Injection and output
Prevent cross-site scripting
Keep untrusted data as text, use framework escaping, and avoid browser sinks that interpret attacker-controlled strings as code or markup.
XSS, cross-site scripting, happens when the browser treats untrusted data as part of the page instead of as plain text. A string that was supposed to be someone’s name becomes running code. The safe default is boring: render text as text.
Two lines, two different worlds
These two lines look almost the same. They behave completely differently:
message.textContent = userMessage // rendered as text, always safe
message.innerHTML = userMessage // parsed as HTML, a script sink
textContent writes characters to the page. innerHTML hands the string to the HTML parser. A sink is any place where a value turns back into code or markup. The same variable is harmless in one sink and dangerous in another.
Try it in the console with a hostile value:
const userMessage = '<img src=x onerror=alert(1)>'
document.body.textContent = userMessage // you see the literal tag on the page
document.body.innerHTML = userMessage // an alert box pops up
That’s the whole bug, in two lines.
Match the escaping to the destination
Frameworks escape for you in the normal case. React does it every time you interpolate a value. Trust that, and stay away from the bypasses:
<span>{userMessage}</span> {/* React escapes this automatically */}
<span dangerouslySetInnerHTML={{ __html: userMessage }} /> {/* bypass, do not */}
The name dangerouslySetInnerHTML is long on purpose. When you see it in a code review, ask where the value came from.
If the product really must accept HTML, say a rich-text comment, don’t write your own filter. Use a maintained sanitizer with a narrow allowlist:
import DOMPurify from 'dompurify'
preview.innerHTML = DOMPurify.sanitize(userMessage) // strips scripts and handlers
Feed it the <img onerror> payload above and you get <img src="x"> back. The handler is gone.
Escape at output, not at input
A tempting shortcut is to escape values once when they come in. It’s fragile. A profile name is safe in an escaped <span>, but the same stored value later lands in an attribute, a URL, or a JSON blob, and each of those needs different escaping. Keep the stored value as the user typed it, and escape for the exact context at the moment you render it.
The realistic failure looks like this. A profile name containing <img src=x onerror=alert(1)> renders fine everywhere for months. Then someone ships a “preview card” feature that builds markup with innerHTML, and the old payload wakes up. The fix is the sink, not the data.
Try this on your own project: pick one user-controlled value and trace it to every place it renders. Write down each sink. Then test with plain text, a <script> tag, an attribute-breaking " onmouseover=, and a javascript: URL, and confirm none of them run.
Lesson completed