Series · 3 partsBrowser-to-Native Trust BoundariesPart 2 · You are here
  1. 1The Extension Was Sandboxed. The Native Host Was Not.
  2. 2The Page Never Had Permission. The Extension Did.You are here
  3. 3The Package Was Signed. The Update Was Still a Security Decision.

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:

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

PAGE → EXTENSION AUTHORITY MAP MESSAGE DELIVERY IS NOT A DELEGATION OF EVERY EXTENSION CAPABILITY WEB PAGEorigin · frame · DOMattacker-controlled data CONTENT SCRIPTisolated worldRELAY / TRANSLATOR SERVICE WORKERsender · schema · statePOLICY DECISION CAPABILITYtabs · fetchstorage · native DIRECT: externally_connectable → onMessageExternal Browser-observed contextsender.url · sender.origintab · frameId · documentId Request capabilityoperation · object IDnonce · expiry · gesture Effect boundaryallowed result onlyNO CALLER-SUPPLIED AUTHORITY THE DEPUTY IS CONFUSED WHEN IT SEES A VALID CHANNEL BUT CANNOT EXPLAIN WHY THIS CALLER MAY PERFORM THIS EFFECT.
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.

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

PrincipalBrowser-enforced propertiesAttacker-influenced propertiesTypical authority
Web pageOrigin, frame and document lifecyclePage JavaScript, DOM, attributes, events and application stateSame-origin web capabilities
Isolated content scriptExtension identity and isolated JavaScript globalAlmost every value obtained from the page or page messaging channelLimited extension APIs and runtime message
Extension service workerExtension origin, package code and privileged API accessMessage body; sometimes stale or incomplete workflow stateHost 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.

ControlWhat it establishesWhat it does not establish
Content-script isolated worldPage scripts cannot directly read the content script’s JavaScript globalsData obtained through shared DOM or explicit messages is trustworthy
content_scripts.matchesWhere a script may be injectedEvery path, frame, tenant and workflow on that origin may use every feature
all_frames: falseDeclarative injection defaults to the top frameA top-frame message describes the current approved document
externally_connectable.matchesWhich page URL patterns may open the external channelThe caller may invoke every exposed operation
runtime.onMessageA message came through an internal extension messaging routeIts page-derived payload or requested object is authorized
runtime.onMessageExternalA caller used the external extension messaging routeThe sender origin alone is sufficient authorization
MessageSenderBrowser-observed sender context available to the listenerPage-provided copies of those fields are true, or the requested effect is safe
Host permissionThe extension context may access a remote originA content script may choose an arbitrary URL for the worker to fetch
User installationThe user accepted the extension package and permission promptEvery 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:

