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
Published August 26, 2026Reviewed 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.


Prevention & Best Practices

1. Never use srcdoc with externally fetched content

srcdoc is designed for embedding trusted, developer-controlled HTML strings. It is not appropriate for content fetched from remote URLs, user input, or any source you do not fully control at build time. If you need to display remote content in an iframe, always use src.

2. Avoid document.write() entirely

document.write() is deprecated for good reason. It is synchronous, bypasses streaming HTML parsing, and is a well-known XSS vector. Replace it with document.createElement() + appendChild() patterns, or better yet, avoid injecting raw HTML at all.

3. Use Content Security Policy (CSP)

A strict CSP can limit the damage of an injection vulnerability by restricting which scripts are allowed to execute. For example:

Content-Security-Policy: default-src 'self'; frame-src https://trusted-cdn.example.com;

This would not have prevented the srcdoc injection (since srcdoc content inherits the parent's origin), but it would have blocked exfiltration attempts to unknown domains.

4. Apply Subresource Integrity (SRI) to external manifests

If your application loads a JSON manifest from an external source, consider using SRI hashes or a pinned hash check to verify the manifest has not been tampered with. This adds a layer of protection against supply-chain compromise.

5. Sandbox your iframes

When embedding third-party content, use the sandbox attribute on iframes to restrict what the embedded content can do:

<iframe sandbox="allow-scripts allow-same-origin" src="..."></iframe>

Note that sandbox does not help when srcdoc is used with injected HTML — the content still runs in the parent's origin context.

6. Reference standards

  • OWASP XSS Prevention Cheat Sheet: Covers output encoding and safe DOM manipulation patterns
  • CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)
  • CWE-494: Download of Code Without Integrity Check

Key Takeaways

  • viewerFrame.srcdoc = fetchedHtml is never safe with external contentsrcdoc 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.


References

Frequently Asked Questions

What is unsanitized external content injection?

It occurs when an application fetches raw HTML or script content from an external source and injects it directly into the DOM (via srcdoc, innerHTML, or document.write) without sanitization, allowing a compromised source to execute arbitrary code in the host page's origin.

How do you prevent external content injection in JavaScript?

Never inject raw HTML strings fetched from remote sources into the DOM. Use `src` or `href` attributes to let the browser load content in an isolated context, enforce Content Security Policy (CSP), and validate external resources with Subresource Integrity (SRI) where possible.

What CWE is unsanitized external content injection?

It maps primarily to CWE-79 (Cross-Site Scripting) when the injected content executes scripts, and CWE-494 (Download of Code Without Integrity Check) when the remote content is loaded without integrity verification.

Is sanitizing the HTML string enough to prevent this vulnerability?

Sanitization alone is fragile — even well-maintained sanitizers have bypasses, and the attack surface grows with every new HTML feature. The preferred fix is to avoid injecting raw HTML entirely by using `src`-based loading so the browser enforces isolation.

Can static analysis detect this vulnerability?

Yes. Tools like Semgrep, ESLint security plugins, and AI-assisted scanners (like Orbis AppSec) can flag patterns where `fetch()` results are assigned to `srcdoc`, `innerHTML`, or passed to `document.write()` without sanitization.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #14

Related Articles

critical

How Unsandboxed iframe Content Injection happens in JavaScript and how to fix it

A critical vulnerability in `app-viewer/js/LupineVault.js` allowed attacker-controlled HTML fetched from an external CDN to execute scripts in the application's full origin context by injecting it directly into an iframe's `srcdoc` attribute without any sandbox restrictions. The fix adds a `sandbox` attribute to the iframe element, restricting what the injected content can do even if it contains malicious scripts. This prevents cross-site scripting and origin-context script execution that could

critical

How Unsafe Attribute Injection happens in JavaScript i18n and how to fix it

A critical attribute injection vulnerability in `assets/js/language.js` allowed attackers with write access to locale JSON files to inject arbitrary HTML attributes — including event handlers like `onclick` — into DOM elements via the `applyTranslations()` function. The fix introduces a strict allowlist (`SAFE_ATTRS`) that restricts which attributes the i18n system can set, closing the injection path entirely. This is a concrete reminder that any code path that writes attacker-influenced data in

critical

How DOM-Based XSS Happens in JavaScript CSS Selectors and How to Fix It

A DOM-based XSS vulnerability in SaltGUI's `Output.js` allowed attackers to inject malicious characters into CSS query selectors by manipulating minion ID values. The root cause was that `btoa()`-encoded IDs could still contain `+`, `/`, and `=` characters that are invalid in CSS selectors, enabling selector breakout. The fix converts the encoding to base64url (RFC 4648 §5), replacing all problematic characters before the ID is used in `querySelector` calls.

critical

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

A cross-site scripting (XSS) vulnerability was discovered in `extension/lib/chatgpt.js` where the `chatgpt.alert()` function used `modalMsg.innerText` to set user-controlled content before passing it to `chatgpt.renderHTML()`, allowing injected HTML to be rendered unsanitized. The fix replaces `innerText` with `textContent` and introduces an allowlist of safe HTML tags and attributes inside `renderHTML()`. This prevents attackers from injecting arbitrary HTML or JavaScript through modal message

high

How XSS via Incomplete HTML Escaping happens in JavaScript Browser Extensions and how to fix it

A high-severity cross-site scripting (XSS) vulnerability was discovered in `extension/lib/popup-response.js`, where the `esc()` HTML-escaping function failed to encode backtick characters. Because backticks are valid JavaScript template literal delimiters and can serve as event handler injection vectors in older browsers, this gap allowed attacker-controlled data to break out of safe HTML encoding and potentially execute arbitrary scripts. The fix adds a single `.replace(/\`/g, "&#96;")` call to

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr