29 — Security: XSS, CSRF, CSP & Prototype Pollution
XSS: Cross-Site Scripting Prevention
javascript
// ── XSS: attacker injects malicious HTML/JS into a page ──
// Three types:
// 1. Stored XSS: payload saved in DB, rendered for all users (most severe)
// 2. Reflected XSS: payload in URL, reflected back in the page
// 3. DOM-based XSS: payload injected via client-side DOM manipulation
// ❌ NAIVE — innerHTML renders raw HTML (XSS vulnerability)
function renderUserInput(input) {
document.querySelector("#output").innerHTML = input; // XSS!
}
renderUserInput('<img src=x onerror="alert(document.cookie)">'); // script executes
// ✅ SAFE — textContent treats input as text (no HTML parsing)
function renderSafe(input) {
document.querySelector("#output").textContent = input; // escaped as text
}
renderSafe('<img src=x onerror="alert(1)">'); // displays as literal text
// ✅ SAFE — sanitize HTML with DOMPurify (allow safe tags, strip scripts)
import DOMPurify from "dompurify";
function renderSanitized(html) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
ALLOWED_ATTR: ["href", "title"],
FORBID_ATTR: ["style", "onerror", "onclick"], // explicit deny list
});
document.querySelector("#output").innerHTML = clean;
}
renderSanitized('<b>safe</b><script>alert("xss")</script>'); // <b>safe</b> (script stripped)
// ── Dangerous sinks (never pass user input to these) ──
element.innerHTML = userInput; // ❌ HTML injection
document.write(userInput); // ❌ overwrites the page
element.setAttribute("onclick", userInput); // ❌ event handler injection
element.outerHTML = userInput; // ❌ replaces element
eval(userInput); // ❌ code execution
setTimeout(userInput, 0); // ❌ string arg = eval
setInterval(userInput, 1000); // ❌ string arg = eval
new Function(userInput); // ❌ creates a function from a string
XSS in Template Literals and URL Contexts
javascript
// ── Template literals with user input → XSS if inserted into HTML ──
const userName = '<img src=x onerror="alert(1)">';
document.querySelector("#greeting").innerHTML = `Hello, ${userName}!`; // ❌ XSS
// ✅ Escape HTML entities before inserting
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str; // browser escapes: < → <, > → >, etc.
return div.innerHTML;
}
document.querySelector("#greeting").innerHTML = `Hello, ${escapeHtml(userName)}!`;
// ── URL context: javascript: scheme is dangerous ──
const userInput = "javascript:alert(document.cookie)";
// ❌ NAIVE — allows javascript: URLs
document.querySelector("#link").href = userInput; // clicking the link executes JS
// ✅ SAFE — validate the URL scheme
function safeUrl(url) {
try {
const parsed = new URL(url, window.location.origin);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.href; // only http/https
}
} catch { /* invalid URL */ }
return "#"; // fallback for dangerous or invalid URLs
}
document.querySelector("#link").href = safeUrl(userInput); // "#" (blocked)
// ── Attribute injection (event handlers in attributes) ──
const userClass = 'foo" onmouseover="alert(1)';
// ❌ NAIVE — quote breakout
element.setAttribute("class", `custom ${userClass}`); // class="custom foo" onmouseover="alert(1)"
// ✅ SAFE — use setAttribute (auto-escapes quotes in the value)
element.setAttribute("class", userClass); // quotes escaped, no breakout
CSRF: Cross-Site Request Forgery
javascript
// ── CSRF: attacker tricks a user's browser into sending a request to your site ──
// The browser includes cookies → the request appears authenticated.
// Attack vector: <img src="https://bank.com/transfer?to=attacker&amount=1000">
// ── Defense 1: CSRF tokens (synchronizer pattern) ──
// Server generates a random token, embeds it in the form, validates on POST.
// Attackers can't read the token (same-origin policy prevents cross-site reads).
// Frontend: include the token in requests
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch("/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken, // send the token in a header
},
body: JSON.stringify({ to: "alice", amount: 100 }),
credentials: "same-origin", // include cookies
});
// ── Defense 2: SameSite cookies (prevent cross-site cookie transmission) ──
// Server sets: Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
// SameSite=Strict: cookie never sent on cross-site requests (most secure)
// SameSite=Lax: cookie sent on top-level navigation (links), not on subrequests
// SameSite=None: cookie always sent (requires Secure — HTTPS only)
// ── Defense 3: custom header requirement (not achievable with plain forms) ──
// Require a custom header (X-Requested-With) — plain HTML forms can't set custom headers.
// Attackers can't send custom headers cross-origin without CORS permission.
fetch("/api/data", {
headers: { "X-Requested-With": "XMLHttpRequest" }, // custom header
credentials: "same-origin",
});
Content Security Policy (CSP)
javascript
// ── CSP: restrict what resources the browser is allowed to load ──
// Set via HTTP header or <meta http-equiv="Content-Security-Policy" content="...">
// ── Strict CSP (prevents XSS, inline scripts, unauthorized connections) ──
const csp = [
"default-src 'self'", // default: only same origin
"script-src 'self'", // scripts: only same origin (no inline)
"style-src 'self' 'unsafe-inline'", // styles: same origin + inline (for CSS-in-JS)
"img-src 'self' data: https:", // images: same origin, data URIs, HTTPS
"connect-src 'self' https://api.example.com", // fetch/XHR: same origin + API
"font-src 'self' https://fonts.gstatic.com", // fonts: same origin + Google Fonts
"frame-ancestors 'none'", // prevent framing (clickjacking)
"base-uri 'self'", // <base> tag: only same origin
"form-action 'self'", // form submissions: only same origin
"object-src 'none'", // no <object>/<embed> (Flash, Java)
"upgrade-insecure-requests", // upgrade HTTP to HTTPS
].join("; ");
// ── Nonce-based CSP (allow specific inline scripts) ──
// Server generates a unique nonce per request:
// Content-Security-Policy: script-src 'self' 'nonce-random123'
// <script nonce="random123">console.log("allowed");</script>
// The nonce changes per request — attackers can't guess it.
// ── Hash-based CSP (allow scripts by their content hash) ──
// script-src 'self' 'sha256-abc123...'
// Only scripts with the matching hash execute.
// ── Reporting (monitor violations without blocking) ──
// Content-Security-Policy-Report-Only: default-src 'self'; report-to /csp-report
// Sends violation reports to /csp-report (doesn't block — for testing before enforcing).
Prototype Pollution
javascript
// ── Prototype pollution: attacker modifies Object.prototype ──
// All objects inherit from Object.prototype — polluting it affects every object.
// ❌ VULNERABLE — recursive merge can pollute the prototype
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object" && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attack: merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'))
// After this, every new object has isAdmin: true → privilege escalation
const obj = {};
console.log(obj.isAdmin); // true (polluted!) — every object now has isAdmin
// ✅ SAFE — block __proto__, constructor, prototype keys
function safeMerge(target, source) {
for (const key in source) {
if (key === "__proto__" || key === "constructor" || key === "prototype") {
continue; // skip dangerous keys
}
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = safeMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// ✅ SAFER — use Object.create(null) (no prototype)
const map = Object.create(null); // no __proto__, no inherited properties
// Object.create(null) objects can't be polluted (they have no prototype chain)
// ✅ BEST — use Map instead of plain objects for key-value storage
const safeMap = new Map();
safeMap.set("key", "value"); // no prototype pollution risk
Input Validation and Sanitization
javascript
// ── Validate input early and strictly (never trust client-side validation alone) ──
// Client-side: UX feedback. Server-side: security enforcement.
// ── Schema validation with a validator (e.g., Zod, Joi, yup) ──
// Using Zod:
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(), // must be a valid email
age: z.number().int().min(0).max(150), // integer, 0-150
name: z.string().min(1).max(100), // 1-100 chars
role: z.enum(["user", "admin"]), // only these values
website: z.string().url().optional(), // valid URL or undefined
});
// Parse and validate (throws on invalid input)
const user = UserSchema.parse(request.body); // throws ZodError if invalid
// Safe parse (returns success/error instead of throwing)
const result = UserSchema.safeParse(request.body);
if (result.success) {
console.log(result.data); // typed and validated
} else {
console.log(result.error.issues); // validation errors
}
// ── SQL injection prevention (use parameterized queries) ──
// ❌ NAIVE — string concatenation (SQL injection)
const query = `SELECT * FROM users WHERE name = '${userName}'`;
// Attack: userName = "'; DROP TABLE users; --" → SQL injection
// ✅ SAFE — parameterized queries (database escapes the values)
const safeQuery = "SELECT * FROM users WHERE name = ?";
db.execute(safeQuery, [userName]); // userName is escaped by the driver
Subresource Integrity (SRI)
xml
<!-- ── SRI: verify the integrity of external scripts/styles ── -->
<!-- If the CDN is compromised and the script changes, the browser won't execute it -->
<script
src="https://cdn.example.com/library.js"
integrity="sha384-abc123..."
crossorigin="anonymous">
</script>
<!-- The hash must match the file content. If it doesn't, the browser blocks execution. -->
<!-- Generate: openssl dgst -sha384 -binary library.js | openssl base64 -A -->
💡 Tips & Tricks
javascript
// ── HttpOnly cookies: prevent JS from reading cookies (XSS can't steal them) ──
// Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
// document.cookie → can't read "session" (HttpOnly) → XSS can't exfiltrate it
// ── Use `textContent` by default, `innerHTML` only with sanitization ──
// textContent: safe (no HTML parsing), slightly faster
// innerHTML: only with DOMPurify or trusted content
// ── Strict Transport Security (HSTS): force HTTPS ──
// Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
// Prevents SSL stripping attacks (browser always uses HTTPS for this domain)
// ── Use `rel="noopener noreferrer"` on target="_blank" links ──
// <a href="https://external.com" target="_blank" rel="noopener noreferrer">
// Without noopener: the new tab can access window.opener (reverse tabnabbing)
// ── JSON hijacking prevention (X-Content-Type-Options: nosniff) ──
// Set: X-Content-Type-Options: nosniff
// Prevents the browser from MIME-sniffing (treating JSON as HTML/JS)
⚠️ Edge Cases & Gotchas
javascript
// ── `textContent` and `innerHTML` differ on script tags ──
const script = document.createElement("script");
script.textContent = 'alert("executes")'; // ❌ DOES execute when inserted
document.body.appendChild(script); // the script runs
// ── Sanitization is context-dependent ──
// DOMPurify sanitizes for HTML context. For URL context, validate the scheme.
// For JS string context, escape quotes and backslashes.
// No single sanitizer works in all contexts — use the right tool for each.
// ── `javascript:` URLs can bypass naive scheme checks ──
// "java\tscript:alert(1)" → tab character bypasses /javascript:/ regex check
// Use new URL() and check protocol (handles encoding correctly)
// ── Prototype pollution via JSON ──
// JSON.parse('{"__proto__": {"x": 1}}') → __proto__ is a key, not the prototype
// But if you merge this into an object with a recursive merge, it can pollute.
// ── `Object.freeze` doesn't prevent prototype pollution ──
// Object.freeze(obj) prevents changes to obj's own properties,
// but doesn't prevent changes to Object.prototype (the prototype).
// ── CSP doesn't prevent all XSS (only limits the damage) ──
// 'unsafe-inline' allows inline scripts (still vulnerable to inline XSS)
// Use nonces or hashes instead of 'unsafe-inline' for strict CSP.
🧠 Quick Quiz
Why is this code vulnerable, and how do you fix it?
javascript
function highlightSearch(query) {
const results = document.querySelector("#results");
results.innerHTML = `Found: ${items
.filter(item => item.name.includes(query))
.map(item => `<li>${item.name}</li>`)
.join("")}`;
}
Answer
The item.name values are inserted into innerHTML without escaping. If an attacker can control item.name (e.g., by submitting a product name like <img src=x onerror="alert(document.cookie)">), the HTML is parsed and the script executes — stored XSS.
Even though query is used in .filter() (not in the HTML), the item.name values are rendered as raw HTML.
Fix: Escape item.name before inserting into HTML:
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function highlightSearch(query) {
const results = document.querySelector("#results");
results.innerHTML = `Found: ${items
.filter(item => item.name.includes(query))
.map(item => `<li>${escapeHtml(item.name)}</li>`) // ✅ escape each name
.join("")}`;
}
Or use textContent and createElement (no HTML parsing):
function highlightSearch(query) {
const results = document.querySelector("#results");
results.innerHTML = "Found:";
const list = document.createElement("ul");
for (const item of items.filter(i => i.name.includes(query))) {
const li = document.createElement("li");
li.textContent = item.name; // ✅ textContent (no HTML parsing)
list.appendChild(li);
}
results.appendChild(list);
}
The lesson: every value inserted into innerHTML must be escaped or sanitized — including data from your own database (stored XSS). Use textContent for plain text, or DOMPurify.sanitize() for rich HTML.