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:
- An attacker compromises the external GitHub repository hosting
gms.json. - They add or modify an entry so that
game.urlpoints to attacker-controlled HTML. - Every user who opens the viewer loads that malicious HTML.
- Because
srcdocruns 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 = fetchedHtmlis never safe with external content —srcdocruns in the parent origin's context, making it equivalent toinnerHTMLinjection for security purposes.tab.document.write(html)inopenNewTab.onclickwas a second, independent injection path — fixing only thesrcdocline would have left the application still exploitable.- The attack vector was a JSON manifest on an external GitHub repository — supply-chain compromise of
gms.jsonwould 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 viasrc. noopener,noreferreronwindow.open()is not optional — omitting these flags allows the opened tab to accesswindow.openerand potentially manipulate the parent page.
How Orbis AppSec Detected This
- Source: The
game.urlfield from an externally hosted JSON manifest (gms.json), resolved intofullUrlat runtime - Sink:
viewerFrame.srcdoc = htmlandtab.document.write(html)inapp-viewer/js/youtube.js, where the rawfetch()response body was injected directly into the DOM - Missing control: No HTML sanitization, no Content Security Policy enforcement on
srcdoccontent, 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; })withviewerFrame.src = fullUrl, and replaced thedocument.write()popup handler withwindow.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
- CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)
- CWE-494: Download of Code Without Integrity Check
- OWASP Cross Site Scripting Prevention Cheat Sheet
- OWASP DOM-based XSS Prevention Cheat Sheet
- MDN Web Docs: HTMLIFrameElement.srcdoc
- MDN Web Docs: window.open() — noopener and noreferrer
- Semgrep rules: srcdoc injection
- fix: the youtube in youtube.js