Cross-site scripting is old enough that most developers can recite the definition, yet it still shows up in nearly every web assessment. The reason is structural: HTML is a text format, and any time untrusted text is concatenated into it, the parser cannot tell data from markup. This post walks through the mechanics of one reflected XSS, fixes it the right way, and then adds a Content-Security-Policy (CSP) as the second layer that catches the bugs that slip through. Everything here is built against a toy application running on your own machine.
How the injection happens
Here is a search endpoint that many frameworks make easy to write:
// vulnerable: the query parameter is pasted straight into HTML
app.get("/search", (req, res) => {
const q = req.query.q ?? "";
res.send(`<h1>Results for ${q}</h1><p>Nothing found.</p>`);
});
Request /search?q=shoes and the page says "Results for shoes". Request /search?q=<img src=x onerror=alert(document.domain)> and the browser receives an img element with an event handler, which it dutifully runs. That is a reflected XSS: the payload travels in the request and comes straight back. A stored XSS differs only in timing; the same string is saved (a comment, a profile name, a support ticket) and rendered later for every reader.
What the attacker gets depends on the page. In the victim's origin the script can read the DOM, call the application's own APIs with the victim's cookies, register a service worker, or quietly exfiltrate form input. HttpOnly cookies stop document.cookie from being read, but they do not stop the script from making authenticated requests from inside the page.
Encoding is the fix, CSP is the seatbelt
The root cause is the missing boundary between data and markup, so the fix is to encode output for the context it lands in. Inside an HTML text node that means escaping five characters; inside an attribute it means quoting plus the same escapes; inside a URL it means percent-encoding; inside a <script> block there is no safe escaping at all and the data belongs in a data-* attribute or a JSON <script type="application/json"> island instead.
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
app.get("/search", (req, res) => {
const q = escapeHtml(req.query.q ?? "");
res.send(`<h1>Results for ${q}</h1><p>Nothing found.</p>`);
});
In practice you let a templating engine with auto-escaping (Jinja2, Nunjucks, Go templates, React's JSX) do this for you and treat every safe, raw or dangerouslySetInnerHTML as a code-review event.
CSP does not fix the bug. It changes what the browser is willing to execute, so that when the next bug ships, the injected script is refused. The two layers are complementary: encoding stops the injection, CSP limits the blast radius when encoding is forgotten.
Writing the policy
A CSP is a list of directives, each naming the sources a type of content may come from. The ones that matter for XSS are few:
| Directive | What it controls | Recommended value |
|---|---|---|
default-src | Fallback for every fetch directive | 'self' |
script-src | JavaScript (external, inline, eval) | 'nonce-…' 'strict-dynamic' |
style-src | Stylesheets and inline styles | 'self' (nonces if you need inline) |
img-src | Images | 'self' data: or your CDN |
connect-src | fetch, XHR, WebSocket, beacons | your API origins |
object-src | Flash-era plugins, <object>, <embed> | 'none' |
base-uri | <base href> rewriting relative URLs | 'none' |
frame-ancestors | Who may embed this page (replaces X-Frame-Options) | 'none' or your own origin |
form-action | Where forms may submit | 'self' |
report-to / report-uri | Where violation reports go | a logging endpoint you control |
upgrade-insecure-requests | Rewrites http: subresources to https: | present |
require-trusted-types-for | Forces DOM sinks through Trusted Types | 'script' once the app is ready |
Nonces, hashes and why allow-lists lost
The first generation of CSPs listed allowed script hosts: script-src 'self' cdn.example.com. This fails in two ways. Any host that serves user-controlled JavaScript (a CDN with a JSONP endpoint, an old AngularJS build) becomes a bypass, and the list rots as the application changes. The current recommendation is a nonce-based policy:
- The server generates a fresh random value per response, say 128 bits base64-encoded.
- It puts
'nonce-<value>'inscript-srcandnonce="<value>"on every legitimate<script>tag. -
'strict-dynamic'lets those trusted scripts load further scripts programmatically (document.createElement("script")), while ignoring any host allow-list and refusing parser-inserted scripts such as the injected one.
A hash-based policy ('sha256-…' of the exact inline script body) does the same job for static pages where no server runs per request. Both approaches make an injected <script> fail for the same reason: the attacker cannot know this response's nonce, and cannot make their payload hash to an allowed value.
Headers in practice
-
The nonce is generated per request, stored on
res.localsfor the template, and emitted in the header. Switching from report-only to enforcing is one environment variable.import crypto from "node:crypto"; app.use((req, res, next) => { const nonce = crypto.randomBytes(16).toString("base64"); res.locals.nonce = nonce; const policy = [ "default-src 'self'", `script-src 'nonce-${nonce}' 'strict-dynamic'`, "style-src 'self'", "img-src 'self' data:", "connect-src 'self' https://api.example.com", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'", "form-action 'self'", "report-to csp-endpoint", ].join("; "); const header = process.env.CSP_ENFORCE === "1" ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only"; res.setHeader(header, policy); res.setHeader("Reporting-Endpoints", 'csp-endpoint="https://example.com/csp-report"'); next(); });In the template every script tag carries the nonce:
<script nonce="{{ nonce }}" src="/static/app.js"></script> <script nonce="{{ nonce }}"> window.__APP_CONFIG__ = JSON.parse(document.getElementById("config").textContent); </script>If you prefer a maintained middleware, Helmet exposes the same directives as a configuration object and accepts a function for the nonce.
-
nginx cannot run application code, but
$request_idis a 32-hex-character random value generated per request, which is good enough as a nonce. The application emits a placeholder andsub_filterreplaces it on the way out.location / { proxy_pass http://app_upstream; # Per-request nonce from nginx's own random request id add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'nonce-$request_id' 'strict-dynamic'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; report-to csp-endpoint" always; add_header Reporting-Endpoints 'csp-endpoint="https://example.com/csp-report"' always; # The app writes nonce="__CSP_NONCE__" on its script tags sub_filter_once off; sub_filter_types text/html; sub_filter 'nonce="__CSP_NONCE__"' 'nonce="$request_id"'; proxy_set_header Accept-Encoding ""; # sub_filter needs uncompressed upstream bodies }The
alwaysparameter matters: without it nginx only adds headers to 2xx and 3xx responses, and error pages are exactly where reflected input tends to appear. See the ngx_http_headers_module documentation.
Rolling it out without breaking anything
The hard part of CSP is not writing the header; it is discovering the inline handlers, third-party widgets and eval-based template engines that a decade of development has accumulated. The Content-Security-Policy-Report-Only header exists for this: the browser evaluates the policy, blocks nothing, and sends a report for every violation it would have enforced.
- Inventory. Grep the codebase and templates for
<script>withoutsrc,on*=attributes,javascript:URLs,eval,new Function, andsetTimeoutwith a string argument. - Deploy report-only to a small percentage of traffic with a reporting endpoint that deduplicates by
(document-uri, violated-directive, blocked-uri). - Triage. Move inline scripts into files or give them nonces, replace handlers with
addEventListener, and swapeval-using libraries for versions that do not need it. Reports whoseblocked-uriischrome-extension://ormoz-extension://can be dropped. - Enforce by renaming the header, route by route if you can. Keep reporting on; violations are now an alerting signal.
- Tighten. Remove any remaining host allow-lists in favour of
'strict-dynamic', addrequire-trusted-types-for 'script'once DOM sinks are wrapped, and considerCross-Origin-Opener-Policyand friends for the next class of bugs.
What a violation report looks like
With the Reporting API (report-to), the browser batches reports as JSON. Each one carries enough context to find the offending template, including a sample of the blocked script when the policy includes 'report-sample'.
[
{
"age": 12,
"type": "csp-violation",
"url": "https://example.com/search?q=%3Cscript%3E...",
"user_agent": "Mozilla/5.0 ...",
"body": {
"documentURL": "https://example.com/search?q=%3Cscript%3E...",
"effectiveDirective": "script-src-elem",
"disposition": "report",
"blockedURL": "inline",
"originalPolicy": "default-src 'self'; script-src 'nonce-9Xq...' 'strict-dynamic'; ...",
"statusCode": 200,
"sample": "steal(document.cookie)"
}
}
]
A blockedURL of inline with disposition: "enforce" on a page that should have no inline scripts is as close to a free intrusion-detection signal as the web platform offers.
Checking your work
Paste the final header into the CSP Evaluator. It flags 'unsafe-inline', missing object-src, missing base-uri, and allow-listed hosts known to host JSONP endpoints. In the browser, the DevTools console prints every violation with the directive it hit, which is the fastest way to debug a page during triage. Finally, try your own injection in the toy app: with encoding fixed the payload renders as text, and with CSP enforced an un-nonced script is refused even if you deliberately remove the escaping.
WARNING
'unsafe-inline'inscript-srcturns the policy off for the exact case CSP exists to stop. Browsers ignore it when a nonce or hash is present, which is a reasonable fallback for very old clients, but if it is the only source expression you have no XSS protection. The same goes for'unsafe-eval', which permitseval,new Functionand string-argument timers, and for a bare*orhttps:inscript-src.
Further reading
- OWASP, Cross Site Scripting (XSS) and the XSS Prevention Cheat Sheet for the per-context encoding rules.
- OWASP, Content Security Policy Cheat Sheet.
- MDN, Content-Security-Policy header reference.
- W3C, Content Security Policy Level 3, the normative source for
'strict-dynamic'and the reporting format. - Google, CSP Evaluator.