Skip to content

fix(extension): the relay's origin was body-supplied; signTrustTask could self-approve - #86

Merged
stormer78 merged 1 commit into
mainfrom
fix/relay-origin-and-sign-gate
Jul 14, 2026
Merged

fix(extension): the relay's origin was body-supplied; signTrustTask could self-approve#86
stormer78 merged 1 commit into
mainfrom
fix/relay-origin-and-sign-gate

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Two security regressions, both introduced by earlier PRs in this series, both found by adversarially re-reading the code against the design rather than trusting my own PR descriptions.

1. The relay read its origin from the message body

#82 added an attested-origin gate: for page-facing message types, the background overwrites the body's origin claim with sender.origin, which page content cannot forge.

#84 then added requestTask — and never added it to that set.

So the one method whose origin is stamped into payload.ext["openvtc.origin"] — and therefore rides inside the digest an approver signs — was reading it straight from the content script's window.location.origin. A hostile page can't forge that (the content script writes its own location), but a hostile extension context — the exact threat the gate was built for — can assert anything.

The site shown to a human as the thing they're authorizing was body-supplied. RUNTIME_REQUEST_TASK is now in the set.

2. signTrustTask short-circuited on a remembered origin

#82 routed it through gatedConsent for the ergonomics, without noticing that gatedConsent returns true with no prompt for a trusted origin.

This method signs an arbitrary envelope with the holder key. A remember-grant that silences it means "let this site sign anything, forever" — a tick made once on a login prompt authorizing a DID deactivation a year later.

It now uses requestConsent, which always asks. Origin trust is not capability trust — the whole thesis of this work, and I had inverted it on the single most powerful method on the page-facing surface.

Stopping the class, not just the instance

The page-facing set now derives from PAGE_FACING_RUNTIME_TYPES, a single source in bridge-protocol.ts, and the content script's dispatch table is typed Record<BridgeMethod, string>.

Adding a page-facing method without registering it is now a compile error naming the method — which is how requestTask should have been caught the first time. (The content script bundles as a classic script and cannot import values, so the two files can't literally share the list; the type binding keeps them in step.)

Verified the guard has teeth: removing requestTask from the table fails tsc with Property 'requestTask' is missing.

Also

The per-action prompts (requestTask, signTrustTask) no longer render a "Remember this site" checkbox. A one-shot authorization has nothing to remember, and a checkbox that silently discards its own tick lies to the user. A noRemember flag suppresses it.

npm run build, npm run lint, npm test (101) clean.

How these were found

Both were surfaced by an adversarial fact-check of the design note against the code. They passed every existing test — because the tests, like the note, encoded what the author believed. This is the second half of the process fix: the harness catches integration bugs, the adversarial read catches untested claims. I should have run both before pushing, not after merge.

…ould self-approve

Two security regressions, both from earlier PRs in this series, both found by
adversarially re-reading the code against the design rather than trusting it.

**The relay read its origin from the message body.** #82 added an attested-origin
gate that overwrites the body's claim with `sender.origin` for page-facing
message types. #84 then added `requestTask` and never put it in that set. So the
one method whose origin is stamped into `payload.ext` — and therefore rides
*inside the digest an approver signs* — was reading it straight from the content
script's `window.location.origin`, which any extension-internal context can
assert. A hostile page cannot forge it; a hostile extension context, which is
exactly the threat the gate exists for, can. `RUNTIME_REQUEST_TASK` is now in the
set.

**`signTrustTask` short-circuited on a remembered origin.** #82 routed it through
`gatedConsent` for its ergonomics, without noticing that `gatedConsent` returns
`true` with no prompt for a trusted origin. But this method signs an *arbitrary*
envelope with the holder key, so a remember-grant that silenced it means "let
this site sign anything, forever" — a tick made once on a login prompt
authorizing a DID deactivation a year later. It now uses `requestConsent`, which
always asks. Origin trust is not capability trust; that was the whole point, and
I had inverted it on the most powerful method on the surface.

To stop the first bug's *class* from recurring: the page-facing set now derives
from `PAGE_FACING_RUNTIME_TYPES`, a single source in `bridge-protocol.ts`, and the
content script's dispatch table is typed `Record<BridgeMethod, string>`. Adding a
page-facing method without registering it is now a compile error naming the
method — which is how `requestTask` should have been caught the first time. (The
content script bundles as a classic script and cannot import values, so the two
files cannot literally share the list; the type binding is what keeps them in
step.)

Also: the per-action prompts (`requestTask`, `signTrustTask`) no longer render a
"Remember this site" checkbox. There is nothing to remember for a one-shot
authorization, and a checkbox that silently discards its own tick lies to the
user. A `noRemember` flag suppresses it.

`npm run build`, `npm run lint`, `npm test` (101) clean. Verified the compile
guard fails when a method is removed from the table.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit ac81e13 into main Jul 14, 2026
1 check passed
@stormer78
stormer78 deleted the fix/relay-origin-and-sign-gate branch July 14, 2026 10:24
stormer78 added a commit that referenced this pull request Jul 16, 2026
…4, D8-F6) (#88)

F4 (offscreen.ts) — the inbound mediator listener re-armed with a single
2s setTimeout from onClose only, and startInbound swallowed errors without
rescheduling. A mediator outage longer than one retry left the listener
dead, silently missing every consent request until the MV3 worker happened
to reboot. Replace with a per-VTA exponential-backoff scheduler
(scheduleInboundReconnect): 2s doubling to 60s, re-arming on FAILED
attempts too, resetting on a successful (re)connect. startInbound now
returns success; reconcileInbound schedules a retry for any VTA that fails
to come up and clears backoff for one that succeeds. onClose fires only on
an unexpected drop, and the forgotten-VTA close path cancels any pending
retry, so a deliberately-closed listener isn't resurrected (a
cancellation guard also covers the in-flight-attempt race). (R1.5)

F6 (background.ts) — the page-facing authenticated fetch proxy
(handleApiGet/handleApiPost) called fetch() with no AbortSignal, so a
blackholed VTA hung the page's await for minutes and stacked requests
pinned the MV3 worker. Route both through a shared proxyFetch that applies
AbortSignal.timeout (20s); a stall now surfaces as a clean
{ ok: false, error } via the dispatcher's existing catch. (R1.2, R4.1
one shared helper rather than two copies)

Type-checks clean (tsc -b packages/extension). No version bump: the
extension is an unpublished artifact and recent fix(extension) PRs
(#82/#86) followed the same convention.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant