Anatomy of a browser extension

Browser extensions have been part of mainstream browsing since Firefox and Chromium adopted them in the early 2000s. Today, even casual users typically run at least one — often an adblocker. Security research on extensions, however, tends to be scattered across individual bug reports and coverage of malicious Chrome extensions. Understanding the structure of an extension, the contexts its code runs in, and the permissions it can request is essential for assessing risk in this ecosystem.

This piece focuses on Firefox and Chromium, which set the standard for most extensions. Their store policies and how extensions interact with the browser differ, and those differences ultimately shape the security posture for end users. A browser extension is essentially a bundle of HTML, CSS, and JavaScript files that runs in its own domain, labeled by the extension ID. For example, uBlock Origin on Chromium runs in the domain cjpalhdlnbpafiamejdnhcphjbkeiagm, visible in the web store URL. Extension URLs follow the pattern browser_specific_extension_scheme://extension_id/actual_resource_name, so the popup for uBlock Origin would be chrome-extension://cjpalhdlnbpafiamejdnhcphjbkeiagm/popup-fenix.html.

Every extension includes a required manifest.json file that declares its identity, required permissions, and which files run in which contexts. Manifest versions have evolved, with newer versions enforcing stricter security defaults and renaming key fields. The three major execution contexts are the content script (which runs inside web pages), the popup, and the background script. In manifest version 2 (v2), these are declared via content_scripts, browser_action, and background_script; in manifest version 3 (v3), they become content_scripts, action, and background.

Screenshot of a manifest.json file showing minor changes between versions.

Background script and permissions

The background context is the most privileged of the three. It can access nearly all Extension APIs — the collective name for the browser extension and WebExtensions APIs — giving it broad control over tabs, cookie reading, and website data modification. These capabilities are gated behind permissions declared in the manifest.json under the permissions key, and users see a friendly summary during installation.

Screenshot of the popup describing which permissions the uBlock Origin extension will be granted.

During a security review, the permissions key deserves close attention. In v2, its values are a mix of host patterns and actual permission strings. An entry like all_urls or a regex grants access to every domain, enabling the extension to send requests to any site with all cookies attached, even bypassing SameSite strict protections. In v3, domain permissions move to host_permissions, and optional permissions can be requested at runtime with user consent.

Screenshot of the permissions key in manifest.json

Screenshot of a v3 manifest

High-risk permissions to look for include those touching sensitive user data — history, bookmarks, cookies, or permissions — and those granting broader browser control like downloads, management, or tab. The activeTab permission is particularly potent: it lets the extension inject JavaScript into whatever domain the user is actively viewing, provided there is user interaction first. That combination of reach and gating makes activeTab attractive to both attackers and malicious extensions. If an attacker can inject malicious input into the executed script, they may achieve Universal XSS (UXSS). A malicious extension could also bind shortcuts over common actions like copy and paste, or interact with tabs it should not touch. Auditing the background script is therefore crucial — both for how it calls browser APIs and how it communicates with content scripts.

Content scripts

Content scripts handle what the background context cannot: direct interaction with a page's DOM. They run in the context of the website but inside an isolated world, where JavaScript variables from the extension are invisible to the host page and to other extensions. A simple example would be prepending a readability summary to the top of an article.

Sample code adding a summary of a page to the top to help readability

Content scripts can also listen for user actions on a page and trigger extension behavior in response — translating highlighted text is a common use case. They work with the background script to deliver the extension's full intended experience.

The popup context contains the HTML and JavaScript for the menu that appears when clicking the extension's toolbar icon. It usually lets users adjust settings or send requests to backend servers tied to the extension. In uBlock Origin, for instance, the popup offers controls for toggling JavaScript, accessing options, or disabling the extension on the current site.

Screenshot of a uBlock Origin popup for a Google Docs page, showing what has been blocked and allowing the user to interact directly with the extension.

Popup pages — along with any other HTML pages bundled in the extension — can be a focal point for attacks. MetaMask, a crypto wallet extension, is a case in point: if a malicious website can obscure or spoof the popup, it might trick a user into signing transactions, potentially draining funds.

Screenshot of two popups from the the crypto wallet extension MetaMask.

JavaScript running in the popup has the same API access as the background script, and it executes in the extension's own domain — meaning any vulnerability reachable there operates with full extension privileges.

Three Paths into an Extension

