Back to Blog
high SEVERITY7 min read

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, "`")` call to

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a Cross-Site Scripting (XSS) vulnerability (CWE-79) in a JavaScript browser extension, specifically in the `esc()` function inside `extension/lib/popup-response.js`. The root cause is that the function encoded double quotes and single quotes but omitted backtick characters, which can act as JavaScript template literal delimiters or legacy event-handler injection vectors. The fix adds `.replace(/\`/g, "`")` to the escaping chain, ensuring backticks in attacker-controlled values are neutralized before being written to `innerHTML`. Any JavaScript code using `innerHTML` with user-supplied data must escape all HTML-significant characters, including backticks.

Vulnerability at a Glance

cweCWE-79
fixAdded .replace(/`/g, "`") to the esc() escaping chain in popup-response.js
riskAttacker-controlled data rendered via innerHTML can execute arbitrary scripts in the extension context
languageJavaScript
root causeThe esc() function escaped " and ' but not `, leaving a bypass path for template-literal-based injection
vulnerabilityCross-Site Scripting (XSS) via Incomplete HTML Escaping

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 &quot; and ' with &#39; to cover attribute-value contexts.

This covers most dangerous characters—but not backticks.

Why Backticks Matter

Backticks (`) are significant in two ways:

  1. 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.

  2. 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, "&quot;").replace(/'/g, "&#39;");

// AFTER (fixed) — popup-response.js:394
return d.innerHTML.replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/`/g, "&#96;");

Why This Works

The HTML entity &#96; is the numeric character reference for the backtick character. When a browser renders &#96; 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
< &lt; Tag injection (via textContent trick)
> &gt; Tag injection (via textContent trick)
& &amp; Entity injection (via textContent trick)
" &quot; Double-quoted attribute breakout
' &#39; Single-quoted attribute breakout
` &#96; 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("&#96;alert(1)&#96;");
});

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 in popup-response.js was 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 innerHTML call site that relies on esc()—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: innerHTML assignments throughout popup-response.js that use the esc() 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.


References

Frequently Asked Questions

What is incomplete HTML escaping in JavaScript?

Incomplete HTML escaping occurs when a sanitization function encodes some dangerous characters (like `"` and `'`) but misses others (like `` ` ``), leaving a bypass path for injecting malicious HTML or script content via innerHTML.

How do you prevent XSS in JavaScript browser extensions?

Always escape all HTML-significant characters—including `<`, `>`, `&`, `"`, `'`, and `` ` ``—before inserting any user-controlled or API-response data into innerHTML. Better yet, prefer `textContent` for plain text and use a well-tested sanitization library for HTML.

What CWE is XSS via incomplete escaping?

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

Is escaping double and single quotes enough to prevent XSS?

No. Backtick characters (`` ` ``) can be used as JavaScript template literal delimiters and as attribute delimiters in some legacy browsers. A complete escaping function must handle all characters that carry meaning in HTML and JavaScript contexts.

Can static analysis detect incomplete escaping like this?

Yes. Tools like Semgrep, ESLint security plugins, and multi-agent AI scanners (like the one used here) can trace data flow from untrusted sources through escaping functions to innerHTML sinks, flagging cases where the escaping logic is incomplete.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7

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 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.

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 Denial of Service via infinite loop happens in Node.js dependencies and how to fix it

A high-severity Denial of Service vulnerability in the nanoid package (CVE-2026-67213) was discovered in the project's dependency tree, where crafted input could trigger an infinite loop during random ID generation. The fix upgrades nanoid from 3.3.17 to 3.3.18 and adds an npm override to ensure all transitive dependencies use the patched version.