---
title: "The Page Never Had Permission. The Extension Did."
description: "A web page cannot call most extension APIs, but it can influence a content script that can message a privileged service worker. This research method proves when that chain becomes a confused deputy and how to reduce it to explicit, testable capabilities."
date: 2026-08-31
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/posts/browser-extension-page-trust-boundary/
---

## The boundary in 60 seconds

A web page does not need direct access to extension APIs to borrow extension authority. It only
needs a message path whose final receiver mistakes **reachability** for **authorization**.

The common chain looks harmless:

```text
web page
  → window.postMessage or shared DOM
  → isolated content script
  → runtime.sendMessage
  → extension service worker
  → privileged browser API or cross-origin request
```

Chrome describes content scripts as isolated from the page's JavaScript environment. That protects
variables and execution state; it does not make values read from the DOM, `MessageEvent.data`, or a
page-controlled attribute trustworthy. Chrome's messaging security guidance is explicit: content
scripts are less trustworthy than the extension service worker, their messages may be
attacker-crafted, and privileged actions reachable from them should be narrowly limited.

There is also a second, distinct route. An extension can declare `externally_connectable` and let
matching web pages call `runtime.sendMessage()` or `runtime.connect()` directly. Those calls arrive
at `onMessageExternal` or `onConnectExternal`. The manifest match is an admission rule, not
operation-level authorization.

The research question is therefore:

> Can a page-controlled principal cause the service worker to exercise a capability that the page
> could not exercise itself, without a fresh and specific authorization decision?

This write-up builds a bounded method for proving or rejecting that hypothesis in a synthetic lab.
It does not require a production target, real account data, or destructive effects.

## Three principals, two message paths, one deputy

<figure class="diagram">
<svg viewBox="0 0 780 454" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two message paths from a web page to an extension service worker: a relay through a content script and a direct externally connectable route, both ending at a privileged capability decision.">
<defs>
<marker id="bep-map-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent" /></marker>
<marker id="bep-map-r" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-crit" /></marker>
</defs>
<text x="4" y="20" class="dg-accent">PAGE → EXTENSION AUTHORITY MAP</text>
<text x="4" y="48" class="dg-muted">MESSAGE DELIVERY IS NOT A DELEGATION OF EVERY EXTENSION CAPABILITY</text>
<rect x="0" y="82" width="170" height="102" rx="9" class="dg-box-crit"/>
<text x="18" y="112" class="dg-crit">WEB PAGE</text><text x="18" y="139" class="dg-mono">origin · frame · DOM</text><text x="18" y="164" class="dg-mono">attacker-controlled data</text>
<line x1="172" y1="132" x2="210" y2="132" class="dg-line-crit" marker-end="url(#bep-map-r)"/>
<rect x="214" y="82" width="176" height="102" rx="9" class="dg-box"/>
<text x="232" y="112" class="dg-label">CONTENT SCRIPT</text><text x="232" y="139" class="dg-mono">isolated world</text><text x="232" y="164" class="dg-muted">RELAY / TRANSLATOR</text>
<line x1="392" y1="132" x2="430" y2="132" class="dg-line-accent" marker-end="url(#bep-map-a)"/>
<rect x="434" y="82" width="184" height="102" rx="9" class="dg-box-accent"/>
<text x="452" y="112" class="dg-label">SERVICE WORKER</text><text x="452" y="139" class="dg-mono">sender · schema · state</text><text x="452" y="164" class="dg-accent">POLICY DECISION</text>
<line x1="620" y1="132" x2="658" y2="132" class="dg-line-crit" marker-end="url(#bep-map-r)"/>
<rect x="662" y="82" width="118" height="102" rx="9" class="dg-box-crit"/>
<text x="678" y="112" class="dg-crit">CAPABILITY</text><text x="678" y="139" class="dg-mono">tabs · fetch</text><text x="678" y="164" class="dg-mono">storage · native</text>
<path d="M84 186 C84 244 468 224 468 188" class="dg-line-crit" marker-end="url(#bep-map-r)"/>
<text x="150" y="232" class="dg-mono">DIRECT: externally_connectable → onMessageExternal</text>
<rect x="0" y="284" width="240" height="112" rx="9" class="dg-box"/>
<text x="18" y="314" class="dg-label">Browser-observed context</text><text x="18" y="342" class="dg-mono">sender.url · sender.origin</text><text x="18" y="367" class="dg-mono">tab · frameId · documentId</text>
<rect x="270" y="284" width="240" height="112" rx="9" class="dg-box-accent"/>
<text x="288" y="314" class="dg-label">Request capability</text><text x="288" y="342" class="dg-mono">operation · object ID</text><text x="288" y="367" class="dg-accent">nonce · expiry · gesture</text>
<rect x="540" y="284" width="240" height="112" rx="9" class="dg-box-crit"/>
<text x="558" y="314" class="dg-label">Effect boundary</text><text x="558" y="342" class="dg-mono">allowed result only</text><text x="558" y="367" class="dg-crit">NO CALLER-SUPPLIED AUTHORITY</text>
<line x1="242" y1="340" x2="266" y2="340" class="dg-line-accent" marker-end="url(#bep-map-a)"/>
<line x1="512" y1="340" x2="536" y2="340" class="dg-line-accent" marker-end="url(#bep-map-a)"/>
<text x="4" y="438" class="dg-muted">THE DEPUTY IS CONFUSED WHEN IT SEES A VALID CHANNEL BUT CANNOT EXPLAIN WHY THIS CALLER MAY PERFORM THIS EFFECT.</text>
</svg>
<figcaption>The content-script relay and the direct external-message route expose different browser metadata. Test them separately. In both cases the service worker must authorize the requested capability.</figcaption>
</figure>

