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.

Blocklist filtering vs escapingTry it yourself
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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   -> &lt;script&gt;steal()&lt;/script&gt;Nice post!  [shown as text]
payload: <img src=x onerror=steal()>
  blocklist -> <img src=x onerror=steal()>  [still executable]
  escaped   -> &lt;img src=x onerror=steal()&gt;  [shown as text]
payload: <scr<script></script>ipt>steal()</script>
  blocklist -> <script>steal()</script>  [still executable]
  escaped   -> &lt;scr&lt;script&gt;&lt;/script&gt;ipt&gt;steal()&lt;/script&gt;  [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, never innerHTML, 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 HttpOnly so 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, dangerouslySetInnerHTML and v-html with untrusted data; add XSS payloads to automated tests.

Key points

  • Never insert user content with innerHTML after a blocklist filter.
  • Escape by default; for rich text use Markdown without HTML or an allowlist sanitiser.
  • Back it up with CSP and HttpOnly cookies.

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.