How XSS via Incomplete HTML Escaping Happens in JavaScript Browser Extensions and How to Fix It
Introduction
The extension/lib/popup-response.js file is responsible for rendering API response data—headers, endpoint details, and body content—inside a browser extension's popup UI. To do this safely, the code relies on a helper function called esc() to sanitize values before they are written to the DOM via innerHTML. The security of every innerHTML assignment in the popup depends entirely on esc() doing its job correctly.
The problem? esc() was almost correct—but it missed one character: the backtick (`). That single omission created a meaningful XSS bypass in production code that processes attacker-influenced API response data.
The Vulnerability Explained
What esc() Was Supposed to Do
The esc() function at line 391 of popup-response.js is a custom HTML-escaping utility. Here is the vulnerable version:
// BEFORE (vulnerable)
function esc(s) {
if (s == null) return "";
const d = document.createElement("div");
d.textContent = String(s);
return d.innerHTML.replace(/"/g, """).replace(/'/g, "'");
}
The function uses a clever browser-native trick: it sets textContent on a throwaway <div> element, which causes the browser to automatically encode <, >, and & into their HTML entities. It then manually replaces " with " and ' with ' to cover attribute-value contexts.
This covers most dangerous characters—but not backticks.
Why Backticks Matter
Backticks (`) are significant in two ways:
-
JavaScript Template Literals: In modern JavaScript,
`delimits template literal strings. If a value containing a backtick is interpolated into an inline<script>block or an event handler attribute, it can break out of a string context. -
Legacy Attribute Delimiter: Some older browsers and HTML parsers have historically treated backticks as attribute value delimiters in certain edge cases, creating an injection path even in attribute contexts.
Because the extension popup renders data from API responses—including server-controlled HTTP headers, endpoint URLs, and response bodies—an attacker who controls a server the user is inspecting can craft a response that includes backtick-wrapped payloads.
A Concrete Attack Scenario
Suppose a malicious API server returns an HTTP header like:
X-Custom-Header: value`+alert(document.cookie)+`
If this header value is passed through the vulnerable esc() function and then inserted into an innerHTML context like:
headerCell.innerHTML = `<span class="header-value">${esc(headerValue)}</span>`;
The backtick is passed through unescaped. Depending on how the surrounding template literal or script context is structured, the backtick can terminate one string and open another, executing arbitrary JavaScript within the browser extension's privileged context.
Browser extensions often have access to sensitive APIs (cookies, storage, tabs, network requests). XSS in an extension popup is not merely cosmetic—it can mean full compromise of the extension's capabilities.
The Fix
The fix is surgical and precise: a single additional .replace() call added to the end of the esc() chain.
Before and After
// BEFORE (vulnerable) — popup-response.js:394
return d.innerHTML.replace(/"/g, """).replace(/'/g, "'");
// AFTER (fixed) — popup-response.js:394
return d.innerHTML.replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`");
Why This Works
The HTML entity ` is the numeric character reference for the backtick character. When a browser renders ` inside an HTML document, it displays a literal backtick—but the character has no syntactic meaning to the HTML parser or JavaScript engine. It cannot act as a template literal delimiter or an attribute boundary.
By adding .replace(/\/g, "`")to the chain, theesc()` function now neutralizes all four of the most commonly exploited character classes in HTML injection:
| Character | Encoded As | Threat Neutralized |
|---|---|---|
< |
< |
Tag injection (via textContent trick) |
> |
> |
Tag injection (via textContent trick) |
& |
& |
Entity injection (via textContent trick) |
" |
" |
Double-quoted attribute breakout |
' |
' |
Single-quoted attribute breakout |
` |
` |
Template literal / legacy attribute breakout (new) |
The change is scoped entirely to the esc() function, which means every call site that already used esc() is automatically protected without any other code changes.
Prevention & Best Practices
1. Prefer textContent Over innerHTML for Plain Text
If you only need to display text (not HTML structure), use textContent or innerText directly. These APIs never interpret their value as HTML, making injection impossible:
// Safe for plain text — no escaping needed
element.textContent = userControlledValue;
Reserve innerHTML for cases where you genuinely need to construct HTML markup.
2. Use a Battle-Tested Sanitization Library
Rolling your own escaping function, as this code did, is risky because it is easy to miss edge cases. Consider using:
- DOMPurify — The gold standard for client-side HTML sanitization
- he — A robust HTML entity encoder/decoder for Node.js and browsers
3. Audit Every Character Your Escaper Handles
If you must write a custom escaper, create a checklist of all characters with HTML or JavaScript significance and verify each one is covered:
< > & " ' ` / = (and context-dependent: newlines, null bytes, Unicode escapes)
Write unit tests that explicitly verify each character is encoded:
test("esc() encodes backticks", () => {
expect(esc("`alert(1)`")).toBe("`alert(1)`");
});
4. Apply Context-Aware Escaping
HTML escaping rules differ by context. A value safe in element content may not be safe in a URL attribute (href, src) or a JavaScript event handler. Consider using a library that supports context-aware escaping, or avoid putting user data in high-risk attribute contexts altogether.
5. Content Security Policy (CSP) as Defense in Depth
Even if an XSS injection succeeds, a strict Content Security Policy can prevent script execution. For browser extensions, define a strict CSP in your manifest:
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'none';"
}
This will not replace proper escaping, but it provides a meaningful second line of defense.
Relevant Standards
- OWASP XSS Prevention Cheat Sheet: Covers all escaping contexts in detail
- CWE-79: Improper Neutralization of Input During Web Page Generation
- OWASP Top 10 A03:2021: Injection (includes XSS)
Key Takeaways
- The
esc()function inpopup-response.jswas one character away from being correct. Escaping"and'but not`left a real bypass path for template-literal-based injection. - Browser extension XSS is higher-stakes than web page XSS. Extensions often have privileged access to browser APIs, cookies, and storage—making code execution in an extension popup particularly dangerous.
- Custom escaping functions must be tested against every dangerous character, not just the most obvious ones. A missing backtick escape is exactly the kind of subtle gap that manual code review misses but automated scanners catch.
- The fix was a one-line change, but its impact covers every
innerHTMLcall site that relies onesc()—demonstrating the value of centralizing sanitization logic. - API response data is attacker-controlled data. Any value that originates from a server response—headers, URLs, body content—must be treated as untrusted input before being rendered in a UI.
How Orbis AppSec Detected This
- Source: API response data (HTTP headers, endpoint values, response body) returned by a server the user is inspecting
- Sink:
innerHTMLassignments throughoutpopup-response.jsthat use theesc()helper function - Missing control: The
esc()function encoded"and'but omitted the backtick character (`), leaving a bypass for template-literal-style injection - CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- Fix: Added
.replace(/\/g, "`")to theesc()return value inpopup-response.js` to encode backticks as their HTML numeric entity
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
This vulnerability is a textbook example of why "almost correct" sanitization is not good enough. The esc() function in popup-response.js used a smart browser-native encoding trick and handled the most common dangerous characters—but the omission of backtick encoding left a real, exploitable gap in an application that processes attacker-influenced server data.
The fix is minimal: one additional .replace() call. But the lesson is broader: when you write a custom HTML escaping function, you must account for all characters that carry syntactic meaning in HTML and JavaScript contexts, including the often-overlooked backtick. Centralize your sanitization logic, test it explicitly for every dangerous character, and layer it with a Content Security Policy for defense in depth.
Security is not about being mostly right—it is about being completely right where it counts.