The page, content script, and service worker are separate principals even when they belong to one
product workflow. Their authority is different:

| Principal                | Browser-enforced properties                              | Attacker-influenced properties                                      | Typical authority                          |
| ------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------ |
| Web page                 | Origin, frame and document lifecycle                     | Page JavaScript, DOM, attributes, events and application state      | Same-origin web capabilities               |
| Isolated content script  | Extension identity and isolated JavaScript global        | Almost every value obtained from the page or page messaging channel | Limited extension APIs and runtime message |
| Extension service worker | Extension origin, package code and privileged API access | Message body; sometimes stale or incomplete workflow state          | Host permissions, tabs, storage, downloads |

The service worker is a **deputy**: it holds authority on behalf of a user-facing feature. It
becomes confused when a lower-trust principal can choose the operation or resource while the deputy
supplies the permission.

## What the platform controls actually guarantee

Security reviews often assign more meaning to platform controls than those controls provide.

| Control                          | What it establishes                                                       | What it does not establish                                                     |
| -------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Content-script isolated world    | Page scripts cannot directly read the content script's JavaScript globals | Data obtained through shared DOM or explicit messages is trustworthy           |
| `content_scripts.matches`        | Where a script may be injected                                            | Every path, frame, tenant and workflow on that origin may use every feature    |
| `all_frames: false`              | Declarative injection defaults to the top frame                           | A top-frame message describes the current approved document                    |
| `externally_connectable.matches` | Which page URL patterns may open the external channel                     | The caller may invoke every exposed operation                                  |
| `runtime.onMessage`              | A message came through an internal extension messaging route              | Its page-derived payload or requested object is authorized                     |
| `runtime.onMessageExternal`      | A caller used the external extension messaging route                      | The sender origin alone is sufficient authorization                            |
| `MessageSender`                  | Browser-observed sender context available to the listener                 | Page-provided copies of those fields are true, or the requested effect is safe |
| Host permission                  | The extension context may access a remote origin                          | A content script may choose an arbitrary URL for the worker to fetch           |
| User installation                | The user accepted the extension package and permission prompt             | Every future page action is a fresh user decision                              |

