Skip to content

test: prove the SEC-4045 egress gates at the socket, and say what the existing ones cannot catch - #242

Merged
stormer78 merged 2 commits into
mainfrom
sec-4045/regression-gates
Sep 13, 2026
Merged

stormer78 merged 2 commits into
mainfrom
sec-4045/regression-gates

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Merge-gate regression tests for the three reproduced SEC-4045 exploits in this
repo. They run in the verify job's npm test step — the one that already
gates merge — through packages/core's node --test tests/*.mjs.

The finding on arrival was that two of the three exploits are already gated,
by tests that landed with their fixes, and both hold up: reverting the specific
guard each is named for makes that specific test fail. So this PR does not
re-add them. It closes the gap on the third, which was gated at a spy and not at
a socket, and records one place where a test claims more than it carries.

What each gate asserts

did:webvh URL-derivation SSRF — packages/core/tests/did.egress-socket.mjs (new)

A TCP listener on 127.0.0.1, plus one on ::1 at the same port since localhost
may resolve to either. Both count connections and drop them. Every vector is a
did:webvh naming that port; verifyDid and deriveSigningKeyId are driven with
the real default resolver and the real fetch, and the assertion is that the
count is zero — so the refusal is proved to precede the dial, not merely to
precede fetch.

The spellings are chosen so this repo's guard is the only thing that can
refuse them
, which is the whole point of the file. didwebvh-ts@2.8.0 has its
own isIPAddress() check that throws for a dotted quad and for anything spelled
only from [0-9a-f:], so 127.0.0.1, 2130706433, [::1] and
[::ffff:127.0.0.1] never reach a socket whether assertResolvableWebvhHost
runs or not. A probe of the shipped resolver says which forms it does let
through, and those are the ones here: localhost, localhost., 0x7f000001,
127.1, 127.0.1, 0x7f.0.0.1, and the same address written 127。0。0。1
and ①②⑦.0.0.1.

The control is vtiResolve on the same DID, bypassing verifyDid and nothing
else: a connection arriving there says the listener counts, the port is
reachable and the DID derives to it — so an empty count above means refused
rather than "this fixture pointed nowhere". A second control drives
example.com through the same path and asserts it fails on the log rather than
on the guard, so a guard that refused everything would not pass.

did.egress-guard.mjs, already on main, keeps the full vector set — the
literals, the IPv4-mapped IPv6 forms, CGNAT, link-local, RFC 1918, the trailing
dot, every alternate IPv4 spelling — asserted against
assertResolvableWebvhHost directly, where nothing else is consulted.

Redirects to an internal address — packages/core/tests/vta.rest-redirect.mjs (new)

Two loopback listeners: one stands for the VTA and answers the bearer handshake
with a 302, the other stands for the internal address it points at and exists
only to count connections. getVtaBearer is driven with the real fetch; the
refusal must carry reason: "redirect" with the target's count at zero.

What this pins is the wiring — the one line in vta/auth.ts that puts every
VTA REST request through guardedFetch, which a refactor can drop without any
other test noticing. vta.rest-net-policy.mjs would not notice: it injects a
spy fetch, and a spy never redirects.

netPolicy is the dev pair (allowInsecure + allowPrivate) deliberately.
Without it the loopback base URL is refused on its scheme before the host is
even looked at, so the test would be green with the redirect control deleted —
pinning the scheme gate twice and the redirect not at all. With both flags set,
a refusal can only be the redirect, and reason is asserted rather than just
the code so that stays true. The last test names the two refusals the dev policy
suspends, separately, so the layering is on the record.

postMessage origin handling — packages/extension/tests/content-origin.test.mts (already on main)

Imports the real content.ts into a happy-dom window and dispatches message
events with origin and source forged. Verified genuine: removing
if (event.origin !== window.origin) return; makes "a same-window message
claiming another origin is dropped" fail with three forged requests relayed.
Nothing added.

Demo-RP CORS — packages/demo-rp/tests/cors.test.mjs (already on main)

Boots the real server.mjs on an OS-assigned port and asserts the served
headers. Verified genuine: putting the reflection back makes two tests fail with
access-control-allow-origin: https://evil.example. Nothing added.

What the second commit changes about the existing test

Removing the 100.64.0.0/10 row from V4_BLOCKED fails the literals test and
both stub tests in did.egress-guard.mjs — and leaves its last test, "with the
real resolver, a blocked host produces no network request", green, because
the dependency refuses a dotted quad on its own. That is a division of labour
rather than a hole, but the test's header said it "would notice the guard being
bypassed", and for about a third of its vector set it would not. The comment now
says which classes it does and does not fail on.

