Cross-Site Scripting (XSS): Stored, Reflected and DOM-Based Attacks — and Why mark_safe and Unsafe Markdown Are Equally Dangerous
Django Security Series — Post 2 | Series I: Injection Attacks
OWASP A05:2025 — Injection | Reading time: ~20 min
🧪 Run it yourself. This attack ships as a runnable lab in django-security-lab: a vulnerable comment board that marks user-submitted HTML safe with no sanitiser, and a secure one that runs it through
nh3first. Everything is exercised from the command line — post a payload withcurl, then read it back live on the vulnerable board and stripped on the secure one. Clone it and reproduce every step.
Post 1 of this series covered SQL Injection — an attack that targets your database by breaking the boundary between SQL structure and data. Post 2 moves up the stack to the browser layer: Cross-Site Scripting (XSS), where the boundary being broken is between HTML structure and user-supplied content. Both attacks share the same root cause — treating untrusted input as trusted code — and both are grouped under OWASP A05:2025 Injection for that reason.
This post exists because of a real decision I had to make. Petição Brasil — my civic-tech platform for legally-binding petitions — renders user-submitted petition descriptions as Markdown. That means there’s a mark_safe() call somewhere in the pipeline, and the only thing standing between an attacker's <script> tag and 100% execution in every visitor’s browser is whatever sanitisation happens before that call. When I discovered that bleach — the library I was relying on for that sanitisation — had been placed into minimum-maintenance mode by Mozilla in January 2023, I needed to understand exactly what gap it had and what to replace it with. That research became this post.
Django's template engine auto-escapes HTML by default, which stops most XSS without you noticing. This post is about where that protection ends: the escape hatches that switch it off, the Markdown pipeline that makes reaching for one feel necessary, and moving from bleach to nh3, a Python binding for the Rust-based Ammonia sanitiser.
The Attack: What It Is and How It Works
Cross-Site Scripting occurs when an application includes untrusted data in a web page without proper output encoding — allowing an attacker to execute scripts in a victim's browser in the context of the application's origin. The browser cannot distinguish between scripts the developer intended and scripts the attacker injected: both arrive in the same HTML document, from the same domain, with access to the same cookies, DOM, and storage.
The name 'Cross-Site Scripting' is basically a historical artifact at this point. In reality, it just means an attacker has found a way to run their own code inside your users' browsers. A successful XSS attack gives the attacker the same DOM access that your own JavaScript has: it can read any cookie not marked HttpOnly, capture keystrokes, make authenticated API requests on behalf of the victim, redirect to a phishing page, or mine cryptocurrency in the background.
Stored XSS is the worst-case scenario: the payload is written into the application's data store — a comment, a profile bio, a product review, a forum post — and from that moment on, it executes in every visitor's browser without any further action from the attacker. One submission, unlimited victims. The attacker doesn't need to keep sending phishing links or tricking users into clicking anything; they just wait while the application does the work for them.
The forensics are also unpleasant. The malicious content is served from the application's own origin via normal HTTP responses, so server logs just show ordinary page loads. The victim's browser has no reason to raise an alarm — the script comes from a trusted domain. Django's default SESSION_COOKIE_HTTPONLY = True makes the session cookie unreadable from JavaScript — it doesn't appear in the document.cookie API at all — which closes the most obvious exfiltration path. But XSS can still read CSRF tokens out of the DOM, perform authenticated actions in-page using the existing session, log keystrokes, or rewrite the UI, so HttpOnly is a partial mitigation, not a fix.
Concrete example: a comment field that stores and renders user input without sanitisation. An attacker submits a comment body that, on every page load, reads the CSRF token from the DOM and uses it to perform an authenticated action against the application's own API in the visitor's name — for example, posting another comment, changing the visitor's email address, or following an attacker-controlled account:
<script>
fetch('/api/account/email/', {
method: 'POST',
credentials: 'include',
headers: {'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value},
body: new URLSearchParams({email: 'attacker@example.com'})
});
</script>
The comment is saved to the database. Every subsequent visitor who loads the page silently runs that request from their own browser, with their own session cookie, against the legitimate site — they see nothing unusual. The attacker submitted the payload once; every future page load is an automatic re-execution. (The canonical document.cookie exfiltration payload still appears in older write-ups and demos, but on a stock modern Django app it would not return the session cookie because of HttpOnly; it can still leak any other non-HttpOnly cookies the site sets.)
HTML attributes provide injection points when <script> tags are stripped but event handlers are not: <img src=x onerror=...>, <svg onload=...>, <body onpageshow=...>. In Markdown specifically, link targets are a critical vector: [click me](javascript:alert(document.cookie)) renders as <a href="javascript:alert(document.cookie)">click me</a> — valid Markdown, valid XSS, and a link an allowlist sanitiser has to reject by scheme, which is what bleach and nh3 both do by default and an unsanitised mark_safe() does not.
The same flaw reaches the browser by two other routes. In reflected XSS the payload rides in a URL or a form submission and the response echoes it straight back, stored nowhere: the same defect, but every victim has to be lured to a crafted link. In DOM-based XSS the server's response is clean: the page's own JavaScript reads a source the attacker controls, such as location.hash, and writes it into a sink such as innerHTML, so the payload can sit in a URL fragment Django never receives, and the fix belongs in the JavaScript (textContent, a sanitiser, the CSP directive Trusted Types). And escaping fits only the context it was made for: a value inside a <script> block needs JSON encoding (json_script), an unquoted attribute accepts an event handler, and a link's href runs javascript: unless its scheme is checked.
Real-World Incidents
TweetDeck XSS Worm (2014)
In June 2014, an Austrian teenager who goes by Firo online was experimenting with TweetDeck — Twitter's official power-user dashboard — trying to make it display the unicode ♥ character. He found that TweetDeck treated anything in a tweet ending with the heart as HTML, and showed it publicly with a harmless alert() popup. He told Twitter fourteen minutes later, but by then the flaw was in the wild. Ninety minutes after that, a German IT student, @derGeruhn, turned it into a self-replicating worm: a single tweet containing a <script> block that retweeted itself from the account of every TweetDeck user who loaded it. Twitter shut TweetDeck down temporarily and reopened it once it had verified a fix; by the time the Guardian published, the worm tweet had 81,500 retweets.
The 2014 TweetDeck incident is a perfect real-world example of how fast Stored XSS can spiral out of control — and a useful reminder that vulnerability disclosure practices matter as much as the patch. The formula was painfully simple: a single rendering pipeline without an HTML sanitiser, one user-generated content field, and every subsequent viewer executes whatever was stored. In ATT&CK terms the code execution fits T1059.007 (Command and Scripting Interpreter: JavaScript). Twitter never published the details of its fix, but the class of fix is one change to that pipeline: sanitise tweet content before it is inserted into the DOM — the step nh3.clean() adds between markdown.markdown() and mark_safe() in this post's secure pipeline. For Django developers the parallel is direct: a comment field, a user bio, a product review — any field that stores content from one user and renders it for others becomes this worm's entry point if it reaches the browser without sanitisation.
Source: The Guardian — Tweetdeck vulnerability found by teen trying to code emoji heart (2014)
Django's Default Protections
Django's template engine auto-escapes all variable output by default. Auto-escaping is the process of automatically converting characters that have special meaning in HTML into their safe text equivalents before they are written into the page — so that a value like <script> is rendered as visible text rather than executed as markup. When you write {{ variable }} in a template, Django converts five characters before inserting the value into HTML:
| Character | Escaped as |
|---|---|
< |
< |
> |
> |
' |
' |
" |
" |
& |
& |
(Footnote: Django historically escaped ' to the decimal entity ' and switched to the hex form ' in Django 3.0. Both are valid HTML entity references for the same character, so older code samples or shell sessions may show either form interchangeably.)
A stored payload of <script>alert(1)</script> in a model field renders as <script>alert(1)</script> — harmless visible text in the browser, never parsed as HTML. This happens automatically for every {{ variable }} expression, with no developer action required.
Auto-escaping applies regardless of where the value came from — a database model field, a URL query parameter (request.GET), a form submission (request.POST), or any other origin. The source of the data makes no difference; the template engine escapes every {{ variable }} the same way.
This protection applies to the output encoding step: the final transformation before HTML is sent to the browser. It ends where the developer declares a value safe: mark_safe(), the | safe filter, and {% autoescape off %} disable auto-escaping entirely for the values they touch. These are covered in the next section.
The Vulnerable Pattern and the Fix
1. Marking user content safe
mark_safe() exists for HTML the developer built: a navigation menu assembled in Python, a widget that renders its own markup. It is a declaration to Django: "I have verified this string is safe HTML; render it as markup, not as text." The entire safety guarantee rests on that declaration being true, and Django does not verify it. Make it about content a user wrote and the declaration is false.
The lab's comment board makes that mistake, and the fix is the one call its secure twin adds:
rows = "".join(
- f"<li><strong>{escape(c.author)}</strong>: {as_html(mark_safe(c.body))}</li>"
+ f"<li><strong>{escape(c.author)}</strong>: {as_html(mark_safe(nh3.clean(c.body)))}</li>"
for c in Comment.objects.order_by("id")
)
as_html is the lab's three-line stand-in for template autoescaping: it escapes anything not marked safe, which makes mark_safe() the operative decision, exactly as it would be in a template. On the vulnerable board a stored <img src=x onerror=...> comes back as live markup, and its handler can read the session token out of the page. On the secure one, nh3.clean() has stripped the handler before anything is marked safe, and a legitimate <strong> still renders. Both views are in the lab in full: views_vulnerable.py and views_secure.py.
The template layer makes the same declaration in two other spellings. {{ comment.body | safe }} is mark_safe() on that value, and {% autoescape off %} makes it for every variable in the block, including the ones a later developer adds without noticing the block is there. Neither is fixed by being careful at that line: the value has to be sanitised before it reaches the template.
2. Markdown without a sanitiser
Markdown feels like a layer of separation between the user and the page. It isn't. The Python markdown library deliberately passes raw HTML in its input straight through to its output, so a user can type a literal <script> into a Markdown body. When the application calls mark_safe() on the converted HTML so the formatting renders, that tag reaches the page unmodified. The fix is to sanitise between the two steps:
def render_user_content(text):
html = markdown.markdown(text)
- return mark_safe(html) # <script> in 'text' survives the markdown step intact
+ return mark_safe(nh3.clean(html))
The order is the whole fix: convert, then sanitise, then mark safe. mark_safe() performs no sanitisation itself; it only tells the template engine not to escape a string someone else has already made safe, which is why it has to be the last call and has to run on the sanitiser's output. nh3's defaults are an allowlist that removes disallowed tags, event-handler attributes and javascript: or data: URLs, and you can narrow it by passing tags= as a set and attributes= as a dict mapping each tag to a set of attribute names. There is no flag on the converter that would make it safe instead: since version 3.0 (2018) the library silently ignores its old safe_mode option — passing it raises no error and escapes nothing — and its documentation recommends an HTML sanitiser downstream.
If the sanitiser in your pipeline is bleach, it is doing that job correctly, and you should still replace it. Mozilla deprecated bleach on 2023-01-23 because it is built on the html5lib parser, which has not had a release since 2020, and from then on promised only security fixes, support for new Python versions and fixes for serious bugs. That promise has now ended: 6.4.0, released on 2026-06-05, is the final release, there will be no further fixes of any kind, security fixes included, and the repository is archived. The gap the introduction set out to find isn't there. bleach validates URL schemes just as nh3 does — its ALLOWED_PROTOCOLS default to http, https and mailto — so [click me](javascript:alert(1)), converted to HTML and passed through bleach.clean(), comes out as <a>click me</a>: the href is dropped, the text kept. The reason to leave is not a hole anyone knows about today; it is that the next one found will never be fixed. The migration is small: swap the import, pass tags as a set (nh3 rejects a list with a TypeError, where bleach accepted either), turn each attributes list into a set, rename protocols to url_schemes, drop strip=True (Ammonia always strips), and replace bleach and webencodings with nh3 in requirements.txt.
Sanitisation is the fix; a Content Security Policy is the backstop for the day it fails, whether through a misconfiguration, an allowlist edge case or a vector nobody foresaw. The directive that does the work for XSS is script-src, never with 'unsafe-inline': an inline <script>alert(1)</script> and a <script src="https://attacker.example/…"> from another origin are both refused at execution time. An allowlist like 'self' still trusts anything your own origin serves, such as an uploaded file or a JSONP endpoint, which is why OWASP recommends nonces or hashes, together with object-src 'none' and base-uri 'none'. In Django, the middleware in django-csp adds the header from a CONTENT_SECURITY_POLICY setting. A CSP lowers the severity of an XSS that gets through; it never closes the hole.
It also doubles as a detection tool. Content-Security-Policy-Report-Only serves the same policy but reports violations instead of blocking them: once you name an endpoint with report-uri or report-to, supporting browsers post a JSON report describing what the policy would have refused. On a live site that is the closest thing to a free intrusion-detection signal for XSS. It can surface the injection that slipped past sanitisation, alongside the legitimate scripts a strict policy would break, and it is the safe way to trial a strict policy before enforcing it. Its rollout, along with the full directive set and the nonces and hashes that let legitimate inline scripts through, is the subject of a later post in this series.
The Analyst's View
A scanner hands you XSS findings in bulk, and the CySA+ framing earns its keep at triage, which is where most of the actual work is. The first question is not "is it exploitable" but who renders the stored value, because that sets the blast radius and therefore the priority. A stored finding is one submission and unlimited victims, re-executing from your own origin on every page load, so a field every visitor sees outranks one shown only to the user who wrote it. The second question is what the payload can actually reach: because Django sets HttpOnly on the session cookie by default, an honest write-up says the attacker gets DOM access and authenticated in-page actions — not "session hijacking" — unless the application hands out a token JavaScript can read.
The controls sort cleanly once you have the vocabulary. Allowlist sanitisation before mark_safe() is a preventive control: it removes the dangerous construct rather than watching for it. An enforced Content Security Policy is a second preventive layer, not a substitute for the first: it constrains what the browser will run if sanitisation fails, which is why "we have a CSP" never closes an XSS finding. It lowers the severity; the finding stays open until the sanitiser is fixed. And unlike the three layers in Post 1, this class does come with a genuine detective control: CSP in report-only mode can report what tried to execute and was refused. That report is the closest thing to an intrusion-detection signal the application layer gives you for free.
Catching It Automatically
Testing Your Defence
One test proves the fix against the stored payload: store it, render the board, and check that no event handler reaches the page. This is the one the lab uses:
PAYLOAD = "<img src=x onerror=\"alert(document.getElementById('session-token').textContent)\">"
def test_exploit_is_blocked_on_secure_view(self):
html = self.client.get("/xss/secure/").content.decode()
self.assertNotIn("onerror=", html) # nh3 strips the event handler, so nothing executes
setUp stores PAYLOAD as one comment and a legitimate <strong> as another. The test client runs no JavaScript, so this checks the markup that reaches the browser rather than whether a handler fires; that is enough, because a live onerror attribute is the vulnerability. Point it at /xss/vulnerable/ and it fails. In your own project, run the same assertion through whatever renders user content. For a Markdown filter, add the link case [click me](javascript:alert(1)) and assert that javascript: never reaches the output: that passes behind any allowlist sanitiser and fails behind an unsanitised mark_safe(). The lab's tests.py adds the other two halves: the exploit succeeds on the vulnerable board, and the secure board still renders legitimate rich text.
Scanning It
Post 1's SQL injection was a class the standard tools catch cleanly — Bandit and Semgrep both flagged the vulnerable view and went quiet on the secure one. XSS through mark_safe is where that tidy result falls apart — and seeing how it happens is the lesson.
Bandit — the AST scanner from Post 1 — has a check aimed straight at this class: B703 / B308, use of mark_safe. Point it at the lab's two views:
bandit -r labs/post_02_xss/
It fires on both of them, four findings in all — B308 and B703 on each view. The two B703 entries:
>> Issue: [B703:django_mark_safe] Potential XSS on mark_safe function.
Location: labs/post_02_xss/views_vulnerable.py:32:60
32 f"<li><strong>{escape(c.author)}</strong>: {as_html(mark_safe(c.body))}</li>"
>> Issue: [B703:django_mark_safe] Potential XSS on mark_safe function.
Location: labs/post_02_xss/views_secure.py:28:60
28 f"<li><strong>{escape(c.author)}</strong>: {as_html(mark_safe(nh3.clean(c.body)))}</li>"
Neither check can tell them apart. B308 is a blacklist entry keyed on the call name, and B703 does read the argument, but it treats only string literals (and .format()/% on a literal) as safe and has never heard of nh3 — so mark_safe(c.body) (the bug) and mark_safe(nh3.clean(c.body)) (the fix) get identical findings. The finding on the secure view is a false positive against code that is already correct.
Semgrep's community rules report nothing at all, on either view — but not for the reason you'd guess:
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_02_xss/
# Ran 156 rules on 15 files: 0 findings.
It is not that Semgrep lacks a rule for this. It ships one — avoid-mark-safe — but it is tagged subcategory: audit, confidence: LOW, and the curated packs an analyst actually runs (p/django and friends) don't pull it in; none of the 156 rules that did run target mark_safe. And invoking that rule directly doesn't rescue the result. It skips only string literals and calls nested inside format_html(...), so it fires on mark_safe(c.body) and mark_safe(nh3.clean(c.body)) — it has never heard of nh3, so it flags the sanitised fix just as Bandit does.
So no off-the-shelf check gives a vulnerable-fires / secure-silent result to assert on: the packs say nothing, and the mark_safe checks that exist flag the fix as loudly as the bug. That is the narrow case where a small custom Semgrep rule earns its place; most of the time the community rules are enough.
The rule is deliberately blunt — in effect it is avoid-mark-safe taught to recognise a sanitiser: flag mark_safe() on anything that is not a string literal and not an allowlist-sanitiser/escaper call (nh3.clean, escape, or format_html with a literal format string, since it escapes its arguments but never the format string itself):
# rules/xss.yaml (abridged)
patterns:
- pattern: mark_safe($X)
- pattern-not: mark_safe("...")
- pattern-not: mark_safe(nh3.clean(...))
- pattern-not: mark_safe(escape(...))
- pattern-not: mark_safe(format_html("...", ...))
That is enough to separate the two views — it fires on the vulnerable one and stays silent on the secure one, the clean result neither standard tool could give:
semgrep --config rules/xss.yaml labs/post_02_xss/views_vulnerable.py # 1 finding, line 32
semgrep --config rules/xss.yaml labs/post_02_xss/views_secure.py # 0 findings
It is honest about its own limits. Being syntactic rather than taint-tracking, it cannot see sanitisation that happened a line earlier through a variable — clean = nh3.clean(x); mark_safe(clean) is a false positive it accepts, documented in the rule's test fixture. (Taint mode would catch that too, given a source you declare yourself: a rule whose source is a model-manager query such as $M.objects.$F(...) and whose sanitiser is nh3.clean(...) fires on the vulnerable view and stays silent on the secure one in Semgrep's open-source engine.) It also matches only the bare name mark_safe(...): safestring.mark_safe(x), an import alias and SafeString(x) slip past it, and it never sees templates, so | safe and {% autoescape off %} are left to the test above. In the other direction, from nh3 import clean and bleach.clean count as unsanitised. A semgrep --test fixture pins the rule's behaviour, and CI re-runs both the fixture and the two-view assert on every commit.
All three runs — Bandit, community Semgrep, and the custom rule — are committed under scans/ so you can read what each reported without installing anything; tests.py remains the runnable proof of the vulnerability itself.
Why there is no dynamic scan (DAST). Post 1 could point sqlmap at the running app because SQL injection is a one-parameter, one-request probe a command-line tool drives end to end. Stored XSS takes two steps: submit a payload, then find it rendered in a later response, here a POST and a GET to the same /xss/vulnerable/ URL. A proxy scanner like OWASP ZAP can be handed that request, but this lab has no HTML form, on purpose (you post with curl), so its crawler has nothing to discover, and scripting the POST and the GET for it would repeat what tests.py already does. A browser harness would add the one thing the tests can't, watching the handler fire; it is left out because the static trio (Bandit, community Semgrep, the custom rule) plus the runnable tests.py already show both the bug and its fix.
The habit I took away from this post: treat every mark_safe(), | safe and {% autoescape off %} that touches user input as unfinished until an allowlist sanitiser has run before it. Post 3 moves deeper into the template engine itself: Server-Side Template Injection (SSTI), what happens when user input reaches Django's template renderer directly, and why Jinja2's {{7*7}} is not the only risk.
Further Reading
- Django Docs — Automatic HTML escaping
- Django Docs — Cross-site scripting (XSS) protection
- nh3 — Python bindings for the Ammonia HTML sanitiser
- django-csp — Content Security Policy for Django
- MDN — Content Security Policy (CSP)
- OWASP A05:2025 — Injection
- OWASP XSS Prevention Cheat Sheet
- PortSwigger Web Security Academy — Cross-site scripting
- MITRE ATT&CK — T1059.007 JavaScript
- django-security-lab — the companion repository of runnable Django vulnerability labs
- The custom Semgrep rule for this post —
rules/xss.yaml(with its test fixture,rules/xss.py) - Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 7: Cross-Site Scripting Attacks
- Secure Web Application Development: A Hands-On Guide with Python and Django (Matthew Baker, Apress)
Next in this series → Post 3: Server-Side Template Injection (SSTI): When Django Templates Become a Weapon