What it is#
Cross-site scripting happens when an application takes data it doesn't control (a URL parameter, a form field, a database value another user wrote, a third-party API response) and places it into an HTML page, attribute, script or URL without encoding it for that context. The browser can't tell the attacker's markup from the application's own, so it runs it.
There are three common forms:
- Reflected XSS: the payload arrives in the request (often a link) and is echoed straight back in the response.
- Stored XSS: the payload is saved (a comment, profile name, support ticket, "notes" field) and runs for every user who later views it. This is usually the most severe form, especially when administrators view the data.
- DOM-based XSS: client-side JavaScript reads attacker-controlled data (for example
location.hash) and writes it into the page with a dangerous sink such asinnerHTML. The server may never see the payload.
Why it matters#
The injected script runs with the victim's identity on your origin. Depending on the page and the victim, an attacker can:
- act as the user: change their email or password, create API keys, approve transactions or invite new admins;
- read anything the user can see on that origin, including data rendered into the page and responses to same-origin requests;
- steal session tokens that aren't protected by
HttpOnly; - show convincing fake login prompts on a trusted domain;
- pivot to an administrator's session when a low-privilege user's stored input is viewed in an admin panel.
Medium for reflected XSS that needs user interaction and has limited reach. High (CVSS 3.1 often 7.x–8.x, scope changed) for stored XSS that reaches privileged users or sensitive actions.
Examples#
A server-side template that concatenates input into HTML:
// Vulnerable: user input is inserted as raw HTML
app.get('/search', (req, res) => {
res.send(`<h1>Results for ${req.query.q}</h1>`);
});
Client-side code that writes a URL fragment into the DOM:
// Vulnerable: DOM-based XSS through innerHTML
const name = decodeURIComponent(location.hash.slice(1));
document.querySelector('#greeting').innerHTML = 'Hello ' + name;
A harmless proof-of-concept testers use to confirm execution is a payload that calls alert(document.domain). Never use payloads that exfiltrate real data during testing.
How to fix it#
1. Encode output for the context it lands in. Use a templating engine that auto-escapes HTML by default (React JSX, Angular, Jinja2 with autoescape, Razor, Go html/template) and avoid the escape hatches.
// Fixed: Express with an auto-escaping template engine
app.get('/search', (req, res) => {
res.render('search', { q: req.query.q }); // {{ q }} is HTML-escaped
});
// Fixed: write text, not HTML
document.querySelector('#greeting').textContent = 'Hello ' + name;
2. Avoid dangerous sinks. Treat innerHTML, outerHTML, document.write, insertAdjacentHTML, eval, new Function, setTimeout(string), React's dangerouslySetInnerHTML and Vue's v-html as findings that need justification.
3. Sanitize when you must accept HTML. For rich text, use a maintained allow-list sanitizer such as DOMPurify and keep it updated.
import DOMPurify from 'dompurify';
el.innerHTML = DOMPurify.sanitize(userHtml, { USE_PROFILES: { html: true } });
4. Validate URLs. Only allow https: (and other expected schemes) in user-supplied links to block javascript: URLs.
5. Add defense in depth.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
A strict, nonce-based CSP sharply limits what an injected payload can do; HttpOnly cookies keep session tokens out of reach of scripts. Neither replaces correct encoding.
How to test for it#
- Map every place user-controlled data is reflected or stored, including headers, file names, JSON fields and data from integrations.
- For each, find the output context (HTML body, attribute, JavaScript string, URL, CSS) and try a context-appropriate benign probe, such as a unique marker string followed by characters like
<,"and', and check whether they come back unencoded. - Review client-side code for sources (
location,postMessage,localStorage) that flow into dangerous sinks. - Check that the CSP is actually enforced (not report-only) and doesn't allow
'unsafe-inline'scripts. - Retest after fixes and add regression tests that render known-bad strings and assert they come out escaped.