Affected Versions
| Affected | not applicable (first-party code) |
| Fixed in | not applicable (first-party code) — see fix commit |
| Ecosystem | browser extension (Chrome/Firefox) |
| CVE / GHSA | not assigned |
| CWE | unknown |
The Vulnerability Explained
Browser extensions operate with elevated privileges, and their content scripts run in the context of every web page users visit. The notice() function in this extension's content script was responsible for displaying notification UI by injecting elements into the page's DOM.
The vulnerable code fetched notice.html from the extension package and assigned it directly to innerHTML:
const html = await fetch(chrome.runtime.getURL('notice.html')).then(response => response.text());
// ... later assigned to shadow root innerHTML
While chrome-extension:// URLs are protected by the extension's Content Security Policy and cannot be intercepted by web pages, this pattern creates a critical vulnerability under a different threat model. If an attacker compromises the extension package—through supply chain attack, malicious insider, or Chrome Web Store compromise—the injected malicious HTML would execute with full access to every page the user visits.
The attack scenario works like this: an attacker with write access to the extension's build artifacts modifies notice.pug (the template source) to include a <script> tag or event handler. When the extension builds, build.mjs processes this through pug.renderFile(), producing a poisoned notice.html. Every user receiving this compromised extension version would then execute attacker-controlled JavaScript in the context of https://banking.example.com, https://mail.example.com, or any site they visit.
The real-world impact is severe: full DOM access, credential theft, session hijacking, and the ability to perform actions on behalf of the user on any website.
The Fix
The fix takes a defense-in-depth approach by eliminating the HTML file entirely and restructuring how the notification UI is constructed.
In build.mjs: The build loop was modified to exclude notice from the pug template rendering:
-for (const name of ['popup', 'options', 'prompt', 'notice']) writeFileSync(new URL(`${name}.html`, `file://${dist}/`), pug.renderFile(fileURLToPath(new URL(`${name}.pug`, import.meta.url)), { pretty: true }));
+for (const name of ['popup', 'options', 'prompt']) writeFileSync(new URL(`${name}.html`, `file://${dist}/`), pug.renderFile(fileURLToPath(new URL(`${name}.pug`, import.meta.url)), { pretty: true }));
In content.js: The vulnerable fetch and innerHTML assignment was replaced with direct shadow DOM construction:
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'closed' });
const css = await fetch(chrome.runtime.getURL('notice.css')).then(response => response.text());
const style = document.createElement('style');
style.textContent = css;
The key security improvements: (1) no HTML parsing occurs—only CSS text is fetched and assigned via textContent, which cannot execute script; (2) the attack surface is reduced by removing notice.html from the extension package entirely; (3) the shadow DOM structure is maintained but populated through safe DOM APIs rather than HTML string injection.
Key Takeaways
- Never assign fetched content to
innerHTMLin extension content scripts, even fromchrome-extension://URLs. The threat model includes package compromise, not just network interception. - CSS injection via
textContentis safe; HTML injection viainnerHTMLis not. When styling is the only requirement, fetch CSS files and assign tostyle.textContentrather than parsing HTML. - Remove unused attack surface. The fix demonstrates that
notice.htmlwas unnecessary—eliminating the file entirely is more secure than sanitizing it. - Extension CSP protects against MITM but not against build-time compromise. Developers must threat-model their code against malicious insiders and supply chain attacks, not just external network attackers.
- Shadow DOM isolation does not prevent XSS. The closed shadow root in the vulnerable code provided encapsulation from page scripts, but did nothing to stop malicious HTML from executing within that shadow boundary.
How Orbis AppSec Detected This
Source: The notice.html resource fetched via chrome.runtime.getURL() and retrieved through fetch().then(response => response.text())
Sink: Assignment to innerHTML of a shadow root, creating an HTML injection point in the content script execution context
Missing control: No sanitization or validation of the fetched HTML content before DOM insertion; no alternative safe construction methods used
CWE: CWE-79 (Improper Neutralization of Input During Web Page Generation, 'Cross-site Scripting')
Fix: Removed notice.html from the pug template build loop and restructured content.js to construct the shadow DOM programmatically with textContent CSS injection, eliminating all HTML parsing from the notification display path
Orbis AppSec detected this vulnerability automatically. Try Orbis AppSec on your repositories to find and fix issues like this.
Conclusion
This vulnerability illustrates how seemingly safe patterns—fetching from chrome-extension:// URLs with CSP protection—can mask serious security risks under alternative threat models. The fix demonstrates that the most secure code is often the code you don't write: by eliminating notice.html entirely and using programmatic DOM construction with textContent, the extension removes an entire class of injection vulnerabilities while maintaining identical functionality.
For developers building browser extensions, the lesson is clear: treat all HTML parsing as dangerous, even from your own package resources, and prefer textContent and programmatic element creation over innerHTML whenever possible.