The same commit reorders the assertions in both new files so a RED run fails on
the claim the file is named for — the connection count — rather than on
whichever vector's error message came first.

Not turned into a gate

  • DNS rebinding refused at connect time. No browser path can do this and
    none tries: an extension has no DNS API, and verifyDid resolves through
    didwebvh-ts's bare fetch(url). A public name resolving to a private
    address passes every check. The control exists — guardedLookup in
    @openvtc/vti-didcomm-js/net-guard/node — but it imports node:dns and is
    unreachable from an extension bundle by design. A test asserting a rebinding
    name is refused would fail; one asserting it is accepted would read as
    sanctioning it. The guard's own header already records the residual, and
    allowHosts is the control that would close it.
  • Redirects on the did:webvh resolution path. didwebvh-ts calls
    fetch(url) with the default redirect: "follow", so a vetted public host
    that answers Location: http://169.254.169.254/ is followed with no second
    vet. The host guard is pre-flight only. This is a real residual on the SSRF
    path, distinct from the VTA REST path gated above, and closing it needs a
    fetch seam in the resolver rather than a test.
  • proxyFetch's redirect refusal stays a source-text assertion in
    proxy-url.test.mts: driving a real redirect needs a browser and the function
    is not exported. Left as found.

Verification

npm run lint, npm run build and npm test all pass (core 632 → 641 tests).

Every RED proof reverted one specific guard and ran one specific test, restoring
in the same shell invocation and printing git diff origin/main for the touched
files afterwards to show nothing was left behind. No revert remains; src/ is
byte-identical to origin/main.

Every RED run is hermetic and fast — the slowest is 0.31s wall clock — because
every vector that would egress names loopback, and the vectors that name a
metadata address are confined to the spy pass, where a request cannot leave the
process.

`did.egress-guard.mjs` and `vta.rest-net-policy.mjs` drive their refusals
through an injected `fetch` that never dials anything. That is the right way to
carry a vector set that names `169.254.169.254` — a request built there cannot
leave the process, so a run with the guard removed stays inside the sandbox —
but it leaves two claims unproved.

The first is *where* the refusal happens. A spy that throws on call shows a URL
was never handed to `fetch`; it does not show a socket was never opened, and
those stop being the same claim the moment anything downstream of `fetch` dials
on its own. The second is redirects: a spy never redirects, so the one line in
`vta/auth.ts` that wraps every VTA REST request in `guardedFetch` — and with it
the only re-vetting a browser offers, since `redirect: "manual"` yields an
opaque response nothing can inspect — is not held by any test today.

Both new files answer those over real sockets, and both are loopback-only, so
they are hermetic when RED as well as when green: the host a run without the
guard would dial is a listener started a few lines above it.

## did.egress-socket.mjs

A TCP listener on 127.0.0.1 (and on ::1 at the same port, since `localhost` may
resolve to either) that counts connections and drops them. Every vector is a
did:webvh naming that port. `verifyDid` and `deriveSigningKeyId` are driven with
the real default resolver and the real `fetch`, and the assertion is that the
count is zero.

The spellings are chosen so **this repo's guard is the only thing that can
refuse them**, which is the point of the file. `didwebvh-ts@2.8.0` has its own
`isIPAddress()` check that throws for a dotted quad and for anything spelled
only from `[0-9a-f:]`, so `127.0.0.1`, `2130706433`, `[::1]` and
`[::ffff:127.0.0.1]` never reach a socket whether `assertResolvableWebvhHost`
runs or not — a socket test built on those would be green with the guard
deleted. A probe of the shipped resolver says which spellings it does let
through, and those are the ones here: `localhost`, `localhost.`, `0x7f000001`,
`127.1`, `127.0.1`, `0x7f.0.0.1`, and the same address written `127。0。0。1`
and `①②⑦.0.0.1`. `did.egress-guard.mjs` still asserts the literals, against
`assertResolvableWebvhHost` directly, where nothing else is consulted.

The control is `vtiResolve` on the same DID, bypassing `verifyDid` and nothing
else: a connection arriving there says the listener counts, the port is
reachable and the DID derives to it, so an empty count above means refused
rather than "this fixture pointed nowhere".

## vta.rest-redirect.mjs

Two loopback listeners: one stands for the VTA and answers the bearer handshake
with a 302, the other stands for the internal address it points at and exists
only to count connections. `getVtaBearer` is driven with the real `fetch`, and
the refusal must carry `reason: "redirect"` with the target's count at zero.

