Let Jinja autoescaping do the work: render {{ value }} with no |safe filter and no Markup() around user input, so & < > " and ' become entities in both text and quoted attributes. A URL is different: javascript:alert(1) has nothing to escape, so link only values that start with http:// or https://.
- Find every |safe filter, Markup(...) call, {% autoescape false %} block and render_template_string on untrusted data; each one turns autoescaping off for that value.
- Delete them for user data and render plain {{ value }}; Flask autoescapes .html, .htm, .xml, .xhtml and .svg templates, escaping & < > " and '.
- Keep attribute values quoted (alt="{{ name }}") so an escaped quote cannot open a new attribute.
- For a URL in href or src, check the value with urlsplit and allow only an http/https (or mailto) scheme, or a relative URL; do not depend on escaping.
- For rich text that must keep some HTML, sanitize it with a maintained allowlist library such as nh3 (Ammonia) and wrap the result in Markup only then; never a regex.
- Keep debug mode off in production: the debugger page echoes the input and traceback to whoever triggers an error.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
<img src="/avatar.svg" alt="{{ name|safe }}" width="64" height="64">
<h1>{{ name|safe }}</h1>
<p class="bio">{{ bio|safe }}</p>With IMPL=vulnerable, 7 of 9 tests fail (5 exploit tests).
The fix: output encoding
<img src="/avatar.svg" alt="{{ name }}" width="64" height="64">
<h1>{{ name }}</h1>
<p class="bio">{{ bio }}</p>With IMPL=fixed, 9 of 9 tests pass.
The fix: URL scheme allowlist
{# Escaping cannot make a URL safe: javascript:alert(1) has nothing to escape.
Link only to http and https URLs. #}
{% if website.startswith(("https://", "http://")) %}
<a href="{{ website }}">Website</a>
{% endif %}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
{# Print raw so entities stored in old profiles (é) still render,
but escape < and > so no tag can be injected. #}
{% set name_html = name|replace("<", "<")|replace(">", ">")|safe %}
{% set bio_html = bio|replace("<", "<")|replace(">", ">")|safe %}
<img src="/avatar.svg" alt="{{ name_html }}" width="64" height="64">
<h1>{{ name_html }}</h1>
<p class="bio">{{ bio_html }}</p>With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: what a browser would run from the page must stay inert.
def assert_nothing_executable(page: Page) -> None:
"""No script elements, no event-handler attributes, and only http(s) links."""
assert [e.text for e in page.elements if e.tag == "script"] == [], "script element"
handlers = [f"{e.tag} {name}" for e in page.elements for name, _ in e.attrs if name.startswith("on")]
assert handlers == [], "event-handler attribute"
for href in page.links:
assert url_scheme(href) in ("", "http", "https"), f"link to a {url_scheme(href)}: URL: {href!r}"
def test_exploit_script_element_in_the_bio_does_not_run(client):
assert_nothing_executable(profile(client, name="Mallory", bio="<script>alert(document.domain)</script>"))
def test_exploit_event_handler_attribute_in_the_bio_does_not_run(client):
assert_nothing_executable(profile(client, name="Mallory", bio="<img src=x onerror=alert(document.domain)>"))
def test_exploit_quote_in_the_name_cannot_break_out_of_the_alt_attribute(client):
name = '" onerror="alert(document.domain)'
page = profile(client, name=name)
assert_nothing_executable(page)
assert avatar_alt(page) == name
def test_exploit_javascript_website_is_not_linked(client):
assert_nothing_executable(profile(client, name="Mallory", website="javascript:alert(document.domain)"))
def test_exploit_tab_inside_the_scheme_does_not_smuggle_a_javascript_url_through(client):
# Browsers delete tabs and newlines from a URL before parsing it.
assert_nothing_executable(profile(client, name="Mallory", website="java\tscript:alert(document.domain)"))- |safe and Markup() are the same switch: both mark a string as already-safe HTML so Jinja will not escape it. Reserve them for HTML you built, never for request data.
- Markup is not a sanitizer. Markup(user_input) wraps the raw string untouched; only Markup.escape(user_input) escapes. Markup('<b>%s</b>') % user_input does escape the argument.
- render_template_string(user_input) compiles the string as a template, which is server-side template injection on top of XSS. Pass a fixed template and feed data as context.
- Autoescaping keys off the template name. A string template or a non-.html extension (for example .txt) is not autoescaped unless you enable it; render_template_string is autoescaped.
- HTML-escaping a URL does not disarm javascript:alert(1); it has no HTML metacharacters. URLs need a scheme allowlist, not escaping.
- A blocklist such as website.lower().startswith('javascript:') misses a leading space, a tab or newline inside the scheme, and other schemes (data:, vbscript:). Allowlist http/https instead.
- Jinja's xmlattr filter and building attribute names from input can inject attributes even when values are escaped; keep attribute names fixed.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs python-flask does this for you).
- Run semgrep --config r/python.flask.security.xss.audit.template-unescaped-with-safe templates; expect findings in vulnerable.html only.
- Against your own endpoint, send bio=<img src=x onerror=alert(1)> and website=javascript:alert(1) and confirm the tag shows as text and the link is dropped.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.flask.security.xss.audit.template-unescaped-with-safe.template-unescaped-with-safe | Flagged the three {{ ... | safe }} expressions in templates/profile/vulnerable.html; no findings in fixed.html (plain {{ }}) or wrong.html, where the |safe sits inside a {% set %} and the regex only looks between {{ and }}. The wrong template's broken escaping is therefore the case automation misses here.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | python.flask.security.xss.audit.template-unquoted-attribute-var.template-unquoted-attribute-var | No findings in any template: every attribute that holds a variable (alt="{{ name }}") is quoted, which is what this rule checks for. Recorded to show the unquoted-attribute vector was tested, not just the raw-output one.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | py/reflective-xss | Models Flask: semmle/python/frameworks/Flask.qll treats request data as a source and render_template / render_template_string as template sinks. py/jinja2/autoescape-false is the companion that flags Jinja environments built with autoescape=False.Checked: github/codeql main at b5b8165; not executed |
| Bandit | B701jinja2_autoescape_false | No findings: this card renders with render_template, which autoescapes .html, and never constructs a jinja2.Environment with autoescape=False. Bandit analyses Python, not the |safe filter inside templates, so it does not see the vulnerable path; the regression tests are the control.Checked: Bandit 1.9.4 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 with Jinja2 templates |
| flask | 3.1.3 |
| jinja2 | 3.1.6 |
| markupsafe | 3.0.3 |
| werkzeug | 3.1.9 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/python-flaskfolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xss/python-flask runs all three modes and checks the expected results.
Verified
- Toolchain
- python 3.14.8
- flask 3.1.3
- jinja2 3.1.6
- markupsafe 3.0.3
- werkzeug 3.1.9
- pytest 9.1.1
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (5 exploit tests); wrong: 4 of 9 tests fail (2 exploit tests)
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross Site Scripting Prevention Cheat Sheet
- Flask: Templates (autoescaping, controlling autoescaping)
- Flask: Security Considerations (XSS, unquoted attributes, javascript: URIs)
- MarkupSafe: escaping and Markup (format/% escape their arguments)
- Jinja: template designer docs (the |safe filter)
- nh3: Python bindings for the Ammonia HTML sanitizer
- WHATWG URL Standard: URL parsing (scheme, tab and newline removal)
Attack patterns: