React, Vue, Angular, Next.js - they auto-encode output by default. XSS is dramatically reduced. Not eliminated.
Where frameworks don't protect you
dangerouslySetInnerHTML / v-html
<div dangerouslySetInnerHTML={{ __html: userContent }} />
<div v-html="userContent"></div>
If userContent is untrusted, attackers inject scripts.
Need raw HTML? Sanitise first:
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(userContent);
href/src with user URLs
<a href={userUrl}>Click here</a>
If userUrl is javascript:alert(document.cookie), clicking executes JS.
Fix:
function isSafeUrl(url) {
try {
const parsed = new URL(url, window.location.origin);
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
Server Components
If server data includes unsanitised HTML that the client renders with dangerouslySetInnerHTML, XSS happens. Sanitise before rendering raw HTML, regardless of source.
Third-party components
Review anything that accepts HTML, rich text, or markdown. A poorly written component might use innerHTML internally.
Template injection via SSR
// Next.js API route - bypasses React encoding
export default function handler(req, res) {
const name = req.query.name;
res.send(`<html><body>Hello ${name}</body></html>`);
}
Don't construct HTML strings manually.
postMessage
// Vulnerable
window.addEventListener("message", (event) => {
document.getElementById("output").innerHTML = event.data;
});
// Fixed
window.addEventListener("message", (event) => {
if (event.origin !== "https://trusted.example.com") return;
document.getElementById("output").textContent = event.data;
});
Content Security Policy
Defence in depth. Even if XSS payload makes it in, CSP can block execution.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'
Frameworks needing inline script for hydration use nonce-based CSP:
Content-Security-Policy: script-src 'self' 'nonce-abc123'
Markdown rendering
Many renderers allow raw HTML by default:
This is a comment: <img src=x onerror=alert(1)>
Fix:
import { marked } from "marked";
import DOMPurify from "dompurify";
const html = DOMPurify.sanitize(marked.parse(userMarkdown));
Checklist
- No
dangerouslySetInnerHTML,v-html,innerHTMLwith unsanitised data - Validate URL schemes before
href/src textContentnotinnerHTMLfor dynamic text- Sanitise markdown output
- Validate
postMessageorigins - Deploy CSP
- Review third-party components
- Don't construct HTML strings in SSR
The takeaway
The escape hatches - dangerouslySetInnerHTML, v-html, unvalidated URLs - are where mistakes happen. Sanitise with DOMPurify. Validate URLs. Deploy CSP.
