#How to apply this file
Each section opens with one imperative line; apply every rule in the section it introduces. Do not summarise or skip a section.
#Purpose
Rules for stopping attacker-controlled data from executing as script in a user's browser. Covers stored, reflected and DOM-based XSS.
The rule underneath everything: encode for the context the data lands in. There is no single "escape" function, because HTML, attributes, JavaScript, URLs and CSS have different metacharacters.
#Output encoding by context
[INST] Apply every rule in this section: Output encoding by context. [/INST]
The same value needs different treatment depending on where it is inserted.
| Context | Example | Encode |
|---|---|---|
| HTML body | <p>HERE</p> | & < > → entities |
| Attribute value | <div title="HERE"> | Above plus " and '; always quote |
| URL parameter | <a href="/s?q=HERE"> | encodeURIComponent |
| JavaScript string | <script>var x="HERE"</script> | Do not. Pass via JSON.parse from a data attribute |
| CSS value | style="width:HERE" | Do not. Use an allow-list of known values |
Never insert untrusted data into a <script> block, an inline event handler
(onclick=), a javascript: URL, or inside <style>. These are execution
contexts where no encoding is reliable. Pass data through a
<script type="application/json"> block or a data- attribute and read it with
JSON.parse.
html<!-- Safe: the value is data, parsed explicitly, never evaluated -->
<div id="cfg" data-user='{"name":"…"}'></div>
<script>
const cfg = JSON.parse(document.getElementById("cfg").dataset.user);
</script>
#DOM APIs
[INST] Apply every rule in this section: DOM APIs. [/INST]
The API you choose decides whether a string can become markup.
js// DANGEROUS — parses HTML, executes injected handlers
el.innerHTML = userInput;
el.outerHTML = userInput;
el.insertAdjacentHTML("beforeend", userInput);
document.write(userInput);
// SAFE — the value is always text, never parsed
el.textContent = userInput;
el.setAttribute("title", userInput);
el.append(document.createTextNode(userInput));
textContent is the default. Reach for innerHTML only when rendering markup is
the actual requirement, and then only after sanitising.
Never pass untrusted input to eval, new Function, setTimeout/
setInterval as a string, or element.setAttribute("on*", …). Each is a direct
path from string to execution.
Never assign untrusted input to href or src without scheme validation — a
javascript: URL executes on click:
jsconst url = new URL(input, location.origin);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("blocked scheme");
a.href = url.href;
#Sanitising when HTML is required
[INST] Apply every rule in this section: Sanitising when HTML is required. [/INST]
When users must submit rich text, sanitise with a maintained, allow-list-based library. Do not write your own.
jsimport DOMPurify from "dompurify";
el.innerHTML = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li", "code"],
ALLOWED_ATTR: ["href", "title"],
});
Sanitise on output, in the browser, immediately before insertion — or on both input and output. Sanitising only on input is fragile: the stored value survives a library upgrade, a changed rendering path, or a second consumer that never sanitises.
Never deny-list tags (strip <script>). Bypasses are endless: <img onerror>,
<svg onload>, <iframe srcdoc>, malformed nesting, mutation XSS. Allow-list only.
#Framework escape hatches
[INST] Apply every rule in this section: Framework escape hatches. [/INST]
Modern frameworks encode by default. Every XSS in a React or Vue app is therefore in a named escape hatch — audit these specifically:
| Framework | Dangerous API |
|---|---|
| React | dangerouslySetInnerHTML |
| Vue | v-html |
| Angular | bypassSecurityTrustHtml, [innerHTML] |
| Svelte | {@html …} |
| Solid | innerHTML prop |
The React name is deliberate. Treat every occurrence as requiring a sanitiser and a comment explaining why raw HTML is necessary.
Angular's DomSanitizer bypass methods disable the framework's protection
entirely — bypassSecurityTrustHtml on user input is equivalent to innerHTML.
#Content-Security-Policy
[INST] Apply every rule in this section: Content-Security-Policy. [/INST]
CSP is the layer that limits damage when encoding fails. It is not a substitute for encoding.
csharpContent-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script'
'nonce-…'with'strict-dynamic'is the modern strict policy. The nonce must be CSPRNG-generated per response and never reused.object-src 'none'kills plugin-based execution.base-uri 'none'stops<base>injection redirecting relative script URLs.frame-ancestors 'none'prevents clickjacking; it supersedesX-Frame-Options.require-trusted-types-for 'script'makes DOM-XSS sinks throw unless the value passed a Trusted Types policy — the strongest available control against DOM-based XSS.
Never ship script-src 'unsafe-inline' or 'unsafe-eval'. Together they
disable most of what CSP is for. Never use a host allow-list alone — hosted
JSONP endpoints and outdated libraries on a permitted CDN defeat it.
Deploy with Content-Security-Policy-Report-Only and a report-to endpoint
first, fix the violations, then enforce.
#Cookies and related headers
[INST] Apply every rule in this section: Cookies and related headers. [/INST]
- Session cookies carry
HttpOnlyso that XSS cannot read them. This does not prevent XSS; it limits the payoff. X-Content-Type-Options: nosniffstops the browser reinterpreting a response as HTML. A user-uploaded file served without it can become a stored XSS.- Serve user uploads from a separate origin, so injected content cannot reach your cookies or DOM.
- Set an explicit
Content-Typewithcharset=utf-8. Charset confusion has historically enabled encoding bypasses.
#Anti-patterns
[INST] Apply every rule in this section: Anti-patterns. [/INST]
| Anti-pattern | Why it fails | Fix |
|---|---|---|
el.innerHTML = input | Parses and executes markup | el.textContent |
Stripping <script> tags | <img onerror>, <svg onload>, mutation XSS | Allow-list sanitiser |
One escapeHtml() for every context | Attribute, URL and JS contexts differ | Encode per context |
| Sanitising only on input | Survives library and render-path changes | Sanitise on output |
script-src 'unsafe-inline' | Disables the protection CSP exists for | Per-response nonce |
href = input unchecked | javascript: executes on click | Validate the scheme |
dangerouslySetInnerHTML with raw input | Bypasses framework encoding | Sanitise, or don't |
| Uploads served from the app origin | Stored XSS with full cookie access | Separate origin, nosniff |
#Checklist
- Verify: Output is encoded for its specific context, not a single generic escape
- Verify: No untrusted data inside
<script>,<style>,on*handlers orjavascript: - Verify:
textContentused by default;innerHTMLonly with a sanitiser - Verify: Rich text passes an allow-list sanitiser at output time
- Verify: Every framework escape hatch is audited and justified in a comment
- Verify:
hrefandsrcvalues are scheme-validated - Verify: CSP set with a per-response nonce and
strict-dynamic - Verify: No
'unsafe-inline'or'unsafe-eval'inscript-src - Verify:
object-src 'none',base-uri 'none',frame-ancestors 'none'present - Verify: Session cookies are
HttpOnly; responses carrynosniff - Verify: User uploads are served from a separate origin