flawopen.com/Cross-Site Scripting/Python

Cross-Site Scripting in Python

High CWE-79 Draft — pending review
Language: English Português (Brasil) Español Français Deutsch Русский 简体中文 日本語 हिन्दी 한국어 Bahasa Indonesia
ELI5

Imagine a guestbook where visitors write a public note. Stored XSS is like someone writing a note that isn't just text — it's a hidden trick that makes the guestbook page itself start doing things, like stealing the next visitor's login session, the moment anyone opens the page to read it.

Key terms on this page
output encoding
Converting characters that have special meaning in HTML (like < and >) into harmless equivalents before inserting untrusted text into a page, so the browser displays it as text instead of running it as markup or script.
DOM
The in-memory tree structure a browser builds from a page's HTML — where an element's innerHTML is set determines whether inserted content is rendered as inert text or executable markup.

What's happening

Cross-site scripting happens when untrusted input is rendered into a page without proper output encoding, letting an attacker's own HTML or JavaScript run in another user's browser under your site's identity. In Python web apps, this usually means either bypassing a template engine's default auto-escaping, or building HTML manually with string formatting instead of using the template engine at all.

Real-world impact

In 2005, an 19-year-old user exploited a stored XSS flaw on MySpace to create the "Samy" worm — a script that added itself to every profile that viewed it, infecting over a million profiles within about 20 hours and forcing MySpace offline to contain it. It remains one of the most-cited demonstrations of how quickly stored XSS can self-propagate.

Source: widely documented in security industry retrospectives — see References below.

Vulnerable vs. fixed

VULNERABLE
# username inserted with no escaping
@app.route("/welcome")
def welcome():
    name = request.args.get("name")
    return f"<div>Welcome, {name}</div>"
FIXED
# Jinja2 template auto-escapes by default
@app.route("/welcome")
def welcome():
    name = request.args.get("name")
    return render_template(
        "welcome.html", name=name
    )
# welcome.html: <div>Welcome, {{ name }}</div>

Why the fix works

Flask's Jinja2 templates auto-escape every {{ }} expression by default — special HTML characters in name are converted to their entity equivalents before insertion, so the browser renders them as visible text rather than parsing them as tags or scripts. Building the response with an f-string bypasses the template engine entirely, so nothing escapes anything.

Python-specific gotchas

The |safe filter and Markup() exist specifically to disable escaping

Jinja2 auto-escapes by default, but {{ comment|safe }} or wrapping a value in Markup() tells the engine to trust it as-is. Django's templates work the same way, via |safe or mark_safe() — both are explicit opt-outs, and both are exactly as dangerous as not escaping at all when applied to untrusted input.

Auto-escaping only protects what goes through the template engine

Any response built outside of render_template() — an f-string, manual string concatenation, a raw Response() body — gets none of Jinja2's or Django's protection, regardless of how safely the rest of the app is templated.

JSON responses need a different defense than HTML

Escaping meant for HTML context doesn't protect a value later reflected into JavaScript or a URL — Flask's jsonify() handles JSON-context safety correctly, but manually building a <script> block with an f-string reintroduces the same class of bug in a different context.

Common misconceptions

"Jinja2/Django auto-escapes everything, so I'm safe by default"

True for standard template expressions — false the moment |safe, mark_safe(), or a response built outside the template engine is involved.

"I only need to worry about this in user-submitted comments"

Any reflected value counts — a search query echoed back on a results page, a URL parameter shown in an error message, a filename displayed after upload.

How to check if you're affected

grep -rn "|safe\|mark_safe(\|Markup(" --include="*.html" --include="*.py" . grep -rn "return f\"<\|return \"<.*+\|Response(f\"<" --include="*.py" .
Bandit (rule B701, jinja2_autoescape_false) flags Jinja2 environments configured with autoescape disabled — run it in CI.

Prevention checklist

FAQ

Is this the same bug as SQL injection?

Same underlying shape — untrusted data mixed into a command's structure without encoding — but the target and damage differ: SQL injection targets the database, XSS targets other users' browsers.

Does a Content-Security-Policy header replace the need to escape output?

No — CSP is a valuable second layer of defense that limits what injected script can do, but output encoding is still the primary fix. Relying on CSP alone leaves gaps CSP doesn't cover.

References

View in: Python JavaScript Go Java PHP C# Ruby C/C++ Rust Kotlin Swift Solidity (N/A)
Also see: SQL Injection Command InjectionCSRF Open Redirect