`netPolicy` is the dev pair deliberately. Without it the loopback base URL is
refused on its scheme before the host is looked at, so the test would be green
with the redirect control gone — pinning the scheme gate twice and the redirect
not at all. With both flags the scheme and address checks are satisfied and a
refusal can only be the redirect; `reason` is asserted, not just the code, so
that stays true. The last test names the two refusals the dev policy suspends,
separately, so the layering is on the record.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…what the old one cannot catch

Two changes, both from reverting a guard and watching which line failed.

## The assertion order

Both new files asserted the vectors one at a time, so with the guard removed
they failed on the first vector's error message — `fetch failed` where
`so it was not contacted` was expected — and never reached the socket count.
The count is the claim; a RED run should say "a refused host reached the socket"
and list the connections, not report a string mismatch. So every vector is
driven first and the count is asserted before the per-vector messages. Same in
`vta.rest-redirect.mjs`, where the error was asserted inline by
`assert.rejects` and its validator ran before the internal listener's count.

## What `did.egress-guard.mjs`'s last test does not carry

Removing the `100.64.0.0/10` row from `V4_BLOCKED` fails the literals test and
both stub tests — and leaves "with the real resolver, a blocked host produces no
network request" green. `didwebvh-ts@2.8.0` refuses a dotted quad itself, so for
`BLOCKED_LITERALS` and a handful of the numeric forms an empty `requested` is
true whether the guard ran or not.

This is a division of labour, not a hole: those literals are asserted against
`assertResolvableWebvhHost` directly, where nothing else is consulted, and what
the integration test uniquely carries is the classes the dependency misses —
every name, and the numeric forms the URL parser rewrites. Which is exactly why
the comment is worth having: the test's own header said it "would notice the
guard being bypassed", and for a third of its vector set it would not.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit bf0212c into main Sep 13, 2026
4 checks passed
@stormer78
stormer78 deleted the sec-4045/regression-gates branch September 13, 2026 05:17
@stormer78

Copy link
Copy Markdown
Contributor Author

Correcting one of the two residuals recorded in this PR's description. The redirect-follow residual on the did:webvh path does not apply — I withdraw it.

What the PR says is true in isolation: didwebvh-ts has three bare fetch(url) calls in dist/esm/index.js (3191, 3330, 3391) with zero redirect options anywhere in the bundle, and assertResolvableWebvhHost is pre-flight only. What neither of us checked is the connecting claim — whether didwebvh-ts is on this package's fetch path at all. It is not.

verifyDid and deriveSigningKeyId resolve through @openvtc/vti-didcomm-js's resolve(), which:

  • fetches the did.jsonl log itself through guardedFetch(...), which forces redirect: "manual" and rejects a 3xx with reason: "redirect" without requesting the target;
  • passes witnessProofs explicitly, and says why in a comment: "an empty array is what stops fetchWitnessProofs from reaching for the global fetch".

So didwebvh-ts verifies the log and is deliberately starved of the opportunity to fetch. No package under packages/extension/src imports it directly either.

Why the gap looked real: the working tree this was developed in had @openvtc/vti-didcomm-js 0.7.0 installed — 3846 bytes, no net-guard, no guardedFetch. Both package.json (^0.8.0) and package-lock.json (0.8.0) specify the hardened line, and 0.8.0's did-webvh.js is 16126 bytes with the guard in it. CI installs from the lockfile, so the tested and shipped product is guarded; only a stale local node_modules was not. Worth an npm ci before drawing conclusions about egress behaviour in this repo.

Nothing to change in this PR. did.egress-socket.mjs and vta.rest-redirect.mjs are sound and remain genuine gates — the REST redirect control they pin is this repo's own guardedFetch wiring in vta/auth.ts, which is unaffected by any of the above.

The other residual stands: DNS rebinding is undefended on the browser path. guardedLookup imports node:dns and sits on a separate subpath so browser bundles do not pull it in, so a public name resolving to a private address (127.0.0.1.nip.io, localtest.me) passes every check. allowHosts is the control that would close it, and extension/src/net-policy.ts already records why the wallet has no host list to put there. That one is a real product decision, not a bug to patch.

Also worth keeping: the observation that did.egress-guard.mjs's integration test cannot fail on roughly a third of its vector set, because didwebvh-ts's own isIPAddress() refuses dotted quads before our guard is consulted. The second commit's comment recording which classes it can and cannot fail on is the durable part of that.

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