Chrome's `externally_connectable` documentation also makes a crucial scoping distinction: its
`matches` setting controls web pages that connect directly and **does not affect content scripts**.
A broad content-script match combined with a `window.postMessage` relay is therefore a separate
externally reachable surface even when `externally_connectable` is absent.

## The vulnerability pattern: authority injection

The dangerous message schema usually contains authority rather than intent:

```json
{
  "action": "fetch",
  "url": "https://internal-api.example/admin/export",
  "method": "POST",
  "headers": { "X-Workspace": "another-tenant" },
  "body": "..."
}
```

The caller chooses a URL, method, headers, tenant, and body. The service worker contributes host
permission, extension cookies or an authenticated network position. Even if the handler checks
that `action === "fetch"`, it has not authorized the destination or object.

A capability-oriented schema reverses that ownership:

```json
{
  "version": 1,
  "operation": "read-current-project-summary",
  "projectId": "project-17",
  "requestId": "7de0d4c1-...",
  "approvalId": "gesture-8b2f"
}
```

The extension resolves a fixed HTTPS endpoint, fixed method, permitted response fields, current
tenant and selected project from trusted state. The caller supplies a bounded identifier, not a
network primitive.

Recurring confused-deputy variants include:

| Variant                      | Lost boundary                                                          | Observable consequence                                        |
| ---------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------- |
| Arbitrary cross-origin fetch | Page chooses a URL; worker supplies host permission                    | Reading or mutating a resource unavailable to the page        |
| Tab authority borrowing      | Page chooses a tab ID or navigation target; worker supplies `tabs`     | Cross-tab disclosure, capture, script injection or navigation |
| Storage oracle               | Page chooses a key or namespace; worker supplies extension storage     | Disclosure or corruption of extension state                   |
| Download deputy              | Page chooses URL, filename or open behavior; worker supplies downloads | Unwanted file creation or misleading local artifact           |
| External-message overreach   | An allowed origin can select any dispatcher action                     | One approved integration inherits unrelated capabilities      |
| Port lifecycle confusion     | A long-lived port remains trusted after document or account transition | Stale authorization applied to a new page state               |
| Response overexposure        | Worker returns raw API output to a less trusted content script         | Secrets leak back into a page-reachable context               |

The vulnerability is not merely "a page can send a message." It is the demonstrated difference
between what the page can do alone and what it can cause the deputy to do.

## Two ingress routes must never be merged

An internal listener and an external listener receive different sender populations. Treating them
as aliases erases useful browser context.

```js
// Dangerous: both populations reach the same permissive dispatcher.
const dispatch = (request, sender) => perform(request.action, request.args);

chrome.runtime.onMessage.addListener(dispatch);
chrome.runtime.onMessageExternal.addListener(dispatch);
```

For an internal message from a content script, the service worker can evaluate browser-provided
`sender.id`, `sender.url`, `sender.origin`, `sender.tab`, `sender.frameId` and, where available,
`sender.documentId`. For an external web-page message, `sender.url` or `sender.origin` identifies
the connecting page, but no content script stands between the page and listener. For another
extension, `sender.id` is central. Missing fields must fail closed when policy requires them.

Do not accept an origin, tab ID, frame ID, extension ID or document ID copied into the message
body. Those are claims from the caller. Policy must use the `sender` object supplied out of band by
the browser and normalize URLs with the URL parser rather than suffix or substring checks.

## A bounded synthetic lab

Build a lab around inert effects:

- an unpacked Manifest V3 extension with one service worker and one content script;
- two loopback or reserved test origins: one allowed and one negative-control origin;
- a page bridge using `window.postMessage` with a fixed namespace;
- a separate `externally_connectable` entry for only the allowed test origin;
- a fake project catalog containing `project-17` and `project-18`;
- one privileged effect that increments an in-memory counter for an approved project;
- an independent event ledger recording sender context, decision and counter change.

Do not use real cookies, browser history, downloads, credentials, remote administration interfaces,
or production origins. A counter is enough to prove that a request crossed the boundary.

