CWE-80: Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS) - JavaScript
Overview
CWE-80 is the tag-injection case of XSS: <, > and & reach the page without
being converted to entities, so an attacker's <script> or
<img onerror=...> is parsed as markup rather than shown as text.
The fix is on CWE-79 - JavaScript, which is
where the encoders, escape-html, DOMPurify, URL-scheme validation, the
React/Vue/Angular guidance and the CSP configuration live. This page does not
repeat them.
What belongs here is the element-content case in detail - the blocklist attempt that produces these findings, and the encoders that are correct for element content and quietly wrong anywhere else.
Common Vulnerable Patterns
Stripping tags with a blocklist
// VULNERABLE - removing what looks dangerous, rather than encoding
function clean(input) {
return input
.replace(/<script>/gi, '')
.replace(/javascript:/gi, '');
}
document.getElementById('out').innerHTML = `<div>${clean(comment)}</div>`;
Why this is vulnerable: every one of these survives it.
<img src=x onerror=alert(1)> no <script> tag at all
<svg onload=alert(1)> same, different element
<scr<script>ipt>alert(1)</script> the inner match is removed, leaving <script>
javascript:alert(1) the literal "javascript:" never appears
The set of ways to execute script is open-ended and grows with the HTML and browser specifications, so a blocklist is guessing against a list it cannot enumerate.
An encoder used outside the context it is correct for
This one is subtler than the blocklist, because the code looks right.
// Correct for element content only - escapes & < > and nothing else
function encodeHtml(value) {
const text = document.createTextNode(value);
const p = document.createElement('p');
p.appendChild(text);
return p.innerHTML;
}
// SECURE - the value lands between > and <
el.innerHTML = `<div class="comment">${encodeHtml(comment)}</div>`;
// VULNERABLE - the same helper, one context over
el.innerHTML = `<div title="${encodeHtml(comment)}">...</div>`;
Why this is vulnerable: HTML fragment serialization escapes &, <, >
and the non-breaking space. It escapes " only when serializing an attribute
value, and never escapes '. So both quotes come back unchanged, because
neither can terminate element content - which makes the output correct for the
context it came from and wrong for any other. Feeding
" onmouseover=alert(1) x=" through it and into a title attribute parses as:
a live event handler. The helper is not broken; the assumption that an HTML encoder is context-free is.
The same applies to any tagged-template helper built on it:
// html`<h2>${name}</h2>` safe - element content
// html`<div title="${name}">` not safe - attribute
A tag cannot see which context a ${} landed in, so it applies one encoding
everywhere. A tag that is safe in every position has to parse the static
strings to work out each hole's context, which is what lit-html does and a
short hand-written one does not.
Secure Patterns
Do not build the markup
// SECURE - the value is never parsed as markup
const div = document.createElement('div');
div.className = 'comment';
div.textContent = comment;
container.replaceChildren(div);
// SECURE - attributes set as data, so quoting never arises
div.setAttribute('title', comment);
Why this works: textContent and setAttribute take the value as data and
never hand it to the HTML parser, so there is no context to encode for and no
way to get the encoder wrong. This removes the entire class of defect above
rather than navigating it, which is why it is the first choice whenever the
output is text rather than markup.
When you must build an HTML string
Use a maintained encoder rather than the browser-serializer trick above, and know which context it covers:
const escapeHtml = require('escape-html'); // npm install escape-html
// escape-html encodes & < > " ' - safe for element content and for
// quoted attributes, which is what makes it the better default here
el.innerHTML = `<div title="${escapeHtml(comment)}">${escapeHtml(comment)}</div>`;
Why this works: escape-html escapes both quote characters as well as the
tag characters, so a value cannot terminate a quoted attribute or open a tag.
That covers the two contexts a string-building helper usually meets. It still
does not make a URL safe in href, or a value safe inside a <script> block -
those need scheme validation and JSON serialisation respectively, both covered
on CWE-79 - JavaScript.
When the input really is HTML
Use DOMPurify (3.4.13 or later), configured and explained on CWE-79 - JavaScript. It parses and rebuilds from an allowlist, which is the question a blocklist cannot ask - "is this on the list" rather than "is this dangerous".
Considerations
Whether the finding is CWE-80 or CWE-79 rarely changes the fix. Both are resolved by encoding for the context the value lands in. The label tells you what the scanner matched - tag characters in element content - not the shape of the remediation.
It does change where to look next. CWE-80 only describes element content.
An application that failed to encode there has usually failed in the contexts
CWE-80 does not name: attributes, href and src values, inline <script>
blocks, and DOM sinks such as document.write, outerHTML and
insertAdjacentHTML. Those are CWE-79's territory.
Check the encoder's context before reusing it, not its name. The defect in
Common Vulnerable Patterns above is the one to expect in a codebase that
already has a helper: someone wrote a correct element-content encoder, and a
later caller used it in an attribute. Grep for the helper's callers rather than
its definition.
Additional Resources
- CWE-80: Improper Neutralization of Script-Related HTML Tags
- CWE-79 - JavaScript - encoders, DOMPurify, URL validation, frameworks and CSP
- HTML Standard: serializing HTML fragments
- OWASP DOM-based XSS Prevention Cheat Sheet