Skip to content

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>
java&#115;cript: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:

<div title="" onmouseover=alert(1) x="">

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