Onveilige bronnen blokkeren in HTML e-mail met BeautifulSoup
In de BeautifulSoup documentatie staat dat het programmeurs uren of dagen werk bespaart. Dit is een understatement.
Ik heb een IMAP E-Mail Reader gemaakt met behulp van IMAPClient en Flask. De IMAP e-maillezer decodeert de e-mail in geldige HTML. Dan moet het deze HTML via de browser weergeven. Werkt prima, tot zover alles goed.
In deze post beschrijf ik hoe ik een optie in mijn IMAP E-Mail Reader heb geïmplementeerd om onveilige bronnen in de HTML te blokkeren. Om dit te doen, gebruik ik BeautifulSoup en Python's Reguliere expressie operaties.
Waarom onveilige bronnen blokkeren
Externe bronnen in HTML zijn gewoonlijk bestanden die in een webpagina zijn opgenomen. Voorbeelden zijn images, stylesheets, JavaScript bibliotheken. Het probleem is dat zij u verbinden met systemen op afstand. Als je privacy bewust bent wil je dit vermijden.
In mijn Firefox browser gebruik ik uBlockOrigin, dit is niet zomaar een ad blocker. Van de website:
'De uBlock Origin is een gratis en open-source, cross-platform browserextensie voor het filteren van inhoud-vooral gericht op het neutraliseren van privacy-inbreuk in een efficiënte, user-vriendelijke methode.'
De meeste HTML e-mails die wij ontvangen, bevatten links naar externe bronnen, vaak images. Door verbinding te maken met zo'n afbeelding kunt u worden getraceerd. Veel e-mailprogramma's proberen deze externe bronnen standaard te blokkeren en bieden een optie om ze toe te staan. Het resultaat kan een vreemd uitziende email zijn, privacy heeft een prijs.
Er kunnen ook HTML emails zijn met opzettelijk toegevoegde code, Javascript, om uw computer te hacken. Wij moeten deze code verwijderen.
Waarom BeautifulSoup
Hier zijn enkele manieren die wij kunnen gebruiken om HTML email te filteren:
- re: Python's reguliere expressie operaties
- BeautifulSoup: een bibliotheek om informatie van webpagina's te schrapen en de inhoud te wijzigen
- Scrapy: een web schrapen framework
Python's re is zeer laag niveau. Ik gebruik het vaak, maar hier lijkt het niet de beste keuze. Scrapy is een framework en waarschijnlijk overkill. Dan blijft BeautifulSoup over.
Prestaties zijn niet echt belangrijk voor mijn IMAP E-Mail Reader. Ik hoef niet duizenden pagina's te filteren. Er hoeft alleen gefilterd te worden bij het tonen van een e-mail. In een high performance omgeving kunnen we er voor kiezen om de gefilterde emails op te slaan.
De BeautifulSoup parser
In het begin ondervond ik wat problemen met de (standaard) 'html.parser'. Het werkte wel maar in een paar image tags werd de image url niet vervangen. Natuurlijk mijn fout, TLDR. BeautifulSoup raadt aan om ofwel de lxml parser ofwel de html5lib parser te gebruiken.
Omdat ik een pure Python oplossing wilde heb ik gekozen voor de html5parser, die HTML verwerkt zoals een web browser dat doet. Dit is uiterst belangrijk. Zonder BeautifulSoup zou het eeuwen duren om code te schrijven die om kan gaan met (opzettelijk) slechte HTML.
De uitvoer van BeautifulSoup
BeautifulSoup is een library om gegevens uit HTML te halen. Dat is leuk, maar in onze use case verwijderen we eerst elementen en wijzigen we elementen en tonen dan het resultaat in een browser.
BeautifulSoup heeft verschillende uitvoer opties maar het wijzigt altijd tenminste een paar dingen. Soms kan dat goed zijn, zoals het toevoegen van ontbrekende tags, maar in andere gevallen is dat misschien niet wat we willen. Een beetje zoals, laten we afwachten.
Hoe onveilige bronnen blokkeren
De belangrijkste dingen die we moeten doen:
- Onveilige bronnen volledig verwijderen
- Onveilige bronnen vervangen
- Slechte bronnen repareren
Verwijder onveilige bronnen volledig
We moeten altijd alle Javascript uit HTML e-mail verwijderen. Wij willen ook andere elementen verwijderen zoals links naar stylesheets.
Onveilige bronnen vervangen
Als we images zouden verwijderen dan kan e-mail in de war raken. Om dit te voorkomen vervangen we images in de email door een lokale afbeelding, een transparante pixel.
Slechte bronnen herstellen
Sommige links bevatten het attribuut niet:
target="_blank"
We willen het attribuut ook toevoegen:
rel="noopener noreferrer"
Dit voorkomt het doorgeven van referrer informatie naar de doelwebsite.
Maar wacht, er is ook CSS
In de HTML email kunnen er CSS-stijlen staan die verwijzen naar externe images, fonts. Eerst wilde ik het pakket CSSUtils gebruiken, maar dit is niet erg vergevingsgezind. Bijvoorbeeld:
background-image: url ('my_url')
Genereert een uitzondering omdat er een spatie staat tussen 'url' en '('. Ik kon ook geen ander geschikt pakket vinden dus besloot ik om Python's reguliere expressie operaties te gebruiken.
Wat ik wil is CSS-code vervangen die 'url()' bevat in het waarde gedeelte. In een HTML pagina kunnen we hebben:
- Inline CSS
- CSS elementen
Om de code te reduceren, verwijderen we voor inline CSS de property volledig, en voor CSS elementen vervangen we het value gedeelte door 'url()'.
De HTMLMailFixer Class
Om HTML emails te filteren heb ik de HTMLMailFixer class gemaakt, de code is eenvoudig te begrijpen.
# 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)
Gebruik:
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,
)
Voorbeeld
Dit is een eenvoudig voorbeeld met wat lelijke HTML en 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>
"""
Na het doorgeven aan de HTMLMailFixer is het resultaat:
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>
Merk op dat BeautifulSoup enkele wijzigingen heeft aangebracht zoals het toevoegen van ontbrekende tags en het omzetten naar UTF-8.
Hoe weten we dat alle vervangingen zijn gedaan?
Wat betreft HTML elementen, weten wij dat niet. Als BeautifulSoup faalt, falen wij. Gelukkig parseert de html5lib parser als een browser. Wat betreft de CSS eigenschappen, heb ik een quick and dirty vervanging gedaan. Ik moet meer in detail kijken hoe CSS externe bronnen kan opnemen.
Samenvatting
Het was niet moeilijk om BeautifulSoup te gebruiken, het is erg krachtig en heeft me veel tijd bespaard. Het zou mooi zijn als er iets vergelijkbaars was voor het parsen en aanpassen van (slechte) CCS. Hoe dan ook, het eindresultaat is dat HTML emails worden gefilterd en worden weergegeven in mijn browser met onveilige bronnen verwijderd en geblokkeerd. Met een knop kan ik externe bronnen toestaan.
Het blokkeren van externe bronnen zou flexibeler moeten zijn. Bijvoorbeeld, we willen altijd Facebook, Google blokkeren, maar andere bronnen toestaan.
Ik pretendeer niet dat de hier gepresenteerde oplossing perfect is, het is slechts een begin.
Links / credits
Beautiful Soup Documentation
https://www.crummy.com/software/BeautifulSoup/bs4/doc/
HTML <link> Tag
https://www.w3schools.com/Tags/tag_link.asp
Lees meer
BeautifulSoup
Recent
- Database UUID primaire sleutels van je webapplicatie verbergen
- Don't Repeat Yourself (DRY) met Jinja2
- SQLAlchemy, PostgreSQL, maximum aantal rijen per user
- Toon de waarden in SQLAlchemy dynamische filters
- Veilige gegevensoverdracht met Public Key versleuteling en pyNaCl
- rqlite: een alternatief voor SQLite met hoge beschikbaarheid en distributed
Meest bekeken
- Met behulp van Python's pyOpenSSL om SSL-certificaten die van een host zijn gedownload te controleren
- Gebruik van UUIDs in plaats van Integer Autoincrement Primary Keys met SQLAlchemy en MariaDb
- Maak verbinding met een dienst op een Docker host vanaf een Docker container
- PyInstaller en Cython gebruiken om een Python executable te maken
- SQLAlchemy: Gebruik van Cascade Deletes om verwante objecten te verwijderen
- Flask RESTful API verzoekparametervalidatie met Marshmallow-schema's