The deliberately unsafe relay is small:

```js
// content-script.js — intentionally unsafe laboratory pattern
window.addEventListener("message", (event) => {
  if (event.data?.namespace !== "lab-extension") return;
  chrome.runtime.sendMessage(event.data.payload);
});
```

It does not check `event.source`, the current page origin, the message shape, operation, size, or
document lifecycle. More importantly, even a perfect `event.source === window` check only proves
which window emitted the event. It does not make the page trusted. MDN recommends checking both
`origin` and possibly `source` for cross-document messaging, but those checks are admission facts;
the service worker still needs operation authorization.

<figure class="evidence">
  <picture>
    <source
      type="image/webp"
      srcset="/images/posts/responsive/browser-extension-page-boundary-cutaway-768.webp 768w, /images/posts/responsive/browser-extension-page-boundary-cutaway-1200.webp 1200w, /images/posts/browser-extension-page-boundary-cutaway.webp 1600w"
      sizes="(max-width: 820px) 94vw, 900px"
    />
    <img
      src="/images/posts/browser-extension-page-boundary-cutaway.webp"
      alt="Architectural cutaway of a web page, isolated content script, extension service worker and privileged browser APIs with message evidence attached at each boundary."
      loading="lazy"
      decoding="async"
      width="1600"
      height="900"
    />
  </picture>
  <figcaption>
    <span>Boundary plate / 02</span>
    The isolated world separates JavaScript environments, not trust. Preserve the browser-observed sender and reduce every request to a named capability before the privileged context acts.
  </figcaption>
</figure>

## Inventory before sending a message

Map code and policy before interacting with the handler.

### Manifest and injection inventory

| Field                    | Evidence to capture                                                   |
| ------------------------ | --------------------------------------------------------------------- |
| Content-script matches   | Exact schemes, hosts, paths, exclusions and dynamic registrations     |
| Frame reach              | `all_frames`, `match_about_blank`, `match_origin_as_fallback`         |
| Execution world          | `ISOLATED` or `MAIN`, and why shared DOM/event channels are required  |
| External callers         | `externally_connectable.matches`, IDs and wildcard use                |
| Privileged permissions   | `permissions`, `host_permissions`, optional grants and `activeTab`    |
| Web-accessible resources | Exposed paths and the sites permitted to load them                    |
| Lifecycle                | Service-worker restart behavior, port reconnection and state recovery |

### Message and effect inventory

| Field               | Questions                                                                |
| ------------------- | ------------------------------------------------------------------------ |
| Listener population | Internal message, external page, external extension, port or one-shot?   |
| Sender policy       | Which browser-observed fields are mandatory and how are URLs normalized? |
| Schema              | Are unknown keys, wrong types, nesting and oversized values rejected?    |
| Dispatcher          | Can the caller choose a URL, method, script, tab, path, key or command?  |
| Trusted state       | Where do tenant, selected object, user gesture and approval come from?   |
| Response            | Which fields return to the content script or external page?              |
| Effect observer     | What independent signal proves the privileged action occurred?           |
| Denial observer     | What proves rejected requests produced no effect?                        |

Static code search should locate `onMessage`, `onConnect`, `onMessageExternal`,
`onConnectExternal`, `sendMessage`, `connect`, `postMessage`, custom DOM events and privileged API
calls. Trace every listener to the final effect; a sanitized handler that forwards into a generic
dispatcher is not the end of the review.

## Preserve and validate sender context

A secure internal listener separates channel admission, schema validation and authorization:

```js
const ALLOWED_ORIGIN = "https://app.lab.invalid";
const PROJECTS = new Set(["project-17"]);

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  const senderUrl = sender.url ? new URL(sender.url) : null;
  const admitted =
    sender.id === chrome.runtime.id &&
    senderUrl?.origin === ALLOWED_ORIGIN &&
    sender.frameId === 0 &&
    typeof sender.documentId === "string";

  if (!admitted) return false;

  const valid =
    message &&
    message.version === 1 &&
    message.operation === "read-current-project-summary" &&
    typeof message.projectId === "string" &&
    Object.keys(message).every((key) =>
      ["version", "operation", "projectId", "requestId", "approvalId"].includes(
        key,
      ),
    );

  if (!valid || !PROJECTS.has(message.projectId)) {
    sendResponse({ ok: false, code: "DENIED" });
    return false;
  }

  void handleApprovedSummary(message, sender).then(sendResponse);
  return true;
});
```

This is a pattern, not drop-in policy. Some browsers or contexts do not provide every sender field,
and browser compatibility must be tested. The policy should state which missing value causes a
denial. It should also bind `approvalId` to trusted extension state rather than accepting any
well-formed string.

The external route needs its own listener and smaller operation set:

```js
chrome.runtime.onMessageExternal.addListener(
  (message, sender, sendResponse) => {
    const origin = sender.url ? new URL(sender.url).origin : null;
    if (origin !== "https://partner.lab.invalid") return false;

    if (
      message?.version !== 1 ||
      message?.operation !== "extension-health" ||
      Object.keys(message).some(
        (key) => !["version", "operation", "requestId"].includes(key),
      )
    ) {
      sendResponse({ ok: false, code: "DENIED" });
      return false;
    }

    sendResponse({ ok: true, state: "ready" });
    return false;
  },
);
```

The partner origin receives a status capability, not a generic dispatcher. An origin allowlist is
stronger when it grants a deliberately tiny protocol.

## Capability minimization beats string filtering

Blocking `..`, `javascript:` or selected domains leaves the caller in control of the security
primitive. Replace primitives with named, narrow capabilities.

| Avoid caller-supplied       | Accept instead                          | Resolve inside trusted code                          |
| --------------------------- | --------------------------------------- | ---------------------------------------------------- |
| Full URL and HTTP method    | Operation plus bounded object ID        | Fixed HTTPS origin, path, method and response fields |
| Tab ID and script source    | Current-workflow action                 | Active approved tab and packaged script file         |
| Storage key or namespace    | Typed preference name                   | Fixed key map and value schema                       |
| Filename and download URL   | Report ID                               | Server-side report URL and safe generated filename   |
| Raw HTML or JavaScript      | Structured display fields               | Text-only DOM construction in the extension context  |
| Arbitrary dispatcher action | Versioned discriminated operation union | Explicit handler with default-deny                   |

Return values need the same treatment. Chrome warns that data sent to a content script may leak to
the page. The service worker should return the minimum fields needed for rendering, never raw
cross-origin responses, authentication headers, internal identifiers or broad extension storage.

## Bind authorization to lifecycle

Message policy can be correct at connection time and wrong at effect time. Single-page
applications replace state without replacing the top-level document. Frames navigate. Service
workers stop and restart. Long-lived ports disconnect and reconnect. A user changes account or
workspace while a queued request remains pending.

A high-impact request should bind:

- `tab.id`, `frameId` and, where supported, `documentId` from `MessageSender`;
- normalized page origin;
- extension-observed account or tenant;
- selected object resolved by the extension;
- a real user gesture or explicit approval state;
- a single-use nonce and short expiry;
- the exact operation and response class.

Revalidate the binding immediately before the effect. Do not store "this port is trusted" as an
unqualified boolean. Store what it was trusted to do, for which document and until when. On
navigation, account transition, worker restart, port disconnect or permission change, invalidate
the state.

## Reproduction methodology: prove the delta

A safe result demonstrates a capability delta with canaries:

1. **Establish the baseline.** From the test page alone, attempt the inert counter operation. Record
   that the page has no direct route to the privileged counter.
2. **Record the ingress.** Send one well-formed request through the content-script relay. Capture
   browser-observed sender fields at the service worker, not caller-provided copies.
3. **Observe the effect independently.** Correlate a generated request ID with the counter ledger.
   A success response is supporting evidence, not effect proof.
