Modern HTML5 in Production · SEO, Sharing and Security
User-Generated HTML Without XSS
How a comment containing an image tag stole session data despite a 'script tag filter', and how escaping, allowlist sanitisers and CSP make user content safe.
Scenario
Example app: a community site lets users post comments with basic formatting. To be safe, the backend removes <script> tags with a regular expression before storing the HTML, and the page inserts comments with innerHTML.
What you see in production
- A comment containing
<img src=x onerror=...>runs JavaScript for everyone who views it, sending their data to an attacker's server. - A second payload uses
<scr<script>ipt>, which the filter turns into a working<script>tag. - The incident affects every page that displays comments.
The naive code
const clean = comment.replace(/<script.*?>.*?<\/script>/gis, ""); // blocklist filter
commentsEl.innerHTML += `<div class="comment">${clean}</div>`;
Reproduce it
The simulation runs three payloads through the naive blocklist filter and through HTML escaping (for plain-text comments). It only transforms strings; nothing is executed.
const payloads = [
"<script>steal()</script>Nice post!",
"<img src=x onerror=steal()>",
"<scr<script></script>ipt>steal()</script>",
];
const blocklist = (s) => s.replace(/<script.*?>.*?<\/script>/gis, "");
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const looksExecutable = (html) => /<script|on\w+\s*=/i.test(html);
for (const p of payloads) {
const b = blocklist(p);
console.log(`payload: ${p}`);
console.log(` blocklist -> ${b} ${looksExecutable(b) ? "[still executable]" : "[inert]"}`);
console.log(` escaped -> ${escapeHtml(p)} [shown as text]`);
}
Output
payload: <script>steal()</script>Nice post! blocklist -> Nice post! [inert] escaped -> <script>steal()</script>Nice post! [shown as text] payload: <img src=x onerror=steal()> blocklist -> <img src=x onerror=steal()> [still executable] escaped -> <img src=x onerror=steal()> [shown as text] payload: <scr<script></script>ipt>steal()</script> blocklist -> <script>steal()</script> [still executable] escaped -> <scr<script></script>ipt>steal()</script> [shown as text]
Root cause
Blocklists can't enumerate every dangerous construct: event-handler attributes (onerror, onload), javascript: URLs, SVG and MathML tricks, and nested tags that reassemble after filtering. Inserting the result with innerHTML executes whatever survives.
The fix
Follow the OWASP XSS prevention cheat sheet:
- Plain text by default: escape on output (template engines do this automatically) or set
textContent, neverinnerHTML, for user data. - If users need formatting, prefer Markdown rendered with raw HTML disabled, or sanitise with an allowlist sanitiser such as DOMPurify (browser) or a maintained server-side equivalent, configured to allow only specific tags and attributes. The upcoming HTML Sanitizer API brings this into browsers.
- Add a Content-Security-Policy that disallows inline scripts (
script-src 'self', or nonces), so injected scripts fail even if escaping is missed. - Keep session cookies
HttpOnlyso scripts can't read them.
Trade-offs
- Allowlists limit what users can format; that's the point. Extend them deliberately.
- Strict CSP may require refactoring inline scripts and event handlers.
Detect it in production
- CSP violation reports (
report-to) reveal injection attempts and missed escapes. - Lint for
innerHTML,dangerouslySetInnerHTMLandv-htmlwith untrusted data; add XSS payloads to automated tests.
Key points
- Never insert user content with
innerHTMLafter a blocklist filter. - Escape by default; for rich text use Markdown without HTML or an allowlist sanitiser.
- Back it up with CSP and
HttpOnlycookies.
Practice questions
Why don't blocklist filters that remove script tags prevent XSS?
Answer
Many other constructs execute script (event-handler attributes such as onerror, javascript: URLs, SVG), and nested or malformed tags can reassemble after filtering. Escape output or use an allowlist sanitiser instead.
How should you display user-provided text in HTML?
Answer
Escape it on output (template auto-escaping) or set it with textContent; never insert it with innerHTML unless it has been sanitised with an allowlist sanitiser.
How does a Content Security Policy reduce XSS impact?
Answer
By disallowing inline scripts and limiting script sources (script-src 'self', nonces or hashes), injected script tags and inline event handlers won't run even if escaping fails somewhere.
Open in the app to mark lessons complete, take notes and track your progress.