Series · 3 partsBrowser-to-Native Trust BoundariesPart 2 · You are here
- 1The Extension Was Sandboxed. The Native Host Was Not.
- 2The Page Never Had Permission. The Extension Did.You are here
- 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
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:
{
"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:
| 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.
// 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.postMessagewith a fixed namespace; - a separate
externally_connectableentry for only the allowed test origin; - a fake project catalog containing
project-17andproject-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.
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:
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-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,frameIdand, where supported,documentIdfromMessageSender;- 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:
- 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.
- 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.
- Observe the effect independently. Correlate a generated request ID with the counter ledger. A success response is supporting evidence, not effect proof.
- Change one authorization dimension. Repeat from the negative-control origin, a child frame, stale document, different project ID or external route.
- Compare vulnerable and fixed builds. The approved request should still work; each unauthorized variant should produce a decision record and no counter change.
- Exercise lifecycle boundaries. Navigate, change account, reconnect the port and restart the worker before replaying the request.
- 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
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
- Remove routes that are not required. Do not add
externally_connectablewhen a direct page integration is unnecessary. Do not create a DOM orpostMessagerelay by habit. - Narrow injection. Use exact HTTPS match patterns, exclusions, top-frame-only execution and
programmatic or
activeTabinjection where the workflow permits it. - Separate caller populations. Internal content messages, extension pages, external pages and external extensions need separate listeners or an explicit route discriminator.
- 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.
- Reject malformed protocols. Version the schema, cap serialized size and collection counts, reject unknown keys and use a discriminated operation allowlist.
- 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.
- Bind sensitive effects to fresh state. Connect tenant, object, gesture, nonce, expiry, document and operation in one authorization record.
- Minimize the response. Treat content scripts as a potential disclosure path to the page.
- Invalidate on transition. Navigation, account changes, worker restarts, disconnects and permission changes end previous authorization.
- 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?
How current is this note?
The latest source-review, content-update, or publication date is shown.
Primary public records were checked. Environment-specific behavior remains outside the claim unless separately reproduced.
- developer.chrome.com · messaging ↗
- developer.chrome.com · content-scripts ↗
- developer.chrome.com · externally-connectable ↗
- developer.chrome.com · network-requests ↗
- developer.chrome.com · stay-secure ↗
- developer.mozilla.org · postMessage ↗
- developer.mozilla.org · Content_scripts ↗
- developer.mozilla.org · onMessageExternal ↗
