angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

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.

30 augustus 2021 Bijgewerkt 30 augustus 2021
post main image
https://unsplash.com/@francesphotos

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: &euro; 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

Laat een reactie achter

Reageer anoniem of log in om commentaar te geven.

Opmerkingen

Laat een antwoord achter

Antwoord anoniem of log in om te antwoorden.