Back to Blog
critical SEVERITY8 min read

How Unsanitized External Content Injection happens in JavaScript and how to fix it

A critical content injection vulnerability in `app-viewer/js/youtube.js` allowed arbitrary HTML and JavaScript from a compromised external CDN to execute directly in the hosting origin's context. The fix replaces unsafe `fetch()`-then-inject patterns with direct URL assignment, eliminating the attack surface entirely. This change prevents supply-chain-style attacks where a compromised JSON manifest could deliver malicious payloads to every user of the viewer.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

This is an Unsanitized External Content Injection vulnerability (CWE-79/CWE-494) in JavaScript, specifically in `app-viewer/js/youtube.js`. The vulnerable code fetched raw HTML from an external CDN URL and injected it directly into `viewerFrame.srcdoc` and `tab.document.write()`, allowing any attacker who could compromise the upstream JSON manifest to execute arbitrary scripts in the hosting origin's context. The fix replaces the fetch-then-inject pattern with direct URL assignment (`viewerFrame.src = fullUrl`) and `window.open(fullUrl, "_blank", "noopener,noreferrer")`, so the browser's built-in sandboxing and same-origin policy handle content isolation instead of trusting raw HTML strings.

Vulnerability at a Glance

cweCWE-79 (Improper Neutralization of Input During Web Page Generation) / CWE-494 (Download of Code Without Integrity Check)
fixReplace fetch-then-inject with direct URL assignment (`viewerFrame.src`) and `window.open()` so the browser sandbox isolates the content natively
riskAttacker-controlled HTML/JS executes in the hosting origin's context, enabling session theft, data exfiltration, or full page takeover
languageJavaScript
root causeRaw HTML fetched from an external CDN was injected into `viewerFrame.srcdoc` and `tab.document.write()` without any sanitization or integrity verification
vulnerabilityUnsanitized External HTML Content Injection (XSS via srcdoc / document.write)

How Unsanitized External Content Injection happens in JavaScript and how to fix it

Summary

A critical content injection vulnerability in app-viewer/js/youtube.js allowed arbitrary HTML and JavaScript from a compromised external CDN to execute directly in the hosting origin's context. The fix replaces unsafe fetch()-then-inject patterns with direct URL assignment, eliminating the attack surface entirely. This change prevents supply-chain-style attacks where a compromised JSON manifest could deliver malicious payloads to every user of the viewer.


Introduction

The app-viewer/js/youtube.js file is responsible for loading viewer content — specifically, it reads a game entry from a JSON manifest, resolves a fullUrl, and displays the content in an iframe. On the surface, this sounds straightforward. But the way the content was loaded introduced a severe security flaw: the code was fetching raw HTML from an external URL and injecting it directly into the page without any sanitization or integrity check.

Here is the specific pattern that caused the problem, starting at line 25:

fetch(fullUrl)
  .then(r => r.text())
  .then(html => {
    viewerFrame.srcdoc = html;
  });

And a second injection path in the "open in new tab" handler:

openNewTab.onclick = async () => {
  try {
    const html = await fetch(fullUrl).then(r => r.text());
    const tab = window.open("about:blank");
    if (tab) {
      tab.document.write(html);
      tab.document.close();
    }
  } catch (e) {
    console.error("Popup blocker or link generation failure encountered:", e);
  }
};

Both paths share the same fatal flaw: the raw response body — an arbitrary string of HTML — is handed directly to the browser's rendering engine with no filtering, no Content Security Policy enforcement, and no integrity verification. If the content at fullUrl contains <script> tags, event handlers, or any other executable markup, the browser will run it.


The Vulnerability Explained

What's actually happening

When viewerFrame.srcdoc is set to an HTML string, the browser renders that string as if it were a full HTML document loaded from the parent page's origin. This is critically different from setting viewerFrame.src to a URL — with src, the browser loads the content in a separate origin context and applies same-origin isolation. With srcdoc, the content inherits the parent frame's origin.

This means any <script> tag in the injected HTML has full access to:
- document.cookie of the hosting origin
- localStorage and sessionStorage
- The ability to make credentialed requests on behalf of the user
- The DOM of the parent page (if sandbox attributes are not set)

The tab.document.write(html) path is equally dangerous. A tab opened with window.open("about:blank") inherits the opener's origin, and document.write() injects markup directly into that context.

The attack chain

The content URL (fullUrl) is derived from an external JSON manifest (described in the PR as gms.json, hosted on GitHub). This creates a supply-chain attack vector:

  1. An attacker compromises the external GitHub repository hosting gms.json.
  2. They add or modify an entry so that game.url points to attacker-controlled HTML.
  3. Every user who opens the viewer loads that malicious HTML.
  4. Because srcdoc runs in the hosting origin's context, the attacker's script can steal session tokens, exfiltrate data, or perform actions as the authenticated user.

