FlawOpen

flawopen.com/xss/Python

● CWE-79 · Hoch
Sicherheitsforschung · FlawOpen

XSS (Cross-Site-Scripting) in Python verhindern (Flask, Jinja2, Django)

Wer Nutzereingaben in Markup, mark_safe oder einen an render_template_string übergebenen f-String packt, lässt den Text eines Besuchers als JavaScript im Browser anderer Nutzer laufen – mit deren Sitzung (CWE-79). Wo Flask, Jinja2 und Django für Sie maskieren, die vier Stellen, an denen sie es nicht tun, und wie man Rich Text sicher zulässt.

💡 Einfache Erklärung (ELI5)

Stell dir eine Druckerei mit einem geschützten Formular vor: Text im Leerfeld wird als normaler Text gedruckt. Klebt ein Mitarbeiter jedoch die Notiz eines Kunden vor dem Druck in das Anweisungsblatt des Druckers, kann die Notiz zum Druckbefehl werden. Eine Python-f-string in render_template_string macht genau das: Sie setzt die Eingabe in den Jinja2-Quelltext ein, bevor autoescape sie als Variable behandeln kann. Markup und mark_safe sind wie ein Stempel „HTML bereits geprüft“; sie prüfen nichts. Lass die Eingabe in Template-Variablen und autoescape wirken oder verwende format_html für ein kleines Django-Fragment.

Kernkonzepte & Begriffe

Jinja2 autoescape
Flask aktiviert Jinja2-Autoescaping für .html-, .htm-, .xml- und .xhtml-Templates sowie für render_template_string, aus {{ q }} wird also <img ...>. Ein eigenständiges jinja2.Environment() hat Autoescaping ausgeschaltet, sofern Sie nicht autoescape=select_autoescape() übergeben.
Markup / |safe / mark_safe
Alle drei markieren einen String als bereits sicheres HTML, und die Template-Engine maskiert ihn nicht mehr. Das sind Versprechen des Entwicklers, keine Sanitizer: Markup(user_input) oder mark_safe(user_input) veröffentlicht, was der Nutzer eingegeben hat.
render_template_string(f"...")
Ein f-String setzt den Wert in den Template-Quelltext, bevor Jinja2 ihn parst, das Autoescaping sieht ihn also nie als Variable. Dieselbe Zeile ist zudem Server-Side Template Injection: Ein Wert wie {{ 7*7 }} wird von Jinja2 ausgewertet.
django.utils.html.format_html
Baut HTML wie str.format, maskiert aber zuerst jedes Argument mit conditional_escape und markiert dann das Ergebnis als sicher. So baut man in Django ein kleines HTML-Fragment in Python-Code – statt mit mark_safe(f"...").
nh3.clean()
Rich Text braucht einen Sanitizer, kein Escaping. nh3.clean() (Rusts ammonia) führt eine Allowlist von Tags und Attributen und entfernt <script>, Event-Handler und javascript:-URLs. Bleach ist veraltet; nh3 ist der gepflegte Nachfolger.

Framework-Zuordnungen

Schritt-für-Schritt Angriffsablauf

Schritt 1

Die Eingabe kommt mit einem Request

Ein Suchfeld, ein Profilformular oder ein Link-Parameter schickt q, bio oder next an den Server. Dem Opfer kann ein präparierter Link geschickt werden, oder der Wert wird gespeichert und jedem Profilbesucher angezeigt.

Schritt 2

Der Wert umgeht das Escaping

Ein f-String setzt ihn in den Template-Quelltext, Markup() oder mark_safe() erklären ihn für vertrauenswürdig, oder er landet in einem href, wo das Maskieren von < und > nicht reicht. Der harmlose Testwert <img src=x onerror=alert(1)> kommt unverändert durch.

Schritt 3

Der Browser parst ihn als Markup

Die Antwort enthält <img src=x onerror=alert(1)> als echtes Element. Wenn die Bildressource nicht geladen werden kann, wird onerror ausgelöst, und eine javascript:-URL im Link läuft beim Klick.

Schritt 4

Das Skript läuft im Namen der Website

In den gezeigten Flask/Jinja- und Django-Pfaden wird ausgegebenes Markup unter dem Origin der Anwendung ausgeführt. Nicht vertrauenswürdige Werte gehören in automatisch maskierte Template-Datenpositionen; HTML ist nur nach kontextgerechter Bereinigung zulässig.

Quellcode: Verwundbar vs. Sicher

VERWUNDBARE IMPLEMENTIERUNG
# views.py: jeder Handler unten lässt Request-Text als HTML in die Seite
from django.utils.safestring import mark_safe
from flask import Flask, render_template_string, request
from markupsafe import Markup

app = Flask(__name__)


# der f-String baut Template-Quelltext, Jinja2 maskiert q also nie
@app.get("/search")
def search():
    q = request.args.get("q", "")
    return render_template_string(f"<h1>Results for {q}</h1>")


# Markup() sagt Jinja2, dass die Bio vertrauenswürdiges HTML ist
@app.get("/profile")
def profile():
    bio = request.args.get("bio", "")
    return render_template_string("<div class='bio'>{{ bio }}</div>", bio=Markup(bio))


# mark_safe auf einem f-String veröffentlicht name als HTML
def greeting(name):
    return mark_safe(f"<p>Hello, {name}</p>")


# Autoescaping stoppt keine javascript:-URL in href
@app.get("/continue")
def continue_link():
    url = request.args.get("next", "/")
    return render_template_string('<a href="{{ url }}">Continue</a>', url=url)
GEHÄRTETER SICHERHEITS-PATCH
# views.py: Werte bleiben Template-Variablen; Rich Text wird bereinigt
import nh3
from django.utils.html import format_html
from flask import Flask, render_template_string, request

app = Flask(__name__)


# q ist eine Template-Variable, Autoescaping kodiert sie
@app.get("/search")
def search():
    q = request.args.get("q", "")
    return render_template_string("<h1>Results for {{ q }}</h1>", q=q)


# nh3 wendet eine Tag-Allowlist an, bevor dem HTML vertraut wird
@app.get("/profile")
def profile():
    bio = request.args.get("bio", "")
    return render_template_string("<div class='bio'>{{ bio|safe }}</div>", bio=nh3.clean(bio))


# format_html maskiert jedes Argument, bevor das Ergebnis als sicher gilt
def greeting(name):
    return format_html("<p>Hello, {}</p>", name)


# nur Pfade der eigenen Website sind als Linkziel erlaubt
@app.get("/continue")
def continue_link():
    url = request.args.get("next", "/")
    if not url.startswith("/") or url.startswith("//"):
        url = "/"
    return render_template_string('<a href="{{ url }}">Continue</a>', url=url)

Checkliste für Engineering & Systemsicherheit

Quellen