An attacker can only enter an extension from two main sources: a website the user visits, or another installed extension. From those entry points, browser extensions boil down to three attack surfaces:

  1. The extension consumes attacker-controlled data from a website and uses it unsafely.
  2. Another extension, or a website, sends a message that the extension handles dangerously.
  3. The extension processes URL parameters on load and uses them in privileged operations — which requires a vulnerable configuration.

The most common exploit of the first surface is when a content script extracts text from a page, modifies it, and injects that result back as HTML. On sites that accept user content, such as comments or social media, this can easily turn into an XSS.

The second surface depends on messaging APIs like sendMessage and the inbound counterpart onMessageExternal/onConnectExternal. If the extension fails to verify the message sender, a malicious extension can trigger any of the functionality those handlers expose.

external_connectable property

external_connectable widens that hazard further: a misconfiguration allows any website to message the extension, not just whitelisted extensions.

Also dangerous is the web_accessible_resources declaration:

web_accessible_resources code

Making an HTML page web-accessible opens two vectors. First, if the page uses URL parameters in privileged ways and loads in an iframe from an arbitrary website, that site can trigger those actions with crafted parameters. Second, if the page enables sensitive actions, it may be susceptible to clickjacking, where a malicious page overlays the iframe to trick users into executing them. An article on a Privacy Badger clickjacking attack covers this technique in detail.

These paths rarely align into a single exploit. In practice, a realistic vulnerability usually needs both a coding error and a misconfiguration, which is a key reason why extensions are generally fairly difficult to exploit.

Vulnerability Classes

Cross-site scripting

XSS appears in two extension contexts: the content script and the background script. An XSS inside the content script lets attackers act on behalf of the user for that specific website. A background-script XSS is more severe, granting full access to any Extension API the extension holds, which can escalate into universal XSS across sites.

Modern extensions are largely safe from direct script injection because Content Security Policy forbids unsafe-inline for both v2 and v3. However, pages such as the popup or options remain vulnerable to HTML injection. The unsafe-eval directive — allowed only in v2 — is the main indicator of possible code-execution risk when inspecting an extension.

When looking for XSS vulnerabilities in extensions, check if the manifest contains the unsafe-eval directive.

When unsafe-eval is present, look for calls to execution sinks like eval(), Function(), setTimeout(), or setInterval(), plus the deprecated v2 API tabs.executeScript(), which also accepts code strings. The v3 replacement, scripting.executeScript(), is restricted to bundled files. Given these constraints, Firefox extensions warrant extra scrutiny: the Firefox Add-On Store still accepts v2 extensions, giving them access to unsafe-eval and tabs.executeScript(). Many extensions ship both a v2 Firefox build and a v3 Chromium build. Wladimir Palant's article on extension attack surfaces details how outdated jQuery versions combined with unsafe-eval can be exploited in such cases.

Server-side request forgery

Extensions bring SSRF to the client side, sending requests with the user's cookies. The impact depends on whether an attacker can control the request URL in a fetch or XMLHttpRequest, and on the extension's configured host permissions plus whether the developer sends credentials.

The shift from v2 to v3 has materially changed the SSRF landscape. In v2 Firefox, even extensions with no permissions could send cookie-bearing requests to any domain, excluding SameSite cookies. Chromium's v2 never allowed that without permissions. In v3, extensions need explicit host_permissions to attach cookies to any request. Combined with the Firefox add-on store's acceptance of new v2 extensions, Firefox extensions currently hold a much larger SSRF footprint than their Chromium counterparts.

Extension API injection

What sets extensions apart from web apps is injection into privileged Extension APIs, with consequences split between data mutation and data exfiltration. For example, downloads.download() is restricted to the user's Downloads directory and sanitizes path traversal, but repeated calls can still fill the disk. Bookmark create/remove, the cookies set function, and similar APIs enable destructive or annoying behavior, like bookmark floods.

A historical example is programmatic use of tabs.update() to run javascript: URLs — the equivalent of typing javascript:alert(document.domain) into the address bar — which was possible until patched. Most API injection scenarios today lead to denial of service or data loss rather than high-impact framework-level attacks.

Mitigations

UUID randomization

Because extensions expose HTML files, many browsers now randomize each extension's internal ID, its UUID, rendering fixed file paths useless for attacks unless the UUID leaks. Relative paths in extension files depend on this ID.

