Bloqueo de recursos inseguros en el correo electrónico HTML mediante BeautifulSoup
La documentación de BeautifulSoup afirma que ahorra a los programadores horas o días de trabajo. Esto es un eufemismo.
He creado un lector de correo electrónico IMAP utilizando IMAPClient y Flask. El lector de correo electrónico IMAP descodifica el correo electrónico en HTML válido. A continuación, tiene que mostrar este HTML a través del navegador. Funciona bien, hasta ahora.
En este post describo cómo he implementado una opción en mi IMAP E-Mail Reader para bloquear los recursos inseguros en el HTML. Para ello, utilizo BeautifulSoup y las operaciones de expresión regular de Python.
Por qué bloquear los recursos no seguros
Los recursos externos en HTML suelen ser archivos que se incluyen en una página web. Ejemplos son images, stylesheets, bibliotecas de JavaScript. El problema es que te conectan a sistemas remotos. Si usted es consciente de la privacidad que desea evitar esto.
En mi navegador Firefox uso uBlockOrigin, esto no es sólo un bloqueador de anuncios. Del sitio web:
'uBlock Origin es una extensión del navegador gratuita y de código abierto, multiplataforma, para el filtrado de contenidos, cuyo objetivo principal es neutralizar la invasión de la privacidad de una manera eficiente y user amigable'.
La mayoría de los correos electrónicos HTML que recibimos contienen enlaces a recursos externos, a menudo images. Al conectarse a una imagen de este tipo puede ser rastreado. Muchos programas de correo electrónico intentan bloquear estos recursos externos por defecto y ofrecen una opción para permitirlos. El resultado puede ser un correo electrónico de aspecto extraño, la privacidad tiene un precio.
También puede haber correos electrónicos HTML con código añadido intencionadamente, Javascript, para hackear tu ordenador. Hay que eliminar este código.
Por qué BeautifulSoup
Aquí hay algunas formas que podemos utilizar para filtrar el correo electrónico HTML :
- re: Python's regular expression operations
- BeautifulSoup: una librería para raspar información de páginas web y modificar el contenido
- Scrapy: un raspado web framework
Python's re es de muy bajo nivel. Lo uso a menudo pero aquí no parece la mejor opción. Scrapy es un framework y probablemente demasiado. Esto deja a BeautifulSoup.
El rendimiento no es realmente tan importante para mi IMAP E-Mail Reader. No necesito filtrar miles de páginas. El filtrado sólo necesita hacerse cuando se muestra un correo electrónico. En un entorno de alto rendimiento podemos optar por almacenar los correos filtrados.
El parser de BeautifulSoup
Al principio experimenté algunos problemas con el parser (por defecto) 'html.parser'. Funcionaba pero en algunas etiquetas de imagen la url de la imagen no era reemplazada. Por supuesto mi error, TLDR. BeautifulSoup recomienda usar el parser lxml o el parser html5lib.
Como yo quería una solución pura de Python opté por el html5parser, que procesa HTML como lo hace un navegador web. Esto es extremadamente importante, ya que sin BeautifulSoup tardaría siglos en escribir un código que pudiera tratar con HTML (intencionadamente) malo.
La salida de BeautifulSoup
BeautifulSoup es una librería para extraer datos de HTML. Eso está bien pero en nuestro caso de uso primero eliminamos elementos y modificamos elementos y luego mostramos el resultado en un navegador.
BeautifulSoup tiene varias opciones de salida pero siempre modifica al menos algunas cosas. A veces esto puede ser bueno, como añadir las etiquetas que faltan, pero en otros casos esto puede no ser lo que queremos. Un poco como, esperemos y veamos.
Cómo bloquear los recursos inseguros
Lo más importante que debemos hacer
- Eliminar completamente los recursos inseguros
- Reemplazar los recursos inseguros
- Arreglar los recursos inseguros
Eliminar completamente los recursos inseguros
Debemos eliminar siempre todos los Javascript del correo electrónico HTML . También queremos eliminar otros elementos como los enlaces a stylesheets.
Reemplazar los recursos inseguros
Si elimináramos images , el correo electrónico podría estropearse. Para evitarlo, sustituimos images en el correo electrónico por una imagen local, un píxel transparente.
Corregir los recursos defectuosos
Algunos enlaces pueden no contener el atributo:
target="_blank"
También queremos añadir el atributo:
rel="noopener noreferrer"
Esto evita pasar la información de referencia al sitio web de destino.
Pero espera, también hay CSS
En el correo electrónico HTML puede haber estilos CSS que hacen referencia a images, fuentes externas. Primero quise usar el paquete CSSUtils pero este no es muy permisivo. Por ejemplo:
background-image: url ('my_url')
genera una excepción porque hay un espacio entre 'url' y '('. Tampoco pude encontrar otro paquete adecuado así que decidí usar las operaciones de expresión regular de Python.
Lo que quiero es reemplazar el código CSS que contiene 'url()' en la parte del valor. En una página HTML podemos tener:
- CSS en línea
- Elementos CSS
Para reducir el código, para el CSS inline eliminamos la propiedad completamente, y para los elementos CSS sustituimos la parte del valor por 'url()'.
El HTMLMailFixer Class
Para filtrar los correos HTML he creado la clase HTMLMailFixer, el código es fácil de entender.
# html_mail_fixer.py
from bs4 import BeautifulSoup
import re
class HTMLMailFixer:
def __init__(
self,
parser='html5lib',
):
self.__parser = parser
self.forbidden = [
'script', 'object', 'iframe',
]
self.__allow_remote_resources = None
self.__block_img_url = None
self.__soup = None
def __remove_forbidden_elems(self):
for elem in self.__soup():
if elem.name in self.forbidden:
# remove
elem.extract()
def __fix_a_elems(self):
for a_elem in self.__soup.find_all('a'):
# add/replace
a_elem['target'] = '_blank'
a_elem['rel'] = 'noopener noreferrer'
def __remove_link_elems(self):
for link_elem in self.__soup.find_all('link'):
link_href = link_elem.get('href')
if link_href is None:
continue
# remove
link_elem.extract()
def __fix_img_elems(self):
for img_elem in self.__soup.find_all('img'):
img_src = img_elem.get('src')
if img_src is None:
continue
# replace
img_elem['src'] = self.__block_img_url
def __fix_style_elems(self):
"""
objective: remove any property with a value starting with 'url('
actual: replace value 'url(url)' by 'url()'
"""
match_url_start_pattern = re.compile(r':\s*url\s*\(', re.I)
for style_elem in self.__soup.find_all('style'):
# 'contents': [
# '\n.section-block {\n padding: 1em;\n background-image: url(https://whatever_image_1);\n}\n#logo {\n margin-top: 10em;\n background-image: url(https://whatever_image_1);\n}\n'
# ]
new_contents = []
for content in style_elem.contents:
chunks = re.split(match_url_start_pattern, content)
for i, chunk in enumerate(chunks):
if i == 0:
continue
chunks[i] = re.sub(r'.*?\)', ')', chunk)
# reconstruct
new_content = ': url(\'\''.join(chunks)
new_contents.append(new_content)
# replace
style_elem.string.replace_with('\n'.join(new_contents))
def __fix_inline_style(self):
"""
search elems with style attribute.
remove any property name:value having a value starting with 'url('
"""
match_url_start_pattern = re.compile(r'^url\s*\(', re.I)
for elem in self.__soup.find_all(attrs={'style': True}):
style_attr = elem['style']
new_props = []
props = style_attr
if ';' in style_attr:
props = style_attr.split(';')
for i, prop in enumerate(props):
if ':' not in prop:
# malformed, skip
continue
name, value = prop.split(':', 1)
if isinstance(value, list):
# malformed, skip
continue
value = value.strip()
if re.match(match_url_start_pattern, value):
# found value starting with 'url(' so skip
continue
new_props.append(name + ': ' + value)
# replace
elem['style'] = '; '.join(new_props)
def fix_all(
self,
html=None,
allow_remote_resources=False,
block_img_url=None,
):
self.__allow_remote_resources = allow_remote_resources
self.__block_img_url = block_img_url
# start soup
self.__soup = BeautifulSoup(html, self.__parser)
# remove, modify html elements
self.__remove_forbidden_elems()
self.__fix_a_elems()
if not self.__allow_remote_resources:
self.__remove_link_elems()
if self.__block_img_url is not None:
self.__fix_img_elems()
self.__fix_inline_style()
self.__fix_style_elems()
# output
output = str(self.__soup)
return re.sub(r'\n\n+', '\n\n', output)
Uso:
from .htm_mail_fixer import HTMLMailFixer
html = 'YOUR_HTML'
allow_remote_resources = False
block_img_url = 'YOUR_PIXEL_URL'
html_mail_fixer = HTMLMailFixer()
html_fixed = html_mail_fixer.fix_all(
html=html,
allow_remote_resources=allow_remote_resources,
block_img_url=block_img_url,
)
Ejemplo
Este es un ejemplo simple con algo de HTML y CSS:
html = """<!DOCTYPE html PUBLIC "- / /w3c / /dtd html 4.01 transitional / /en">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet"><style>
.section-block{background-image: url ( ' https://whatever_image_1') !important;
font-size: 1.1em}
#logo {
margin-top: 10em;background-image: url ( https://whatever_image_1 )}
.wino { color: #ff0000 }
</style>
</head><body style="font: inherit; font-size: 100%; margin:0; padding:0; background-image: url( ' https://whatever_image_2' )">
<a href="https://whatever_a_href_1"><img src="https://whatever_img_src_1" width="60"></a>
<p>Amount: € 1,50</p>
<a href=https://whatever_a_href_2 target=_top><img src=https://whatever_img_src_2 width="60"></a>
<script> a = 'b' </script>
"""
Después de pasarlo al HTMLMailFixer el resultado es:
html_fixed = <!DOCTYPE html PUBLIC "- / /w3c / /dtd html 4.01 transitional / /en">
<html><head><style>
.section-block{background-image: url('') !important;
font-size: 1.1em}
#logo {
margin-top: 10em;background-image: url('')}
.wino { color: #ff0000 }
</style>
</head><body style="font: inherit; font-size: 100%; margin: 0; padding: 0">
<a href="https://whatever_a_href_1" rel="noopener noreferrer" target="_blank"><img src="YOUR_PIXEL_URL" width="60"/></a>
<p>Amount: € 1,50</p>
<a href="https://whatever_a_href_2" rel="noopener noreferrer" target="_blank"><img src="YOUR_PIXEL_URL" width="60"/></a>
</body></html>
Obsérvese que BeautifulSoup ha realizado algunos cambios como la adición de las etiquetas que faltan y la conversión a UTF-8.
¿Cómo sabemos que se han realizado todas las sustituciones?
En cuanto a los elementos HTML , no lo sabemos. Si BeautifulSoup falla, nosotros también. Afortunadamente, el analizador de html5lib analiza como un navegador. En cuanto a las propiedades CSS, hice un reemplazo rápido y sucio. Debo mirar más en detalle cómo CSS puede incluir recursos externos.
Resumen
No fue difícil usar BeautifulSoup, es muy potente y me ahorró mucho tiempo. Sería bueno si hubiera algo similar para analizar y modificar (mal) CCS. De todos modos, el resultado final es que los correos electrónicos HTML se filtran y se muestran en mi navegador con recursos inseguros eliminados y bloqueados. Con un botón puedo permitir los recursos externos.
El bloqueo de recursos externos debería ser más flexible. Por ejemplo, siempre queremos bloquear Facebook, Google, pero permitir otros recursos.
No pretendo que la solución presentada aquí sea perfecta, es sólo un comienzo.
Enlaces / créditos
Beautiful Soup Documentation
https://www.crummy.com/software/BeautifulSoup/bs4/doc/
HTML <link> Tag
https://www.w3schools.com/Tags/tag_link.asp
Leer más
BeautifulSoup
Recientes
- Cómo ocultar las claves primarias de la base de datos UUID de su aplicación web
- Don't Repeat Yourself (DRY) con Jinja2
- SQLAlchemy, PostgreSQL, número máximo de filas por user
- Mostrar los valores en filtros dinámicos SQLAlchemy
- Transferencia de datos segura con cifrado de Public Key y pyNaCl
- rqlite: una alternativa de alta disponibilidad y dist distribuida SQLite
Más vistos
- Usando Python's pyOpenSSL para verificar los certificados SSL descargados de un host
- Usando UUIDs en lugar de Integer Autoincrement Primary Keys con SQLAlchemy y MariaDb
- Conectarse a un servicio en un host Docker desde un contenedor Docker
- Usando PyInstaller y Cython para crear un ejecutable de Python
- SQLAlchemy: Uso de Cascade Deletes para eliminar objetos relacionados
- Flask RESTful API validación de parámetros de solicitud con esquemas Marshmallow