No user interaction beyond opening the viewer is required. The attack is silent, affects all users simultaneously, and leaves no obvious indicator in the application's own code.

Why document.write() makes it worse

The openNewTab handler compounds the problem. document.write() is one of the most dangerous DOM APIs in JavaScript — it bypasses the browser's HTML parser streaming protections and can be used to inject <script> tags that execute synchronously. Even if srcdoc were somehow restricted, this second code path would remain a fully exploitable injection point.


The Fix

The fix is elegant in its simplicity: stop fetching and injecting HTML entirely. Instead, let the browser load the URL natively.

Before

// VULNERABLE: fetches raw HTML and injects it into srcdoc
fetch(fullUrl)
  .then(r => r.text())
  .then(html => {
    viewerFrame.srcdoc = html;
  });

openNewTab.onclick = async () => {
  try {
    const html = await fetch(fullUrl).then(r => r.text());
    const tab = window.open("about:blank");
    if (tab) {
      tab.document.write(html);
      tab.document.close();
    }
  } catch (e) {
    console.error("Popup blocker or link generation failure encountered:", e);
  }
};

After

// SAFE: browser loads the URL in an isolated context
viewerFrame.src = fullUrl;

openNewTab.onclick = () => {
  window.open(fullUrl, "_blank", "noopener,noreferrer");
};

Why this works

viewerFrame.src = fullUrl tells the browser to load the URL as a navigation, not as an injected string. The content is fetched and rendered in the iframe's own browsing context, which is isolated from the parent page by the same-origin policy. Scripts inside the iframe cannot access the parent's DOM, cookies, or storage unless the origins explicitly match and postMessage is used.

window.open(fullUrl, "_blank", "noopener,noreferrer") opens the URL as a proper navigation in a new tab. The noopener flag severs the link between the opener and the new tab (preventing the new tab from accessing window.opener), and noreferrer prevents the Referer header from leaking origin information. This is the correct, modern pattern for opening external URLs.

The fix also removes approximately 13 lines of code — a net reduction in complexity and attack surface. There is no longer any HTML string handling, no document.write(), and no fetch()-then-inject pattern to audit or maintain.


Key Takeaways

  • viewerFrame.srcdoc = fetchedHtml is never safe with external content — srcdoc runs in the parent origin's context, making it equivalent to innerHTML injection for security purposes.
  • tab.document.write(html) in openNewTab.onclick was a second, independent injection path — fixing only the srcdoc line would have left the application still exploitable.
  • The attack vector was a JSON manifest on an external GitHub repository — supply-chain compromise of gms.json would have silently delivered malicious HTML to all users with no changes to the application's own code.
  • Switching from fetch-then-inject to src-based loading required zero sanitization logic — the browser's same-origin policy handles isolation natively when content is loaded via src.
  • noopener,noreferrer on window.open() is not optional — omitting these flags allows the opened tab to access window.opener and potentially manipulate the parent page.

How Orbis AppSec Detected This

  • Source: The game.url field from an externally hosted JSON manifest (gms.json), resolved into fullUrl at runtime
  • Sink: viewerFrame.srcdoc = html and tab.document.write(html) in app-viewer/js/youtube.js, where the raw fetch() response body was injected directly into the DOM
  • Missing control: No HTML sanitization, no Content Security Policy enforcement on srcdoc content, and no integrity verification of the external manifest or fetched HTML
  • CWE: CWE-79 (Improper Neutralization of Input During Web Page Generation — Cross-site Scripting) and CWE-494 (Download of Code Without Integrity Check)
  • Fix: Replaced fetch(fullUrl).then(r => r.text()).then(html => { viewerFrame.srcdoc = html; }) with viewerFrame.src = fullUrl, and replaced the document.write() popup handler with window.open(fullUrl, "_blank", "noopener,noreferrer")

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 youtube.js is a textbook example of how a seemingly minor implementation choice — fetching HTML and assigning it to srcdoc instead of just setting src — can open a critical security hole. The two vulnerable code paths (srcdoc injection and document.write()) together created a scenario where a single compromised entry in an external JSON manifest could execute arbitrary JavaScript in every user's browser session, with full access to the hosting origin's data.

The fix required no new libraries, no complex sanitization logic, and no architectural changes. It simply removed the unnecessary fetch-and-inject pattern and replaced it with the browser's native, safe content loading mechanism. This is a recurring theme in web security: the safest code is often the code that does the least, and delegates trust decisions to the platform rather than trying to handle them manually.

When building viewers, embeds, or any component that loads external content, always ask: am I injecting a string, or am I navigating to a URL? The answer determines whether you're trusting your own sanitization — or trusting the browser's battle-tested isolation model.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #14

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.