Let Django's autoescaping work: render {{ value }} without |safe, mark_safe() or {% autoescape off %}, so < > ' " and & become entities in text and quoted attributes. Build HTML in Python with format_html(), never f-strings. URLs differ: javascript:alert(1) has nothing to escape, so link only http:// and https:// values.
- Find every place autoescaping is switched off: |safe and |safeseq in templates, {% autoescape off %}, mark_safe() and SafeString in Python, and format_html() calls whose format string is built from input.
- Remove |safe from request and database values; Django escapes {{ value }} in text and in quoted attributes by default.
- Build HTML fragments in Python with format_html('<b>{}</b>', value), which escapes every argument, instead of mark_safe(f'...').
- For links, allow only http and https URLs: a small template filter (is_web_url here) or a check in the view, and always quote attribute values.
- Where users really need formatting, render a restricted markup language or run an HTML sanitizer such as nh3 with an allowlist, then mark only its output safe.
- Add regression tests that parse the rendered page and assert there are no script elements, no on* attributes and no javascript: links.
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
{% if website|is_web_url %}
<a href="{{ website }}">Website</a>
{% endif %}With IMPL=fixed, 9 of 9 tests pass.
The fix: the is_web_url filter
@register.filter
def is_web_url(value: str) -> bool:
"""True only for http and https URLs. Escaping cannot make a URL safe: javascript:alert(1)
has nothing to escape, and browsers ignore leading spaces and tabs inside the scheme."""
return isinstance(value, str) and value.startswith(("https://", "http://"))With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
{# striptags removes every tag, so the text can go out raw and entities in old profiles (é) still render. #}
<img src="/avatar.svg" alt="{{ name|striptags|safe }}" width="64" height="64">
<h1>{{ name|striptags|safe }}</h1>
<p class="bio">{{ bio|striptags|safe }}</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 mark_safe() are the same switch: both tell Django the string is already-safe HTML. Reserve them for HTML you built from constants or with format_html().
- striptags is not a sanitizer. Django's documentation says to never apply |safe to its output; it keeps quotes, so a name such as " onerror="alert(1) still breaks out of an attribute, and it mangles legitimate text that contains < and >. That is this card's wrong implementation.
- A string marked safe becomes unsafe again when you modify it: mark_safe(a) + b escapes nothing of b, but the result is a plain str again, and wrapping the whole thing in mark_safe() trusts b too.
- {% autoescape off %} and Engine(autoescape=False) switch escaping off for a whole block or engine; templates rendered outside Django's template engine (str.format, string.Template) are never escaped.
- Escaping protects quoted attribute values only: alt={{ name }} without quotes lets a space start a new attribute. Quote every attribute.
- HTML-escaping a URL does not disarm javascript:alert(1). A blocklist such as website|lower|slice:":11" != "javascript:" misses a tab inside the scheme and other schemes such as data:; allowlist http and https instead.
- json_script is the safe way to hand data to JavaScript; {{ value|safe }} inside a <script> block is XSS even when the value is JSON.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs xss/python-django does this for you).
- Against your own page, submit <img src=x onerror=alert(1)> and " onerror="alert(1) in every text field and java\tscript:alert(1) as a URL, and check the rendered HTML: the markup must appear as text and the link must be absent.
- grep templates for |safe, safeseq and autoescape off, and Python for mark_safe and SafeString; Semgrep's template-unescaped-with-safe rule found both failing templates here.
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 | Filed under Flask, but it is a regular expression over HTML templates, so it also reads Django's: it flagged the three {{ ... |safe }} lines in vulnerable.html and the three {{ ...|striptags|safe }} lines in wrong.html, and nothing in fixed.html. It is in p/security-audit.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> profiles config (Ran 1 rule) |
| Semgrep | generic.html-templates.security.var-in-href.var-in-href | Flagged href="{{ website }}" in all three templates, the fixed one included: it cannot see the is_web_url check around the link, so treat it as a reminder to look, not as a verdict.Checked: Semgrep 1.180.0 (CE); in p/security-audit |
| Semgrep | python.django.security.audit.xss.template-autoescape-off.template-autoescape-off | No findings: no template uses {% autoescape off %}. Semgrep's Django XSS rules cover autoescape off, safeseq, mark_safe and format_html misuse in Python, but not the |safe filter itself.Checked: Semgrep 1.180.0 (CE) (Ran 1 rule); also p/django (28 rules) |
| CodeQL | py/reflective-xss | Not expected to flag this card: CodeQL does not read Django template files, so |safe and striptags are invisible to it. It models request data reaching an HttpResponse body, which this card never builds by hand.Checked: github/codeql main at b5b8165; not executed |
| Bandit | B308, B703mark_safe and django_mark_safe | No findings: these checks look for mark_safe() calls in Python, and this card's unsafe output is the |safe filter inside templates, which Bandit does not read.Checked: Bandit 1.9.4; bandit -r profiles config |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Django 6.1.2 with Django templates |
| django | 6.1.2 |
| pytest | 9.1.1 |
| pytest-django | 4.14.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/python-djangofolder.
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-django runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- django 6.1.2
- pytest 9.1.1
- pytest-django 4.14.0
- 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)
- Test results
- fixed 9/9 pass; vulnerable 7/9 fail (5 exploit tests); wrong 4/9 fail (2 exploit tests), offline after install
- Dependency freshness
- Django 6.1.2 was published on 2026-10-06 and is taken at four days old because it is a security release (CVE-2026-77050, CVE-2026-84429, CVE-2026-87890); pytest-django 4.14.0 dates from 2026-08-10. iniconfig is pinned at 2.3.0 rather than 2.3.1 (2026-10-06), which was four days old and is not a security release. OSV reported no known vulnerabilities for any pin. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
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
- Django 6.1: Cross site scripting (XSS) protection
- Django 6.1: Automatic HTML escaping in templates
- Django 6.1 built-in filters: safe, escape and striptags (no safety guarantee)
- Django 6.1: django.utils.html.format_html() and mark_safe()
- Django 6.1 built-in filters: json_script
- WHATWG URL Standard: URL parsing (tabs and newlines removed)
- nh3: Python bindings to the ammonia HTML sanitizer
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Attack patterns: