FlawOpen

flawopen.com/xss/Python

● CWE-79 · High
FlawOpen Security Research

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

Wrapping user input in Markup, mark_safe or an f-string passed to render_template_string lets a visitor's text run as JavaScript in other users' browsers, with their session (CWE-79). Where Flask, Jinja2 and Django escape for you, the four places they don't, and how to allow rich text safely.

💡 Plain English Explainer (ELI5)

Imagine a print shop with a protected form: text written in the blank field is printed as ordinary text. But if a clerk pastes a customer's note into the printer's instruction sheet before the job starts, the note can become a printing command. A Python f-string passed to render_template_string does that: it inserts input into Jinja2 source before autoescaping can treat it as a variable. Markup and mark_safe are like a stamp saying “already checked HTML”; they do not check it. Keep input in template variables and let autoescape work, or use format_html for a small Django fragment.

Core Concepts & Subsystem Terms

Jinja2 autoescape
Flask enables Jinja2 autoescaping for .html, .htm, .xml and .xhtml templates and for render_template_string, so {{ q }} becomes <img ...>. A standalone jinja2.Environment() has autoescape off unless you pass autoescape=select_autoescape().
Markup / |safe / mark_safe
All three mark a string as already-safe HTML, so the template engine stops escaping it. They are promises made by the developer, not sanitizers: Markup(user_input) or mark_safe(user_input) publishes whatever the user typed.
render_template_string(f"...")
An f-string puts the value into the template source before Jinja2 parses it, so autoescaping never sees it as a variable. The same line is also server-side template injection: a value such as {{ 7*7 }} is evaluated by Jinja2.
django.utils.html.format_html
Builds HTML like str.format, but escapes every argument with conditional_escape first, then marks the result safe. It is the Django-approved way to build a small HTML fragment in Python code instead of mark_safe(f"...").
nh3.clean()
Rich text needs a sanitizer, not escaping. nh3.clean() (Rust's ammonia) keeps an allowlist of tags and attributes and drops <script>, event handlers and javascript: URLs. Bleach is deprecated; nh3 is its maintained successor.

Framework mappings

Step-by-Step Attack Flow

Step 1

Input arrives in a request

A search box, profile form or link parameter sends q, bio or next to the server. A victim can be sent a crafted link, or the value is stored and shown to everyone who views the profile.

Step 2

The value bypasses escaping

An f-string places it in the template source, Markup() or mark_safe() flags it as trusted, or it lands in an href, where escaping < and > is not enough. The benign test value <img src=x onerror=alert(1)> passes through intact.

Step 3

The browser parses it as markup

The response contains <img src=x onerror=alert(1)> as a real element. If the image resource fails to load, onerror fires, and a javascript: URL in the link runs when clicked.

Step 4

The script runs as the site

In the Flask/Jinja and Django paths shown, emitted markup runs under the application origin. Keep untrusted values in autoescaped template data positions; allow HTML only after context-appropriate sanitization.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
# views.py: each handler below lets request text reach the page as HTML
from django.utils.safestring import mark_safe
from flask import Flask, render_template_string, request
from markupsafe import Markup

app = Flask(__name__)


# the f-string builds template source, so Jinja2 never escapes q
@app.get("/search")
def search():
    q = request.args.get("q", "")
    return render_template_string(f"<h1>Results for {q}</h1>")


# Markup() tells Jinja2 the bio is trusted HTML
@app.get("/profile")
def profile():
    bio = request.args.get("bio", "")
    return render_template_string("<div class='bio'>{{ bio }}</div>", bio=Markup(bio))


# mark_safe on an f-string publishes name as HTML
def greeting(name):
    return mark_safe(f"<p>Hello, {name}</p>")


# autoescape does not stop a 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)
HARDENED SECURE PATCH
# views.py: values stay template variables; rich text is sanitized
import nh3
from django.utils.html import format_html
from flask import Flask, render_template_string, request

app = Flask(__name__)


# q is a template variable, so autoescape encodes it
@app.get("/search")
def search():
    q = request.args.get("q", "")
    return render_template_string("<h1>Results for {{ q }}</h1>", q=q)


# nh3 keeps an allowlist of tags before the HTML is trusted
@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 escapes each argument before marking the result safe
def greeting(name):
    return format_html("<p>Hello, {}</p>", name)


# only same-site paths are allowed as the link target
@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)

Engineering & System Hardening Checklist

Sources