Handbooks / Web Security (OWASP) / Chapter 2
Cross-Site Scripting (XSS)
7 pages · ~5 min✓ Reviewed
Builds on Injection. Next up: Authentication and Sessions.
XSS Mental Model: Stored, Reflected, DOM
1. What XSS Is
- Attacker data runs as code in the victim's browser
- Runs with the page's origin: reads DOM, sends requests, acts as the user
2. Three Types
| Stored | Reflected | DOM | |
|---|---|---|---|
| Payload | saved (comment, profile name) | in request (query, form field) | client source to sink |
| Needs a click? | no | yes, crafted URL | yes |
| Server sees it | yes | yes | often never (#fragment) |
| Impact | every viewer | one victim | one victim |
3. DOM: Source to Sink
- Sources:
location.hash,location.search,document.referrer,postMessage - Sinks:
innerHTML,document.write,eval,setTimeout(string),element.href location.hash,postMessageskip the server;location.searchis sent
javascript
// #<img src=x onerror=alert(1)> el.innerHTML = decodeURIComponent(location.hash.slice(1)) el.textContent = decodeURIComponent(location.hash.slice(1))
innerHTML runs the alert; textContent is safe
4. Interview Point
- Never reaches the server? DOM XSS via #fragment; grep for sources and sinks
- Stored worse? No crafted link; hits every viewer
- Sources, sinks?
location.hash,postMessage;innerHTML,eval
Context-Aware Output Encoding
1. The Core Rule
- Encode on output, for the exact context where the data lands
- Input validation is defence in depth, not the fix
- Each context has its own encoder
2. One Encoder per Context
- Untrusted data
- Find the context
- Apply its encoder
- Output
| Context | Encoder | Result |
|---|---|---|
| HTML body | < > & " ' to entities | < > & " ' |
| HTML attribute | quote value, encode quotes | value="..."..." |
| JS string | \xHH or JSON.stringify in a data attribute | \x3c |
| URL parameter | encodeURIComponent | a%26b |
| URL scheme | allowlist http / https | never javascript: |
| CSS | its own CSS escaper | \3c |
3. Common Mistakes
- Unquoted attribute: a space breaks out,
x onmouseover=alert(1) - HTML entities do not stop a
javascript:href - HTML-escaping a value placed in
<script>is the wrong encoder
Right encoder, right context
Quote every attribute and allowlist http/https for URLs.
CSP and HttpOnly Cookies as Containment
1. CSP: Second Layer
- Response header listing allowed script sources
- Limits damage when an escaping bug slips through
- Not a replacement for output encoding
http
Content-Security-Policy: default-src 'self'; script-src 'nonce-R4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'
nonce: fresh per response, unguessable
| In script-src | Result |
|---|---|
'unsafe-inline' (no nonce/hash) | injected inline script runs |
'unsafe-eval' | eval() allowed |
https:, *.cdn.com | any script on those hosts runs |
- Inline event handlers are blocked unless allowed
- Roll out with
Content-Security-Policy-Report-Only+report-to
2. HttpOnly Cookies
- HttpOnly hides the session cookie from
document.cookie - Add
SecureandSameSite=LaxorStrict - Does NOT stop XSS sending authenticated requests as the user
3. Interview Point
- HttpOnly? Stops cookie theft, not in-page requests as the user
- Nonce vs allowlist? Allowed hosts can serve usable scripts; a nonce trusts only your tags
- Deploy safely? Report-Only, fix reports, then enforce
Framework Auto-Escaping and Its Escape Hatches
1. Defaults and hatches
- Escaped by default: JSX, Angular, Vue
{{ }}, Django, Rails - Jinja2 only with autoescape on (Flask does for .html)
| Hatch (escaping off) | Safe | |
|---|---|---|
| React | dangerouslySetInnerHTML | {text} |
| Vue | v-html | {{ }} |
| Angular | [innerHTML] + bypassSecurityTrustHtml | {{ }} |
| Jinja2 | |safe, autoescape off | default |
| Django | mark_safe | {{ }} |
| Rails | raw, html_safe | <%= %> |
2. Raw HTML? Sanitise
- Allowlist sanitiser: DOMPurify (client) or a server-side equivalent
- Run it immediately before rendering
- Never write your own regex filter
javascript
el.innerHTML = DOMPurify.sanitize(dirty)
3. Still unprotected
href/srcwithjavascript:URLsstyleand event-handler attributes- Server-side template injection
- Direct DOM writes:
ref.innerHTML
Text positions only
Allowlist http/https schemes for user URLs.
- Three hatches, fixes?
v-html,|safe,mark_safe: use default{{ }}escaping - Why is
<a href={userUrl}>risky? React 18 and earlier runjavascript:on click; React 19 blocks it, still allowlist - Sanitiser instead of encoding? When users must supply real HTML
Common XSS Mistakes
1. Filters and encoders
| Mistake | Bypass | Fix |
|---|---|---|
Blacklist <script> | <img src=x onerror=…>, <svg onload=…>, case, nested tags | Encode + allowlist |
| Wrong context | HTML-escaped value in a JS string, unquoted attribute or URL | Encoder per exact context |
2. Sinks and containment
innerHTML,document.writeparse markuptextContent,innerTextnever do
javascript
el.innerHTML = userText; // parses markup el.textContent = userText; // plain text
one-line fix
- HttpOnly alone: XSS still sends requests as the user
- CSP alone: it is only a second layer
'unsafe-inline'with no nonce or hash: injected inline script runs
Useless CSP
Without a nonce or hash, unsafe-inline lets injected inline script run, so CSP stops almost no XSS.
3. Interview Point
- Why does a blacklist fail? Too many tags, events and case tricks; allowlist instead.
- Fix for innerHTML with user text? Use
textContent. - Which mistake makes CSP useless?
'unsafe-inline'without a nonce or hash.
XSS Defence Cheat Sheet
1. Three types
| Type | Payload | Server sees |
|---|---|---|
| Stored | saved, hits all viewers | yes |
| Reflected | in the request, needs a link | yes |
| DOM | client source to sink, e.g. #fragment | often never |
2. Encode by context
| Context | Encoding |
|---|---|
| HTML body | entities < > & |
| Attribute | quote it, encode quotes |
| JS string | \xHH or JSON |
| URL | encodeURIComponent + http/https allowlist |
3. Layers, in order
- Auto-escapetemplates
- Sanitiserallowlist, rich HTML
- CSPnonce + strict-dynamic
- CookiesHttpOnly, Secure, SameSite
4. Audit and interview
bash
rg -n "innerHTML|outerHTML|document\.write|eval|dangerouslySetInnerHTML|v-html|\|safe|mark_safe|bypassSecurityTrust"sinks to grep in review
- Comment box? Escape on output, sanitise rich HTML, nonce CSP, HttpOnly cookie
- Stop and miss? Escaping misses hatches; CSP and HttpOnly contain, they do not stop requests
- Audit? Grep sinks, trace sources to them, check CSP for
unsafe-inline
Check yourself
A page runs el.innerHTML = location.hash.slice(1). Which XSS type is this, and will server logs show the payload?
- DOM XSS: source
location.hashflows into sinkinnerHTML. - The fragment is never sent to the server, so logs see nothing.
- Fix: use
textContent.
A user URL is HTML-escaped, then placed in <a href="...">. The user submits javascript:alert(1). Does it run?
- Yes, on click: that string has no characters that HTML-escaping changes.
- Allowlist the scheme (http/https only).
The CSP is script-src 'self' 'unsafe-inline'. An attacker injects an inline <script>. Is it blocked?
- No:
'unsafe-inline'lets injected inline script run. - Use a per-response nonce with
'strict-dynamic'instead.