4. **Change one authorization dimension.** Repeat from the negative-control origin, a child frame,
   stale document, different project ID or external route.
5. **Compare vulnerable and fixed builds.** The approved request should still work; each unauthorized
   variant should produce a decision record and no counter change.
6. **Exercise lifecycle boundaries.** Navigate, change account, reconnect the port and restart the
   worker before replaying the request.
7. **Remove the lab.** Unload the extension, clear the synthetic state and confirm neither ingress
   route remains available.

Change one variable per test. When origin, frame, schema and object all change together, a denial
does not reveal which gate worked.

Never demonstrate the delta by reading real cross-origin account data, capturing another tab,
creating a real download or invoking a native host. The canary effect proves the authorization
failure without increasing harm.

## The decision pipeline

<figure class="diagram">
<svg viewBox="0 0 780 452" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Extension message authorization pipeline validating route, browser sender, schema, workflow state and capability before a bounded effect, with all failures producing a denial record.">
<defs><marker id="bep-gate-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-accent"/></marker></defs>
<text x="4" y="20" class="dg-accent">EXTENSION CAPABILITY DECISION PIPELINE</text>
<text x="4" y="48" class="dg-muted">THE REQUEST EARNS ONE EFFECT; IT DOES NOT INHERIT THE WORKER'S AMBIENT AUTHORITY</text>
<rect x="0" y="78" width="132" height="92" rx="9" class="dg-box"/>
<text x="16" y="106" class="dg-label">1 · ROUTE</text><text x="16" y="133" class="dg-mono">internal</text><text x="16" y="156" class="dg-mono">external · port</text>
<line x1="134" y1="124" x2="150" y2="124" class="dg-line-accent" marker-end="url(#bep-gate-a)"/>
<rect x="154" y="78" width="132" height="92" rx="9" class="dg-box"/>
<text x="170" y="106" class="dg-label">2 · SENDER</text><text x="170" y="133" class="dg-mono">origin · frame</text><text x="170" y="156" class="dg-mono">document · id</text>
<line x1="288" y1="124" x2="304" y2="124" class="dg-line-accent" marker-end="url(#bep-gate-a)"/>
<rect x="308" y="78" width="132" height="92" rx="9" class="dg-box"/>
<text x="324" y="106" class="dg-label">3 · SCHEMA</text><text x="324" y="133" class="dg-mono">version · type</text><text x="324" y="156" class="dg-mono">size · keys</text>
<line x1="442" y1="124" x2="458" y2="124" class="dg-line-accent" marker-end="url(#bep-gate-a)"/>
<rect x="462" y="78" width="144" height="92" rx="9" class="dg-box-accent"/>
<text x="478" y="106" class="dg-label">4 · WORKFLOW</text><text x="478" y="133" class="dg-mono">tenant · gesture</text><text x="478" y="156" class="dg-accent">nonce · expiry</text>
<line x1="608" y1="124" x2="624" y2="124" class="dg-line-accent" marker-end="url(#bep-gate-a)"/>
<rect x="628" y="78" width="152" height="92" rx="9" class="dg-box-accent"/>
<text x="644" y="106" class="dg-label">5 · CAPABILITY</text><text x="644" y="133" class="dg-mono">fixed operation</text><text x="644" y="156" class="dg-accent">bounded result</text>
<rect x="0" y="230" width="230" height="104" rx="9" class="dg-box-crit"/>
<text x="18" y="260" class="dg-crit">DENIAL RECORD</text><text x="18" y="287" class="dg-mono">gate · reason · request ID</text><text x="18" y="312" class="dg-mono">zero privileged effects</text>
<rect x="276" y="230" width="230" height="104" rx="9" class="dg-box-accent"/>
<text x="294" y="260" class="dg-label">EFFECT OBSERVER</text><text x="294" y="287" class="dg-mono">expected canary changed</text><text x="294" y="312" class="dg-accent">adjacent canary unchanged</text>
<rect x="550" y="230" width="230" height="104" rx="9" class="dg-box"/>
<text x="568" y="260" class="dg-label">EVIDENCE BUNDLE</text><text x="568" y="287" class="dg-mono">sender · decision · effect</text><text x="568" y="312" class="dg-mono">negative control · cleanup</text>
<path d="M702 172 C702 202 666 208 666 226" class="dg-line-accent" marker-end="url(#bep-gate-a)"/>
<line x1="508" y1="282" x2="546" y2="282" class="dg-line-accent" marker-end="url(#bep-gate-a)"/>
<path d="M374 172 C374 202 391 208 391 226" class="dg-line-accent" marker-end="url(#bep-gate-a)"/>
<text x="4" y="386" class="dg-muted">FAIL CLOSED ON:</text>
<rect x="0" y="400" width="780" height="42" rx="7" class="dg-box-crit"/>
<text x="18" y="426" class="dg-mono">WRONG ROUTE · UNKNOWN ORIGIN · STALE DOCUMENT · EXTRA KEY · MISSING GESTURE · REPLAY · EFFECT MISMATCH</text>
</svg>
<figcaption>Authorization is a pipeline, not a sender-origin if-statement. Every failed gate records a reason and produces no privileged effect.</figcaption>
</figure>

