test: prove the SEC-4045 egress gates at the socket, and say what the existing ones cannot catch - #242
Conversation
`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>
|
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:
So Why the gap looked real: the working tree this was developed in had Nothing to change in this PR. The other residual stands: DNS rebinding is undefended on the browser path. Also worth keeping: the observation that |
Merge-gate regression tests for the three reproduced SEC-4045 exploits in this
repo. They run in the
verifyjob'snpm teststep — the one that alreadygates merge — through
packages/core'snode --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
localhostmay resolve to either. Both count connections and drop them. Every vector is a
did:webvh naming that port;
verifyDidandderiveSigningKeyIdare driven withthe real default resolver and the real
fetch, and the assertion is that thecount 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.0has itsown
isIPAddress()check that throws for a dotted quad and for anything spelledonly from
[0-9a-f:], so127.0.0.1,2130706433,[::1]and[::ffff:127.0.0.1]never reach a socket whetherassertResolvableWebvhHostruns 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 written127。0。0。1and
①②⑦.0.0.1.The control is
vtiResolveon the same DID, bypassingverifyDidand nothingelse: 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.comthrough the same path and asserts it fails on the log rather thanon the guard, so a guard that refused everything would not pass.
did.egress-guard.mjs, already on main, keeps the full vector set — theliterals, the IPv4-mapped IPv6 forms, CGNAT, link-local, RFC 1918, the trailing
dot, every alternate IPv4 spelling — asserted against
assertResolvableWebvhHostdirectly, 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.
getVtaBeareris driven with the realfetch; therefusal must carry
reason: "redirect"with the target's count at zero.What this pins is the wiring — the one line in
vta/auth.tsthat puts everyVTA REST request through
guardedFetch, which a refactor can drop without anyother test noticing.
vta.rest-net-policy.mjswould not notice: it injects aspy
fetch, and a spy never redirects.netPolicyis 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
reasonis asserted rather than justthe 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.tsinto a happy-dom window and dispatchesmessageevents with
originandsourceforged. Verified genuine: removingif (event.origin !== window.origin) return;makes "a same-window messageclaiming 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.mjson an OS-assigned port and asserts the servedheaders. 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/10row fromV4_BLOCKEDfails the literals test andboth stub tests in
did.egress-guard.mjs— and leaves its last test, "with thereal 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
none tries: an extension has no DNS API, and
verifyDidresolves throughdidwebvh-ts's barefetch(url). A public name resolving to a privateaddress passes every check. The control exists —
guardedLookupin@openvtc/vti-didcomm-js/net-guard/node— but it importsnode:dnsand isunreachable 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
allowHostsis the control that would close it.didwebvh-tscallsfetch(url)with the defaultredirect: "follow", so a vetted public hostthat answers
Location: http://169.254.169.254/is followed with no secondvet. 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
fetchseam in the resolver rather than a test.proxyFetch's redirect refusal stays a source-text assertion inproxy-url.test.mts: driving a real redirect needs a browser and the functionis not exported. Left as found.
Verification
npm run lint,npm run buildandnpm testall 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/mainfor the touchedfiles afterwards to show nothing was left behind. No revert remains;
src/isbyte-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.