Skip to content

CWE-80: Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS)

Overview

CWE-80 is the tag-injection case of Cross-Site Scripting (XSS): untrusted data reaches the page with <, > and & intact, so the browser parses what the attacker supplied as markup instead of showing it as text. A value carrying <script>, <img onerror=...> or <iframe> becomes a real element, and the script it brings with it runs in the victim's browser.

Relationship to CWE-79

MITRE classifies CWE-80 as a Variant beneath CWE-79 (Cross-site Scripting), and the two are close enough that the same finding can reasonably be reported under either. Knowing which one you have tells you where to look, not what to do.

CWE-79 is the class. It covers every way untrusted data can reach a page and be interpreted as code: HTML body, quoted and unquoted attributes, JavaScript, CSS, URLs, and DOM sinks. Each of those contexts needs a different encoder, and choosing the wrong one is the usual reason a "fixed" XSS still fires.

CWE-80 is one case of it. MITRE scopes it to <, > and & - the characters that open and close a tag - reaching the page without being converted to entities. That is the plain case where an attacker's <script> or <img onerror=...> becomes markup. It is the finding a scanner reports when it sees tag injection specifically, rather than an attribute or script-context break-out.

The fix is the same, and that is the point. Encoding <, > and & for the context the value lands in resolves both. So this page and its language pages cover what is distinctive about the tag-injection case - in particular the blocklist attempts that characterise it, such as stripping <script> or filtering the word javascript, which fail because the tag is not the only way to run script. For the encoders themselves, and for the attribute, JavaScript, URL and CSS contexts CWE-80 does not reach, use the CWE-79 page for your language.

If you are fixing a CWE-80 finding, read the CWE-79 page for your language too. A codebase that got one tag-injection point wrong usually has the same gap in the contexts CWE-80 does not name.

OWASP Classification

A05:2025 - Injection

Risk

High to Critical: Injected script runs in the victim's browser with the page's own origin, so it can read the session cookies and tokens the page's JavaScript can reach, issue authenticated requests as the victim, and rewrite what the page shows - a login form that posts credentials elsewhere, for example.

Remediation Steps

Core Principle: Never include untrusted input in HTML output without context-appropriate output encoding so it cannot be interpreted as executable markup or script.

Trace the Data Path

Follow the untrusted value from where it enters to where it is written into the page:

  • Source: where untrusted data enters - user input, external files, databases, network requests, cookies, headers
  • Data Flow: the transformations between source and output
  • Sink: where the data is rendered - response writing, template rendering, DOM manipulation
  • Output Context: where in the page it lands - HTML body, attribute, JavaScript, CSS, URL
  • Missing Encoding: whether an encoding or escaping step exists on that path, and whether it matches the output context

Apply Context-Aware Output Encoding (Primary Defense)

HTML Body Context:

  • Encode <, >, &, " and ' as HTML entities - <&lt;, >&gt;, &&amp;
  • Use framework-provided encoding functions

HTML Attribute Context:

  • Always quote attributes (<div class="value">, not <div class=value>)
  • Encode quotes and HTML special characters
  • Avoid placing untrusted data in event handler attributes (onclick, onerror, etc.)

JavaScript Context:

  • Avoid placing untrusted data directly in <script> tags
  • If unavoidable, use JavaScript encoding (escape quotes, backslashes, newlines)
  • Better still, pass the value in a data attribute and read it from JavaScript