## Evidence matrix

Separate a message receipt from a privileged effect and a privileged effect from unauthorized
impact.

| Claim                                     | Positive evidence                                                   | Negative control                                                 | Strong conclusion                                             |
| ----------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------- |
| Page reaches the content-script bridge    | Namespaced event observed in the expected top document              | Foreign namespace and child-frame event are ignored              | Tested page-to-content ingress is characterized               |
| Content script reaches the service worker | Browser sender context and request ID recorded                      | Message from extension page or stale document is distinguished   | Listener identifies tested sender classes                     |
| External page route is scoped             | Allowed lab origin reaches only the status operation                | Unlisted origin and unrelated operation are denied               | Direct external ingress is origin- and capability-limited     |
| A confused-deputy effect exists           | Page request correlates with an otherwise unavailable canary change | Page-only baseline cannot change the canary                      | Extension authority created the demonstrated capability delta |
| Object authorization works                | Approved project counter changes once                               | Unknown and adjacent project IDs remain unchanged                | Caller cannot select an unapproved tested object              |
| Lifecycle binding works                   | Current document and fresh approval succeed                         | Navigation, expired approval and replay create no effect         | Tested authority does not survive its bound lifecycle         |
| Response is minimized                     | Caller receives only documented summary fields                      | Secret and internal fields are absent in success and error paths | Tested response does not expose broader worker-held data      |
| Cleanup is complete                       | Extension and synthetic state are removed                           | Both message routes fail after cleanup                           | Lab no longer exposes the tested bridge                       |

A rejected response does not prove that no effect happened. The independent ledger must show that
the target and adjacent canaries were unchanged.

## Test matrix

| Dimension        | Variants                                                       | Expected secure result                                     |
| ---------------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
| Ingress route    | Content relay, external page, external extension, extension UI | Separate listener and operation policy for each population |
| Origin           | Exact allowed, sibling subdomain, HTTP, opaque, unrelated      | Only normalized exact origins required by policy proceed   |
| Source/frame     | Top window, child frame, popup, replaced document              | Only the intended current frame and document proceed       |
| Schema           | Missing, extra, wrong type, array, deep nesting, oversized     | Reject before capability resolution                        |
| Operation        | Allowed, unknown, deprecated, case variation                   | Explicit versioned allowlist with default-deny             |
| Object           | Current, adjacent, unknown, cross-tenant ID                    | Trusted state resolves only the approved object            |
| Approval         | Fresh gesture, absent, expired, mismatched operation           | High-impact operation requires a matching fresh approval   |
| Replay/lifecycle | Duplicate nonce, navigation, worker restart, account change    | No authority survives outside the recorded binding         |
| Response         | Success, denial, timeout, upstream error                       | Minimum stable schema with no privileged raw data          |
| Cleanup          | Unload extension and clear registrations                       | Neither direct nor relayed message route remains usable    |

