Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix cross-site scripting in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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://.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Flask 3.1.3 with Jinja2 templates
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks8 cards
Download the project ZIP, 22 KB
On this page

Steps

  1. Find every |safe filter, Markup(...) call, {% autoescape false %} block and render_template_string on untrusted data; each one turns autoescaping off for that value.
  2. Delete them for user data and render plain {{ value }}; Flask autoescapes .html, .htm, .xml, .xhtml and .svg templates, escaping & < > " and '.
  3. Keep attribute values quoted (alt="{{ name }}") so an escaped quote cannot open a new attribute.
  4. 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.
  5. 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.
  6. Keep debug mode off in production: the debugger page echoes the input and traceback to whoever triggers an error.

Vulnerable, fixed and wrong

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

Vulnerabletemplates/profile/vulnerable.htmlHTML
<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

Fixedtemplates/profile/fixed.htmlHTML
<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

Fixedtemplates/profile/fixed.htmlHTML
{# 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

Wrong fixtemplates/profile/wrong.htmlHTML
{# Print raw so entities stored in old profiles (&eacute;) still render,
   but escape < and > so no tag can be injected. #}
{% set name_html = name|replace("<", "&lt;")|replace(">", "&gt;")|safe %}
{% set bio_html = bio|replace("<", "&lt;")|replace(">", "&gt;")|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 regression test

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.

Testtests/test_profile.pyPython
# 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)"))

Pitfalls

  • |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.

Verify the fix

  1. 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).
  2. Run semgrep --config r/python.flask.security.xss.audit.template-unescaped-with-safe templates; expect findings in vulnerable.html only.
  3. 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.

Scanner rules

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.

Scanner rules for Fix cross-site scripting in Python and Flask
ToolRuleWhat it found here
Semgreppython.flask.security.xss.audit.template-unescaped-with-safe.template-unescaped-with-safeFlagged 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)
Semgreppython.flask.security.xss.audit.template-unquoted-attribute-var.template-unquoted-attribute-varNo 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)
CodeQLpy/reflective-xssModels 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
BanditB701jinja2_autoescape_falseNo 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3 with Jinja2 templates
flask3.1.3
jinja23.1.6
markupsafe3.0.3
werkzeug3.1.9
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/python-flaskfolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 22 KB

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

  1. OWASP Cross Site Scripting Prevention Cheat Sheet
  2. Flask: Templates (autoescaping, controlling autoescaping)
  3. Flask: Security Considerations (XSS, unquoted attributes, javascript: URIs)
  4. MarkupSafe: escaping and Markup (format/% escape their arguments)
  5. Jinja: template designer docs (the |safe filter)
  6. nh3: Python bindings for the Ammonia HTML sanitizer
  7. WHATWG URL Standard: URL parsing (scheme, tab and newline removal)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.