URL Context:

  • URL-encode special characters (percent-encoding)
  • Validate URL scheme (only allow http:// and https://)
  • Never allow javascript:, data:, or vbscript: URLs

CSS Context:

  • Avoid untrusted data in CSS contexts
  • If unavoidable, use CSS encoding
  • Never allow @import directives built from untrusted data

Use Safe APIs and Avoid Dangerous Functions

Prefer APIs that cannot interpret the value as markup:

Safe DOM Manipulation:

  • Use text-only DOM APIs that set content without parsing it as HTML (in JavaScript, textContent rather than innerHTML or document.write)
  • See the JavaScript language guidance for the exact safe/dangerous API pairs

Template Engines with Auto-Escaping:

  • Server-side: Thymeleaf, Razor, and Flask/Jinja templates where auto-escaping is enabled
  • Client-side: React, Vue, Angular (auto-escape by default)
  • Verify auto-escaping is enabled and not bypassed

Never Use Framework Security Bypasses with Untrusted Data

Frameworks provide "escape hatches" that bypass their XSS protection. Never use these with untrusted data:

  • React: dangerouslySetInnerHTML
  • Angular: bypassSecurityTrustHtml(), bypassSecurityTrustScript(), bypassSecurityTrustUrl()
  • Vue.js: v-html directive
  • Jinja2: {{ data | safe }}
  • Thymeleaf: th:utext
  • Razor: @Html.Raw()

Any API with "unsafe", "raw", "bypass", "dangerously" or "trust" in its name is a risk when it receives untrusted data.

Add Input Validation and CSP (Defense in Depth)

Input Validation (supplementary):

  • Validate expected data format (email, phone, numeric, alphanumeric)
  • Use allowlists for enumerated values
  • Input validation alone is not enough; encoding is still required

Content Security Policy (CSP):

  • Set a strict CSP header that blocks inline scripts
  • Disallow unsafe-inline and unsafe-eval
  • Use nonces or hashes for legitimate inline scripts
  • Restrict script sources to trusted domains
  • CSP is defense-in-depth, not a replacement for encoding
  • Do not set the legacy X-XSS-Protection header. It is deprecated, current browsers ignore it, and CSP is its replacement

Test with XSS Payloads

Check the encoding with these payloads:

Basic XSS:

  • <script>alert(1)</script>
  • <img src=x onerror='alert(1)'>
  • <svg onload=alert(1)>

Context-specific:

  • Attribute injection: " onclick="alert(1)"
  • JavaScript injection: '; alert(1); //
  • URL injection: javascript:alert(1)

Expected results:

  • Each payload appears on the page as text and does not execute; DevTools shows it in the markup as HTML entities
  • The browser console shows no errors
  • Legitimate input still renders and behaves correctly
  • Automated scanners such as OWASP ZAP or Burp Suite report no XSS

Common Vulnerable Patterns

Untrusted data reaches script-related HTML tags or attributes without encoding, in any of these contexts:

// VULNERABLE - pseudo-code

// HTML body: username = "<script>alert('XSS')</script>" renders as a live tag
output("<div>Welcome, " + username + "</div>")

// Unquoted attribute: userValue = "x onload=alert('XSS')" adds a new attribute
output("<input type=\"text\" value=" + userValue + ">")

// JavaScript context: userInput = "'; alert('XSS'); //" breaks out of the string
output("<script>var username = '" + userInput + "';</script>")

// URL/href attribute: userUrl = "javascript:alert('XSS')" executes on click
output("<a href=\"" + userUrl + "\">Click here</a>")

// Markup-parsing DOM sink: renders and executes injected tags
setInnerHtml(outputElement, userInput)

Why this is vulnerable: these are one weakness reaching five different parsers, which is why no single escaping rule covers them. Each context decides for itself what counts as dangerous. In element content it is < and &; inside an unquoted attribute a space is enough to begin a new attribute and no angle bracket is needed; inside a <script> block the parser is reading JavaScript, so a quote ends the string; in an href the payload is a scheme and contains no special character at all.

The name of this weakness invites the fix that fails. Removing or escaping <script> addresses the first line and none of the others - onload=, a broken-out string literal, and javascript: all execute without the tag ever appearing. What decides safety is an encoder matching the position the value lands in, applied at the point of output where that position is known, rather than a filter applied to the value earlier, when it is not.

Secure Patterns

Use Framework Auto-Escaping

Frameworks escape HTML automatically through their normal text-binding APIs:

  • React: <div>{userInput}</div> - auto-escapes
  • Angular: {{ userInput }} - auto-escapes
  • Vue: {{ userInput }} - auto-escapes
  • Flask/Jinja (Python): {{ userInput }} - auto-escapes in templates where auto-escaping is enabled
  • Thymeleaf (Java): <div th:text="${userInput}"></div> - auto-escapes
  • Razor (C#): @userInput - auto-escapes

Why this works: Framework text-binding APIs convert the characters with special meaning in HTML (<, >, &, ", ') into entities such as &lt;, &gt; and &amp; before rendering, so an attacker-controlled string is displayed as data rather than parsed as tags or attributes. The guarantee varies by framework and context: some engines escape only HTML text and attribute contexts, while others also sanitize selected bindings. Check that auto-escaping is enabled for the context you are writing into, and do not route untrusted content through escape hatches such as dangerouslySetInnerHTML, v-html, th:utext, @Html.Raw(), {% autoescape false %} and |safe unless it is trusted or has been sanitized.

Manual Encoding When Needed

Where no framework binding is available, or the context needs a different encoder, use the language's standard encoding functions:

  • HTML context: HTML entity encoding (<&lt;, >&gt;)
  • JavaScript context: JavaScript string escaping
  • URL context: URL/percent encoding
  • Attribute context: Quote attributes and encode special characters

The language pages show the concrete functions:

Why this works: Encoding functions neutralize special characters before they reach the browser. HTML encoders such as htmlspecialchars() in PHP, html.escape() in Python or Encode.forHtml() in Java turn <>"'& into HTML entities, so <script> becomes &lt;script&gt; and renders as plain text rather than a script tag. Each context needs its own encoder: JavaScript contexts need escape sequences (\xHH) for quotes and control characters, URL contexts need percent-encoding (%20 for spaces) to prevent parameter injection, and HTML attributes need quoting as well as entity encoding. A language-standard encoding library, rather than a custom regex replacement, covers the full character set, handles edge cases such as Unicode, and is updated as new attack vectors appear. Apply the encoder at the output boundary, where the data leaves application code for HTML, JavaScript, URL or CSS.

Safe DOM Manipulation

On the client, use text-only DOM APIs (such as textContent) that never parse their input as HTML, instead of markup-parsing APIs (such as innerHTML or document.write). Text-only APIs insert the value as a text node with no parsing step, so tags and event handlers in the data can never become part of the DOM. See the JavaScript language guidance for the exact APIs and safe/dangerous pairs.

URL Validation

Before rendering a user-controlled URL in a link or resource attribute, parse it with the platform's URL parser and check the scheme against an allowlist (typically http: and https:). Reject or replace anything else, including javascript: and data: schemes, so a crafted URL cannot execute code when a user interacts with the link. See the language-specific guidance for concrete implementations.

Common Pitfalls

  1. Encoding too late: Encode at output time, not storage time

    • Storing encoded data prevents search, sorting, and breaks functionality
  2. Wrong encoding for context: HTML encoding in JavaScript won't prevent XSS

    • HTML encoding doesn't escape JavaScript special characters
    • Use context-appropriate encoding (JavaScript encoding for <script> tags)
    • Example: var name = '{{ userInput }}'; can be broken with '; alert('XSS'); //
  3. Trusting client-side validation: Always validate/encode on server

    • Client-side validation can be bypassed via browser DevTools or HTTP clients
    • Server-side encoding is mandatory
  4. Filtering instead of encoding: Denylist filters are incomplete

    • Attackers bypass filters with tricks like: <scr<script>ipt>, <img src=x onerror=alert(1)>
    • Use allowlist validation and context-appropriate encoding
  5. Double encoding: Don't encode already-encoded data

    • Encoding twice results in visible HTML entities: &amp;lt;script&amp;gt; instead of &lt;script&gt;
    • Track whether data has already been encoded

Language-Specific Guidance

These pages cover what is specific to the tag-injection case: the blocklist attempts that characterise it, the escaping opt-out that makes a filtered value reachable, and the allowlist sanitizer to use when the input really is HTML. The encoders themselves, and the output contexts CWE-80 does not reach, are on the matching CWE-79 page for each language.

  • C# - @Html.Raw as the opt-out, Ganss.Xss HtmlSanitizer
  • Java - escapeXml="false" and bare ${} in JSP, OWASP Java HTML Sanitizer
  • JavaScript/Node.js - safe DOM APIs, framework escaping, DOMPurify (3.4.13 or later)
  • Perl - HTML escaping, Template Toolkit escaping, sanitizer patterns
  • PHP - strip_tags() mistaken for an encoder, Blade {!! !!}, HTML Purifier
  • Python - mark_safe/Markup as the opt-out, nh3

Additional Resources