Skip to content

CWE-83: Improper Neutralization of Script in Attributes in a Web Page

Overview

CWE-83 is the specific Cross-Site Scripting (XSS) variant where the injection point is an HTML attribute value, not the markup body or a <script> tag. This covers two related patterns: event-handler attributes (onload, onerror, onclick, onmouseover, and similar) that the browser executes as JavaScript, and URI-bearing attributes (href, src, action, formaction) populated with a javascript: or other executable-scheme URI. MITRE's entry also names style, which is a third case with a different fix - see the remediation section below. A finding lands here rather than under CWE-79 when the vulnerable output is an attribute value rather than element content.

Relationship to Other CWEs

  • CWE-79 (Cross-Site Scripting) - the parent category covering all XSS injection contexts: HTML body, attributes, JavaScript, CSS, URLs. Use CWE-79's page for the general case or when the injection context is not specifically an attribute.
  • CWE-83 (this page) - the attribute case: the payload is a value assigned to an attribute the page already writes, so it carries no tags of its own and often no angle brackets.
  • CWE-80 (Improper Neutralization of Script-Related HTML Tags) - the sibling variant for injecting whole tags such as <script>, <img> or <iframe> into markup, rather than injecting through an existing tag's attribute.

OWASP Classification

A05:2025 - Injection

Risk

High: Successful attribute-based script injection executes attacker-controlled JavaScript in the victim's browser with the same effect as any other XSS variant - session and cookie theft, credential harvesting through a fake form drawn over the page, and actions performed against the application as the victim. It is easy to miss during review because the payload does not look like a <script> tag; it is a value assigned to an ordinary-looking attribute.

Remediation Steps

Core Principle: Never place untrusted data inside an HTML attribute value without attribute-context encoding, never build event-handler attributes from untrusted data at all, and always validate the URI scheme before writing untrusted data into a URI-bearing attribute.

Trace the Data Path

  • Source: Where untrusted data enters (user profile fields, search terms, redirect targets, uploaded file names, query parameters used to build links).
  • Sink: Where the data is written into an attribute - server-side string-built HTML, template attribute binding, or a client-side setAttribute() / property assignment.
  • Data Flow / Missing Controls: Look for attribute values built by string concatenation without attribute encoding, and for href/src/action values written without validating the URI scheme.

Apply Attribute-Context Encoding (Primary Defense)

HTML body encoding is not enough inside an attribute - encode for the attribute context specifically:

  • Always quote attribute values (attr="value", never a bare attr=value).
  • Encode ", ', <, >, and & so injected input cannot terminate the quoted value and add a new attribute.
  • Use the platform's or framework's attribute-encoding function rather than a hand-rolled replace - the CWE-79 language pages name the encoder for each ecosystem.

Never Build Event-Handler Attributes from Untrusted Data

onclick, onload, onerror, onmouseover, and the other on* attributes are executed as JavaScript by the browser - there is no safe encoding that makes it acceptable to put untrusted data inside one. Attach the behavior with an event listener in application JavaScript instead, and pass the untrusted value as data (a property or data-* attribute), not as part of the handler's source text.

Before writing untrusted data into href, src, action, or formaction:

  • Parse the value with the platform's URL parser and check the scheme against an allowlist (typically http: and https:, plus mailto: if relevant).
  • Reject anything else, including javascript: and data: unless data: images are explicitly required and separately validated.
  • Do not rely on a denylist of "bad" schemes - browsers accept many casing, whitespace, and encoding variants of javascript: that a denylist regex will miss.
  • Decide deliberately what happens to a relative URL. It has no scheme, so an allowlist check rejects it; if relative links are legitimate here, resolve the value against your own base URL first and validate the result, rather than exempting scheme-less values from the check.

Treat style as CSS, Not as a URI

A style attribute holds a declaration list, so there is no scheme for a URL parser to read - any URLs in it are arguments to url() inside a declaration. Nor is it still a script sink: expression() was IE-only and went with the engine, so no current browser executes JavaScript out of CSS. What an injected declaration can still do is position an element, so the realistic impact is a transparent overlay for clickjacking or a background image whose request signals what the page contains. Build the attribute from application-defined values only - pick a class name from an allowlist, or assign properties through the platform's style API (element.style.color = value), which parses each value as a single declaration and discards it if it is not one, instead of concatenating a declaration string.

