test(sec-4045): regression gates for the mediator, did:webvh and rebinding egress findings - #23
Merged
Merged
Conversation
A hostname is not an address, so a public-looking name whose A record is 127.0.0.1 walks straight past the URL-text check that the SEC-4045 mediator and did:webvh harnesses were stopped by. guardedLookup is the connect-time half of the guard; this drives it end to end through authenticateToMediator and didWebvh.resolve and asserts the internal listener records zero TCP connections, with an allowPrivate positive control showing nothing else was in the way. Hermetic: a stub resolver table, one listener on 127.0.0.1. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
A request that arrives is proof the guard failed, but so is a socket that is opened and then dies on the response — a TLS handshake against a plaintext listener never produces a request, and a refusal asserted only on `hits` would call that a pass. Count connections alongside requests so the mediator vectors can assert the dial itself never happened. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The live H8 harness pointed a mediator DID document at a loopback HTTPS listener and the client completed the whole auth handshake against it, posting its packed authcrypt'd auth message to the attacker's host. The document is chosen by whoever controls the mediator DID, so this is forced-auth: the credential goes wherever the document says. One table now holds every host spelling the review found a way past a URL-text check — localhost and *.localhost, *.local, *.internal, *.home.arpa and the root-dot form of each; loopback and its alternate IPv4 spellings (127.1, 0x7f000001, 2130706433, 0177.0.0.1); IPv4-mapped IPv6, which didwebvh-ts and vta-sdk's own guard_public_url still admit; link-local and 169.254.169.254; CGNAT 100.64.0.0/10; RFC 1918; and the NAT64 / 6to4 wrappers around loopback — and every egress the document carries is driven through it: the REST endpoint, the Authentication endpoint the challenge and auth message go to, and the WebSocket. The refusal is asserted at the TCP level. An https endpoint dialed against a plaintext listener dies in the handshake and never produces a request, so a gate that counted requests would have called that a pass; these count connections, and assert zero, before they look at the error. Every vector runs before anything is asserted, so the counters describe the whole set rather than however far a bail-out reached. A companion positive control completes the handshake over each loopback spelling with allowPrivate set, so the refusals are known to be refusing hosts that genuinely reach the listener rather than inert URLs. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The live did:webvh harness resolved `did:webvh:<scid>:localhost%3A<port>` and the resolver GOT `/.well-known/did.jsonl` from the internal listener. The identifier names its own host and identifiers are not caller-chosen — `unpackInbound` resolves an inbound frame's `skid` before the frame is authenticated — so whoever can route a frame picks a host this client fetches from. The host vectors now match the mediator gate's table rather than a sample of it: the root-dot forms, `*.localhost` and `*.local`, the alternate IPv4 spellings of loopback, CGNAT and "this network". A bracketed IPv6 literal has no expressible form in a DID segment, and the identifier parse already fails closed on one, so that stays where it is. Both entry points are driven for each vector — `didWebvh.resolve` and the method dispatcher's `resolve(did)`, which is the one `unpackInbound` reaches — and the listener now counts TCP connections, asserted zero before the errors are examined. The redirect gate asserts the same of its redirect target: not requested, and not dialed. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Driving the whole set through the real fetch made the gates hermetic only
while they pass. With the guard's host classification disabled to prove
them red, the vectors that are unroutable from a test host — RFC 1918,
link-local, CGNAT — went out to the real network and sat there until the
TCP timeout: 181s for one file, and egress a CI sandbox should never see
even from a broken build.
So each set is now gated twice, with the instrument each half needs:
- a fetch spy takes the full set, every spelling, and asserts it is
never called. Nothing can leave the process no matter what state the
guard is in, which is the hermeticity property actually wanted.
- the local listener takes the spellings that fold onto 127.0.0.1 —
the live vectors — through the real fetch, and asserts zero TCP
connections. Loopback is as far as a broken guard gets them.
Same coverage, both halves fast, and the socket assertion stays where a
socket can prove something.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The answer-form case drove every class through a real socket, which is fine while it passes and not fine when it does not: with the connect-time address check disabled to prove it red, the stub's 169.254.169.254, 10.0.0.5 and 192.168.1.1 answers were dialed for real and the file took 226s to fail. A regression gate should not be able to reach a metadata service on the way to reporting the regression. The answer classes now go through `guardedLookup` directly. That is where the decision is, it cannot leave the process in any state, and the set grows to cover 172.16/12, 0.0.0.0 and the ULA metadata address as well. It also asserts the refusal drops the whole name rather than one address of it — nothing is handed back for the socket to choose from — and a public answer still passes through with its addresses, so a broken stub cannot masquerade as a refusal. The end-to-end proof that the decision reaches the socket stays where it can be made safely: the loopback mediator case and the did:webvh case, both against the local listener, both still asserting zero connections. Red now takes 100ms instead of 226s. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Regression gates for the SEC-4045 egress findings in this repo. No
src/change: the guard (
src/net-guard.js,src/net-guard-node.js) and theresolver cache bound already landed on
main, and nothing here relaxesthem.
git diff origin/main -- src/is empty on this branch.Every gate asserts this repo's own guard refuses the vector. None of
them lean on
didwebvh-tsor vta-sdk'sguard_public_url, which stilladmit
localhost, DNS rebinding and IPv4-mapped IPv6 — a test resting onthose would prove nothing.
Hermetic throughout: in-process listeners bound to
127.0.0.1, a stub DNStable, no external network — and, after the last two commits, no external
network even when the guard is deliberately broken (see "Hermeticity"
below).
The three PoCs
ssrf-mediator.mjs(H8, mediator DID-doc SSRF → forced-auth)test/mediator-auth-ssrf.test.js,test/dns-rebinding.test.jsssrf-webvh.mjs(did:webvh resolver SSRF)GET /.well-known/did.jsonlarrived at the internal listenertest/did-webvh-ssrf.test.js,test/dns-rebinding.test.jscache-unbounded.mjs(unbounded resolver cache, CWE-400)test/resolver.test.js(already onmain; red-then-green evidence below)What each test asserts
test/mediator-auth-ssrf.test.js— H8, the highest-value gate hereOne
VECTORStable holds every host spelling the review found a way pasta URL-text check, each with the
reasonthe guard owes for it:localhost,*.localhost,*.local,*.internal,*.home.arpa, andthe trailing-dot form of each (
localhost.,mediator.localhost.)— a root-dot name is the same name to a resolver, so the dot has to come
off before the check
127.1,127.0.1,0x7f000001,2130706433,0177.0.0.1[::ffff:127.0.0.1],[::ffff:7f00:1], plus[::1]169.254.169.254,[fe80::1],[fd00:ec2::254]100.64.0.0/10incl. the Alibaba metadata address100.100.100.2000.0.0.0, and the NAT64 / 6to4 wrappers around loopbackDriven through it:
no vector in the set reaches fetch— the whole set, bothhttps:under the default policy andhttp:underallowInsecure(the host block must not depend on the scheme gate's ordering),
against an injected
fetchspy. Asserts the spy is never called,then that every vector was refused with the right
reason.the loopback spellings get zero TCP connections— the spellingsthat fold onto
127.0.0.1, through the real fetch at a locallistener. Asserts zero TCP connections before it looks at the
error. This ordering is the point: an
httpsendpoint dialed at aplaintext listener dies in the TLS handshake and never produces a
request, so a gate counting requests would call that a pass.
every loopback spelling really does reach the listener—positive control. With
allowPrivateeach spelling completes the fullhandshake against the listener, so the refusals above are known to be
refusing hosts that genuinely work, not inert URLs.
the vector set is refused in every egress the document carries—a document names a host in three places and vetting only the first
still dials the other two: the DIDCommMessaging REST endpoint, the
Authenticationendpoint the challenge and packed auth message go to,and the WebSocket. Full set against each.
the redirect target sees no request and is not dialed at all.
Every vector runs before anything is asserted, so the counters describe
the whole set rather than however far a bail-out on the first one reached.
test/did-webvh-ssrf.test.js— did:webvh SSRFSame table, in the spelling a DID segment can carry (percent-encoded
port; a bracketed IPv6 literal is inexpressible and the identifier parse
already fails closed on one). Same two passes — spy, then zero TCP
connections on the loopback spellings — and each vector goes through
both entry points:
didWebvh.resolveand the method dispatcher'sresolve(did), which is the oneunpackInboundreaches for an inboundframe's
skidbefore the frame is authenticated. The redirect gatenow also asserts its target was never dialed.
test/dns-rebinding.test.js— the connect-time half (new file)A hostname is not an address:
https://mediator.rebind.test/has nothinglocal-looking about it, passes the URL-text check, and its DNS answer
alone decides what the socket dials. This is the bypass that makes the
literal blocks insufficient on their own.
127.0.0.1, drivenend to end through
authenticateToMediatorwith the README'slookup-wired fetch. Asserts zero TCP connections first, then therefusal; asserts the name was resolved, so the refusal is the address
and not a lookup failure; positive control with
allowPrivatecompletesthe handshake and shows the
Hostheader is the attacker's name.169.254.169.254, CGNAT, RFC 1918 (incl.172.16/12),0.0.0.0, the ULA metadata address, and the IPv4-mappedIPv6 spelling of loopback, driven at
guardedLookupitself. Alsoasserts the refusal drops the whole name rather than one address of
it (nothing is handed back for the socket to choose from) — a single
hostile answer among public ones refuses the name — plus positive
controls that a wholly public answer passes through with its addresses
and that
allowPrivateis the opt-in.connects and then fails on the handshake, which is what distinguishes
"refused" from "dialed".
test/resolver.test.js— unbounded cache (already onmain)Not re-authored; verified to be a genuine gate.
maxEntries(default500) with LRU eviction, and
did:key/did:peernever cached at all,which is what the PoC exploited — they resolve offline and are free to
mint in unlimited quantity.
Red-then-green evidence
Four independent reverts. Each was applied, captured, restored inside
the same command, and the restore proven with
git diff origin/main -- src/printing nothing before anything else happened. No revert remainsin the worktree;
git diff origin/maintouches onlytest/.Revert A — private-host classification in
assertSafeEndpoint19 and 12 sockets opened to the internal listener — the SSRF firing
at TCP level, which is the PoC's own result reproduced as a test failure.
Revert B — connect-time address filter in
guardedLookupThat last one is the request-vs-connection distinction in the raw: the
socket reached the plaintext listener and died in the TLS handshake, so it
produced a TLS error and no request at all.
Revert C — redirect refusal in
guardedFetchRevert D — resolver cache bound and method exclusion
Green with every revert restored: 277 tests, 270 pass, 0 fail, 7
skipped (the 7 skips pre-date this branch).
Hermeticity
Worth calling out, because the first cut of these gates got it wrong.
Driving the whole vector set through a real socket is hermetic only
while the gate passes. With the guard deliberately broken, the vectors
that are unroutable from a test host — RFC 1918, link-local, CGNAT — were
dialed for real: 181s for the SSRF files and 226s for the rebinding file,
and outbound traffic toward a metadata service from a CI sandbox on the
way to reporting the regression.
Each set is therefore gated with the instrument its half needs:
can leave the process in any state, which is the property actually wanted
127.0.0.1andasserts zero TCP connections — loopback is as far as a broken guard
gets those, and a socket is the only thing that can prove the refusal
precedes the dial
guardedLookupdirectly,where the decision actually is
Same coverage, and red takes 295ms and 100ms instead of 181s and 226s.
Checks
npm test— 277 tests, 270 pass, 0 fail, 7 skippednpm run build:types— clean.tsconfig.types.jsonuntouched; itsexplicit
rootDir(TypeScript 7 / TS5011) is left exactly as it is.src/change, no guard weakened to suit a testssrf-cert.pem/ssrf-key.pemor any key material: nothing hereneeds TLS material, since the listeners are plaintext and the TLS
failure against them is itself a signal the gates use