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.

9 min read
ibrahimsql
1,640 words

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:

  1. <script> inserted through innerHTML is parsed but never runs. Event-handler payloads do run.
  2. Escaping has to match the context. HTML escaping does nothing for a javascript: URL.
  3. 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

Python
"""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     :8092

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

JavaScript
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

Text
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>"&gt;
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:

ContextEncoder in the labWhy
HTML body, quoted attributehtml.escape(s, quote=True)<, >, &, ", ' become entities
JS string in a script blockjson.dumps(s) + < to <valid JS string literal, and the HTML parser never sees </script>
hrefallowlist http://, https://, /, then HTML-escapeblocks javascript: and data:
DOMtextContent instead of innerHTMLthe browser treats the value as text, never as markup
Text
blocked /body  <script>alert(1)</script>                | <p>Results for: &lt;script&gt;alert(1)&lt;/script&gt;</p>
blocked /body  <img src=x onerror=alert(1)>             | <p>Results for: &lt;img src=x onerror=alert(1)&gt;</p>
blocked /attr  "><script>alert(1)</script>              | <input name="q" value="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;">
blocked /attr  " autofocus onfocus="alert(1)            | <input name="q" value="&quot; autofocus onfocus=&quot;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 &lt;script&gt;alert(1)&lt;/script&gt;</div>
blocked /dom   <img src=x onerror=alert(1)>             | <div id="greeting">Hello &lt;img src=x onerror=alert(1)&gt;</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.

Text
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 missing base-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.

---
Share this post:

What do you think?

React to show your appreciation

Related Posts