encodeURI, encodeCSS } from 'he'; // or equivalent library
export type Context = 'html' | 'js' | 'css' | 'url' | 'attribute';
export function encodeForContext(value: string, context: Context): string {
if (typeof value !== 'string') return String(value);
switch (context) {
case 'html':
return encodeHTML(value);
case 'js':
return encodeJS(value);
case 'css':
return encodeCSS(value);
case 'url':
return encodeURI(value);
case 'attribute':
return value.replace(/["'<>]/g, (char) => ({
'"': '"',
"'": ''',
'<': '<',
'>': '>',
}[char] || char));
default:
throw new Error(Unsupported encoding context: ${context});
}
}
**Architecture Rationale:** Frameworks typically default to HTML context. Explicit context routing prevents accidental leakage into JavaScript strings or URL parameters where HTML entities are ineffective.
### Step 2: Strict Content Security Policy (CSP)
CSP acts as the runtime enforcement layer. It restricts script execution sources, disables unsafe inline scripts, and enables violation reporting.
```typescript
// security-headers.ts
import { Request, Response, NextFunction } from 'express';
export function applyStrictCSP(_req: Request, res: Response, next: NextFunction): void {
const nonce = crypto.randomUUID().replace(/-/g, '');
res.locals.cspNonce = nonce;
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' 'nonce-" + nonce + "'",
"style-src 'self' 'nonce-" + nonce + "'",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.yourdomain.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
"block-all-mixed-content",
].join('; ')
);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
next();
}
Architecture Rationale: Nonce-based CSP eliminates unsafe-inline while allowing dynamically generated inline scripts/styles. The policy explicitly restricts execution domains, prevents framing, and forces HTTPS. Reporting endpoints should be configured separately for production monitoring.
Step 3: DOM Sanitization & Sink Control
User-controlled HTML must never pass directly into the DOM. Use a battle-tested sanitizer that strips dangerous attributes, protocols, and tags.
// dom-sanitizer.ts
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const purify = DOMPurify(window);
export function sanitizeHTML(input: string): string {
return purify.sanitize(input, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'li', 'span'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'srcdoc', 'data'],
ADD_ATTR: ['target'],
KEEP_CONTENT: true,
WHOLE_DOCUMENT: false,
});
}
Architecture Rationale: DOMPurify operates on the parsed DOM tree, neutralizing bypass techniques like SVG <use> injection, data: URIs, and event handler attributes. Whitelisting over blacklisting prevents evasion via obscure HTML5 features.
Step 4: Runtime Sink Auditing & Linting
Prevent unsafe patterns from reaching production. Integrate static analysis and runtime guards.
// eslint-plugin-xss-guard.js (conceptual rule)
module.exports = {
meta: {
type: 'problem',
docs: { description: 'Prevent direct DOM manipulation with unsanitized input' },
},
create(context) {
return {
MemberExpression(node) {
if (node.property.name === 'innerHTML' || node.property.name === 'outerHTML') {
context.report({
node,
message: 'Direct DOM sink detected. Use sanitized content or textContent.',
});
}
},
};
},
};
Architecture Rationale: Static analysis catches developer oversights before deployment. Combined with CI/CD scanning, it enforces policy compliance without runtime performance penalties.
Step 5: Defense-in-Depth Orchestration
Wire components into a unified security pipeline:
- Ingress: Validate structure, reject malformed payloads
- Processing: Sanitize if HTML is required; otherwise treat as opaque strings
- Egress: Context-encode before rendering
- Runtime: CSP enforces execution boundaries
- Monitoring: CSP violation reports + runtime error tracking
Pitfall Guide
1. Assuming Framework Auto-Escaping Covers All Contexts
Frameworks escape HTML context by default. They do not escape JavaScript strings, CSS values, or URL parameters. Injecting ${userInput} inside a <script> block or style="background: url('${input}')" bypasses HTML escaping entirely. Always route through context-aware encoders.
Using script-src 'self' 'unsafe-inline' or * negates CSP's protective value. Attackers who achieve injection can execute arbitrary code. Replace inline scripts with nonce-based or hash-based allowances. Audit third-party dependencies before whitelisting external domains.
3. Double-Encoding or Under-Encoding
Applying HTML encoding twice corrupts data and can create bypasses. Conversely, encoding only < and > leaves quotes, ampersands, and backticks vulnerable. Use standardized libraries that handle full context escaping. Never write custom regex encoders.
4. Trusting innerHTML, v-html, or dangerouslySetInnerHTML Without Sanitization
These APIs bypass framework escaping. Even if input comes from a "trusted" CMS, compromised admin accounts or supply chain attacks can inject payloads. Always pass through a sanitizer before assignment, or prefer textContent/innerText for plain text.
5. Ignoring URL and Attribute Contexts
Injecting javascript:alert(1) into an href or src attribute executes code regardless of HTML encoding. Validate URL protocols (https:, mailto:, tel:), reject javascript:, data:, and vbscript:, and encode attribute values with proper quote handling.
6. Over-Reliance on WAFs as Primary Defense
Web Application Firewalls filter known patterns but cannot understand application context. They generate false positives, block legitimate traffic, and fail against logic-based DOM XSS. Treat WAFs as a supplementary layer, not a replacement for secure coding.
7. Skipping DOM Sink Auditing in SPAs
Modern SPAs process location.hash, URLSearchParams, postMessage, and history.state entirely client-side. These are prime XSS vectors. Map all data entry points, apply sanitization at ingestion, and enforce encoding at rendering. Use tools like dom-xss-audit or manual source-sink tracing during code reviews.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Modern SPA with client-side routing | Context encoding + strict CSP + DOM sink auditing | Frameworks don't cover hash/URL params; CSP blocks execution | Low (dev time) |
| SSR application with template rendering | Output encoding at render time + CSP headers | Server handles HTML context; CSP prevents injected scripts | Low |
| Legacy app with inline scripts | Gradual CSP migration + nonce injection + sanitizer wrapper | Immediate protection without full refactor | Medium |
| Third-party heavy dashboard | Strict CSP allowlist + sandboxed iframes + input validation | External scripts introduce uncontrolled sinks | High (integration effort) |
Configuration Template
# Nginx CSP & Security Headers (production-ready)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id'; style-src 'self' 'nonce-$request_id'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.yourdomain.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests; block-all-mixed-content;" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# CSP Report-Only for staging
# add_header Content-Security-Policy-Report-Only "report-uri https://csp-report.yourdomain.com/api/violation; default-src 'self' 'unsafe-inline' 'unsafe-eval';" always;
// tsconfig.json security compiler options
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true
}
}
Quick Start Guide
- Install baseline dependencies:
npm install dompurify jsdom helmet express
- Apply security middleware: Integrate
helmet or custom CSP headers into your Express/Fastify/Next.js entry point. Generate per-request nonces for inline scripts.
- Replace unsafe sinks: Search codebase for
innerHTML, outerHTML, v-html, dangerouslySetInnerHTML, eval, setTimeout(string). Replace with textContent or wrap with DOMPurify.sanitize().
- Enable context encoding: Create a centralized encoding utility. Route all dynamic output through context-specific functions before rendering.
- Validate in staging: Deploy with
Content-Security-Policy-Report-Only. Monitor violation logs, adjust nonces/allowlists, then switch to enforcing mode. Run OWASP ZAP or Burp Suite baseline scan to verify coverage.
XSS prevention is not a single configuration; it's a continuous enforcement of context boundaries, execution restrictions, and sink control. Implement the layers, automate the checks, and treat every user-controlled value as untrusted until proven safe at the rendering boundary.