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

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

critical

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

medium

How Cross-Site Scripting Happens in JavaScript Parsers and How to Fix It

A cross-site scripting vulnerability in JSXGraph's JessieCode parser allowed attackers to inject JavaScript through maliciously crafted input that appeared in error messages. The fix ensures proper output encoding when user-controlled data is included in parser error reporting.

critical

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

A critical XSS vulnerability was discovered in the `sanitizeInput()` function in script.js, where only angle brackets were being escaped while quotes, ampersands, and backticks remained unprotected. This incomplete sanitization allowed attackers to craft payloads using event handlers and template literals that bypassed the security controls entirely. The fix implements comprehensive HTML entity encoding for all XSS-relevant characters.

high

How DOM-based Cross-Site Scripting happens in JavaScript and how to fix it

A high-severity DOM-based XSS vulnerability in `public/audio_match_demo/index.html` allowed attackers to inject malicious JavaScript through manipulated song metadata from API responses. The fix replaces dangerous HTML string concatenation with secure DOM API methods that automatically escape content.

critical

How Cross-Site Scripting happens in fast-xml-parser and how to fix it

CVE-2026-25896 is a critical Cross-Site Scripting vulnerability in fast-xml-parser stemming from improper DOCTYPE entity handling, which could allow attackers to inject malicious scripts through crafted XML payloads. The fix upgrades the vulnerable dependency from version 4.4.1 to patched versions 5.3.5 and 4.5.4, eliminating the unsafe parsing behavior while preserving all legitimate XML processing functionality.