Screenshot of developer details for the extension FoxyProxy.

While a content script could glean paths via browser.runtime.getURL(), page scripts cannot. In my experience, Firefox randomizes the UUID by default, based on the user context (container), while Safari randomizes on each app restart. Chromium has historically been an outlier: despite use_dynamic_url appearing in both the Chrome and MDN docs, dynamic UUIDs were only rolled out by default in August 2024.

Extending CodeQL for Extension Threats

Browser-extension vulnerabilities map naturally onto CodeQL’s existing vulnerability models, including XSS and SSRF. The standard code-injection query starts from a RemoteFlowSource and tracks data into eval-style sinks. Those sinks are still relevant for extensions, but the browser adds its own set. By creating a module of browser-specific sinks and extending the query’s Sink class, you can teach CodeQL to flag them too.

/**
* Sink for chrome.tabs.executeScript() which may allow an allow arbitrary   * javascript execution.
**/
class ExecuteScript extends DataFlow::Node {
  ExecuteScript() { exists( DataFlow::CallNode c | 
    c = tabsRef().getAMethodCall("executeScript") | (this = c.getArgument(0) and c.getNumArgument() = 1) 
    or
    (this = c.getArgument(1) and c.getNumArgument() = 2 ) )}
}

The sink definition above targets the executeScript method on browser.tabs. When called with a single argument, that argument is the code to inject; when called with two, the second argument is — the first is the tab ID.

Screenshot of the execute script method

Chrome’s API implementation is not shipped inside an extension, so CodeQL has no source code to trace through. To make analysis possible, you must model how the foreground and background scripts talk to each other.

class BrowserStep extends DataFlow::SharedFlowStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
      (exists (DataFlow::ParameterNode p |
        pred instanceof BrowserAPI::SendMessage and
        succ = p and 
           p.getParameter() instanceof BrowserAPI::AddListener
      ))
    }
  }

  class ReturnStep extends DataFlow::SharedFlowStep {
    override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
      (exists (DataFlow::ParameterNode p |
        succ instanceof BrowserAPI::SendMessageReturnValue and
        pred = p.getAnInvocation().getArgument(0) and 
           p.getParameter() instanceof BrowserAPI::AddListenerReturn
      ))
    }
  }

In JavaScript CodeQL, extending the SharedFlowStep class tells the engine that data moves from one flow node to another. The first model above declares a single-step flow from the first parameter of sendMessage in the foreground script to the third parameter of the AddListener method in the background script. The second model covers the reverse direction. With these in place, the standard Code Injection and CSRF queries can find XSS and SSRF in extensions. Ready-made CodeQL packs are published in GitHub’s Community Pack repository.

From Model to Real Bug

The models found a Universal XSS (UXSS) flaw in smartup, a gesture-driven extension with over 100,000 downloads. Smartup receives untrusted input via onMessageExternal, applies parsing, and eventually processes the message. In the apps_test path, it appends the message’s apptype property to code passed to the chrome.tabs.executeScript v2 API, producing XSS. Because smartup requests broad permissions (all URLs or activeTab) and accepts messages from any extension, a separate extension the user installed — even one with no permissions of its own — could achieve UXSS on any website.

chrome.runtime.onMessageExternal.addListener(function(message,sender,sendResponse){
    sub.funOnMessage(message,sender,sendResponse);
})
...
case"apps_test":
    let _fun=function(){
        if(message.appjs){
            chrome.tabs.executeScript({code:"sue.apps['"+message.apptype+"'].initUI();",runAt:"document_start"});      <----- message is passed into executeScript
            return;
        }

The exploit stacks three developer-side mistakes: overly broad permissions, an open messaging policy, and a code-injection weakness. Removing any one breaks the chain. That reinforces why raising developer awareness of secure defaults — and the risks of changing them — is so important.

What Users Can Do

Check who wrote the extension and remember that the author can exercise every permission in the manifest. Extensions that have gone stale are more likely to rely on older, insecure APIs. Don’t rely on the install prompt: open the manifest and read the permissions yourself. For example, Chrome’s prompt does not mention the activeTab permission even when it is present. On Firefox, where manifest v3 is not required for new extensions, many add-ons still run on v2 — which, generally speaking, makes Firefox the less secure environment.

For a deeper look, the CodeQL community packs include queries for XSS, SSRF, API injection, and best-practice alerts covering all the issues described here.