Use Framework-Safe Attribute Binding

All three major frameworks apply attribute-context encoding to a bound value, so the " onmouseover=" breakout is closed by React's JSX props, Angular's [attr] binding and Vue's v-bind alike. They differ on the scheme check, which is the half that matters here, because javascript: contains no character an encoder acts on:

  • Angular sanitizes the URL context automatically, so a javascript: value bound to <a href> is neutralised. Its RESOURCE_URL context - <script src>, <iframe src> - is not sanitized at all: Angular refuses the binding outright and requires an explicit bypassSecurityTrustResourceUrl.
  • React blocks javascript: in href and src. Measured on React 19.2.8, an <a href> bound to JaVaScRiPt:alert(1) with a leading space in front of it renders as javascript:throw new Error('React has blocked a javascript: URL as a security precaution.'), so leading whitespace and casing do not evade it. data:text/html,<script>alert(1)</script> passes through unchanged, so this is a block on one scheme rather than an allowlist.
  • Vue does neither. Its escaping goes through setAttribute, which closes the breakout and does nothing to a scheme; Vue's own security guide names <a :href="userProvidedUrl"> as a security issue and says to sanitize server-side before the value is stored.

So the scheme allowlist above remains yours to apply on all three - the framework only decides whether there is a second line behind it. Avoid the raw/DOM-property escape hatches (dangerouslySetInnerHTML, bypassSecurityTrustUrl) for attribute values built from untrusted data.

Test with Attribute-Injection Payloads

  • " onmouseover="alert(1) - breaks out of a quoted attribute to add a new event handler
  • javascript:alert(document.cookie) - placed in href/src/action
  • data:text/html,<script>alert(1)</script> - placed in src. What a browser does with this depends on the attribute, so expect the check rather than the browser to be what stops it: top-level navigation to a data: URL has been blocked by Chrome and Firefox since 2017-18, so an href will not run it, while an iframe src still executes it in an opaque origin - unable to read the parent page's cookies or DOM, but able to draw a convincing form over your page
  • Confirm each payload is rejected by the scheme check or rendered as an inert attribute value, and that legitimate URLs and attribute values still work

Common Vulnerable Patterns

// VULNERABLE - pseudo-code
output("<a href=\"" + untrustedUrl + "\">Click here</a>")
// Attack: untrustedUrl = "javascript:alert(document.cookie)"
// Result: clicking the link executes attacker-controlled script

output("<div onclick=\"handleClick('" + untrustedInput + "')\">")
// Attack: untrustedInput = "'); alert(document.cookie); //"
// Result: breaks out of the quoted argument and injects a second statement

Why this is vulnerable: an attribute value is parsed twice, and only the first parse is HTML. The browser reads the attribute out of the markup, and then hands the result to a second parser chosen by which attribute it is - a URL parser for href and src, the JavaScript parser for anything beginning on, the CSS parser for style. Getting the first parse right says nothing about the second.

This is what makes the usual defence miss. javascript:alert(document.cookie) contains no character HTML escaping acts on - no angle bracket, no ampersand, no quote - so an encoder passes it through unchanged and correct, and the URL parser then reads a scheme that executes. The onclick case fails the other way round: the value is escaped correctly for HTML and lands inside a JavaScript string literal, where a plain apostrophe ends the string and the rest is code. What the value needs is the encoding for the context it arrives in, applied last, plus a check on the scheme for anything that becomes a URL, since no amount of encoding makes javascript: safe to navigate to.

Secure Patterns

// SECURE - pseudo-code
scheme = parseUrlScheme(untrustedUrl)
if scheme not in ["http", "https"]:
    reject(untrustedUrl)
output("<a href=\"" + attributeEncode(untrustedUrl) + "\">Click here</a>")

// No inline event-handler attribute at all - attach behavior separately
setAttribute(element, "data-item-id", untrustedInput)
addEventListener(element, "click", handleClick)   // handleClick reads element.dataset.itemId

Why this works: Validating the URI scheme against an allowlist before writing it into a href/src/action attribute means an executable-scheme URI is rejected before it reaches the browser, regardless of encoding. Removing the untrusted data from the event-handler attribute and passing it instead as a plain data attribute, read by a separately attached listener, removes the injection point: the browser never parses the value as JavaScript source.

Additional Resources