## Hardening order

1. **Remove routes that are not required.** Do not add `externally_connectable` when a direct page
   integration is unnecessary. Do not create a DOM or `postMessage` relay by habit.
2. **Narrow injection.** Use exact HTTPS match patterns, exclusions, top-frame-only execution and
   programmatic or `activeTab` injection where the workflow permits it.
3. **Separate caller populations.** Internal content messages, extension pages, external pages and
   external extensions need separate listeners or an explicit route discriminator.
4. **Use browser-observed sender facts.** Normalize and compare exact origins. Require the frame and
   document properties your policy depends on. Never trust copies inside the payload.
5. **Reject malformed protocols.** Version the schema, cap serialized size and collection counts,
   reject unknown keys and use a discriminated operation allowlist.
6. **Replace authority with intent.** The caller selects a named operation and bounded identifier;
   trusted code selects URL, method, tab, storage key, filename and packaged script.
7. **Bind sensitive effects to fresh state.** Connect tenant, object, gesture, nonce, expiry,
   document and operation in one authorization record.
8. **Minimize the response.** Treat content scripts as a potential disclosure path to the page.
9. **Invalidate on transition.** Navigation, account changes, worker restarts, disconnects and
   permission changes end previous authorization.
10. **Test denials as effects.** For each rejected request, independently verify zero privileged
    side effects.

## Detection and forensic signals

Useful telemetry includes:

- extension ID, version, install source and permission changes;
- content-script registration, match scope, frame policy and execution world;
- internal versus external listener, port name and request ID;
- browser-observed sender origin, tab, frame and document identifiers;
- schema version, operation name and object identifier—not secret payload content;
- authorization gate, decision reason, approval age and replay result;
- privileged API invoked, destination class and bounded response class;
- navigation, account, worker and port lifecycle events around the request;
- unexpected request bursts from one document or repeated denials across origins;
- effect correlation and proof that adjacent canaries were unchanged.

Avoid logging tokens, page content, full URLs with sensitive query strings or raw cross-origin
responses. Security telemetry must not become a second exfiltration route.

## Reporting the finding

A precise finding title names the borrowed capability:

> A page script could cause the extension service worker to perform an authenticated cross-origin
> project request because the content-script relay forwarded a caller-selected URL without
> validating browser sender context or object authorization.

The report should contain:

- exact browser, extension and manifest versions;
- both ingress routes and the listener each one reaches;
- manifest match patterns, frame scope and permissions relevant to the effect;
- captured browser-observed sender fields;
- minimal message schema and the caller-controlled authority fields;
- page-only baseline, approved canary, unauthorized canary and independent effect evidence;
- negative controls for origin, frame, document, operation, object, replay and lifecycle;
- the minimum demonstrated capability delta without real sensitive data;
- root cause in both the relay and service-worker dispatcher;
- remediation expressed as a capability contract rather than a blocklist;
- cleanup proof and remaining uncertainty.

Severity follows the reachable privileged effect and caller population. A fixed health response to
one exact partner origin is not equivalent to an arbitrary authenticated fetch reachable through a
broad content-script match. Claims should remain limited to the tested operations, origins,
permissions and lifecycle states.

## Final model

The secure design is not "page data sanitized before use." It is a small authorization protocol:

```text
browser-observed sender
  → explicit ingress policy
  → versioned intent
  → trusted object resolution
  → fresh workflow authorization
  → one bounded effect
  → minimum response
```

The isolated world remains valuable. Manifest match patterns remain valuable.
`externally_connectable` remains valuable. None of them grants the page the service worker's ambient
authority.

The extension stops being a confused deputy only when it can answer, with evidence: **which
principal requested which named capability, for which object, under which fresh approval, and what
exactly changed?**