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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #7

Related Articles

critical

How stored XSS happens in TinyMCE plugins and how to fix it

The snippets plugin's `Main.ts` inserted raw, unsanitized snippet content directly into the TinyMCE editor via `editor.insertContent(snippet.content)`, allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

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.