{
  "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:

{
  "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:

VariantLost boundaryObservable consequence
Arbitrary cross-origin fetchPage chooses a URL; worker supplies host permissionReading or mutating a resource unavailable to the page
Tab authority borrowingPage chooses a tab ID or navigation target; worker supplies tabsCross-tab disclosure, capture, script injection or navigation
Storage oraclePage chooses a key or namespace; worker supplies extension storageDisclosure or corruption of extension state
Download deputyPage chooses URL, filename or open behavior; worker supplies downloadsUnwanted file creation or misleading local artifact
External-message overreachAn allowed origin can select any dispatcher actionOne approved integration inherits unrelated capabilities
Port lifecycle confusionA long-lived port remains trusted after document or account transitionStale authorization applied to a new page state
Response overexposureWorker returns raw API output to a less trusted content scriptSecrets 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.

// 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:

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

Architectural cutaway of a web page, isolated content script, extension service worker and privileged browser APIs with message evidence attached at each boundary.
Boundary plate / 02 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.

Inventory before sending a message

Map code and policy before interacting with the handler.

Manifest and injection inventory

FieldEvidence to capture
Content-script matchesExact schemes, hosts, paths, exclusions and dynamic registrations
Frame reachall_frames, match_about_blank, match_origin_as_fallback
Execution worldISOLATED or MAIN, and why shared DOM/event channels are required
External callersexternally_connectable.matches, IDs and wildcard use
Privileged permissionspermissions, host_permissions, optional grants and activeTab
Web-accessible resourcesExposed paths and the sites permitted to load them
LifecycleService-worker restart behavior, port reconnection and state recovery

Message and effect inventory

FieldQuestions
Listener populationInternal message, external page, external extension, port or one-shot?
Sender policyWhich browser-observed fields are mandatory and how are URLs normalized?
SchemaAre unknown keys, wrong types, nesting and oversized values rejected?
DispatcherCan the caller choose a URL, method, script, tab, path, key or command?
Trusted stateWhere do tenant, selected object, user gesture and approval come from?
ResponseWhich fields return to the content script or external page?
Effect observerWhat independent signal proves the privileged action occurred?
Denial observerWhat 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:

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:

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-suppliedAccept insteadResolve inside trusted code
Full URL and HTTP methodOperation plus bounded object IDFixed HTTPS origin, path, method and response fields
Tab ID and script sourceCurrent-workflow actionActive approved tab and packaged script file
Storage key or namespaceTyped preference nameFixed key map and value schema
Filename and download URLReport IDServer-side report URL and safe generated filename
Raw HTML or JavaScriptStructured display fieldsText-only DOM construction in the extension context
Arbitrary dispatcher actionVersioned discriminated operation unionExplicit 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

EXTENSION CAPABILITY DECISION PIPELINE THE REQUEST EARNS ONE EFFECT; IT DOES NOT INHERIT THE WORKER'S AMBIENT AUTHORITY 1 · ROUTEinternalexternal · port 2 · SENDERorigin · framedocument · id 3 · SCHEMAversion · typesize · keys 4 · WORKFLOWtenant · gesturenonce · expiry 5 · CAPABILITYfixed operationbounded result DENIAL RECORDgate · reason · request IDzero privileged effects EFFECT OBSERVERexpected canary changedadjacent canary unchanged EVIDENCE BUNDLEsender · decision · effectnegative control · cleanup FAIL CLOSED ON: WRONG ROUTE · UNKNOWN ORIGIN · STALE DOCUMENT · EXTRA KEY · MISSING GESTURE · REPLAY · EFFECT MISMATCH
Authorization is a pipeline, not a sender-origin if-statement. Every failed gate records a reason and produces no privileged effect.

Evidence matrix

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

ClaimPositive evidenceNegative controlStrong conclusion
Page reaches the content-script bridgeNamespaced event observed in the expected top documentForeign namespace and child-frame event are ignoredTested page-to-content ingress is characterized
Content script reaches the service workerBrowser sender context and request ID recordedMessage from extension page or stale document is distinguishedListener identifies tested sender classes
External page route is scopedAllowed lab origin reaches only the status operationUnlisted origin and unrelated operation are deniedDirect external ingress is origin- and capability-limited
A confused-deputy effect existsPage request correlates with an otherwise unavailable canary changePage-only baseline cannot change the canaryExtension authority created the demonstrated capability delta
Object authorization worksApproved project counter changes onceUnknown and adjacent project IDs remain unchangedCaller cannot select an unapproved tested object
Lifecycle binding worksCurrent document and fresh approval succeedNavigation, expired approval and replay create no effectTested authority does not survive its bound lifecycle
Response is minimizedCaller receives only documented summary fieldsSecret and internal fields are absent in success and error pathsTested response does not expose broader worker-held data
Cleanup is completeExtension and synthetic state are removedBoth message routes fail after cleanupLab 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

DimensionVariantsExpected secure result
Ingress routeContent relay, external page, external extension, extension UISeparate listener and operation policy for each population
OriginExact allowed, sibling subdomain, HTTP, opaque, unrelatedOnly normalized exact origins required by policy proceed
Source/frameTop window, child frame, popup, replaced documentOnly the intended current frame and document proceed
SchemaMissing, extra, wrong type, array, deep nesting, oversizedReject before capability resolution
OperationAllowed, unknown, deprecated, case variationExplicit versioned allowlist with default-deny
ObjectCurrent, adjacent, unknown, cross-tenant IDTrusted state resolves only the approved object
ApprovalFresh gesture, absent, expired, mismatched operationHigh-impact operation requires a matching fresh approval
Replay/lifecycleDuplicate nonce, navigation, worker restart, account changeNo authority survives outside the recorded binding
ResponseSuccess, denial, timeout, upstream errorMinimum stable schema with no privileged raw data
CleanupUnload extension and clear registrationsNeither 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:

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?

Sources & freshness

How current is this note?

Sources checkedAugust 31, 2026

The latest source-review, content-update, or publication date is shown.

ReviewPublic sources reviewed

Primary public records were checked. Environment-specific behavior remains outside the claim unless separately reproduced.