Skip to content

test(sec-4045): regression gates for the mediator, did:webvh and rebinding egress findings - #23

Merged
stormer78 merged 6 commits into
mainfrom
sec-4045/regression-gates
Sep 12, 2026
Merged

test(sec-4045): regression gates for the mediator, did:webvh and rebinding egress findings#23
stormer78 merged 6 commits into
mainfrom
sec-4045/regression-gates

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

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 the
resolver cache bound already landed on main, and nothing here relaxes
them. 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-ts or vta-sdk's guard_public_url, which still
admit localhost, DNS rebinding and IPv4-mapped IPv6 — a test resting on
those would prove nothing.

Hermetic throughout: in-process listeners bound to 127.0.0.1, a stub DNS
table, 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

PoC Live result Gated by
ssrf-mediator.mjs (H8, mediator DID-doc SSRF → forced-auth) REPRODUCED — 2 requests arrived at the internal listener; client completed the handshake and POSTed its packed authcrypt'd auth message to the attacker's host test/mediator-auth-ssrf.test.js, test/dns-rebinding.test.js
ssrf-webvh.mjs (did:webvh resolver SSRF) REPRODUCED — 1 GET /.well-known/did.jsonl arrived at the internal listener test/did-webvh-ssrf.test.js, test/dns-rebinding.test.js
cache-unbounded.mjs (unbounded resolver cache, CWE-400) REPRODUCED — 50,000 distinct DIDs retained 27.7 MB, >1000× the same-DID control test/resolver.test.js (already on main; red-then-green evidence below)

What each test asserts

test/mediator-auth-ssrf.test.js — H8, the highest-value gate here

One VECTORS table holds every host spelling the review found a way past
a URL-text check, each with the reason the guard owes for it:

  • localhost, *.localhost, *.local, *.internal, *.home.arpa, and
    the 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
  • loopback and its alternate IPv4 spellings: 127.1, 127.0.1,
    0x7f000001, 2130706433, 0177.0.0.1
  • IPv4-mapped IPv6: [::ffff:127.0.0.1], [::ffff:7f00:1], plus [::1]
  • link-local incl. 169.254.169.254, [fe80::1], [fd00:ec2::254]
  • CGNAT 100.64.0.0/10 incl. the Alibaba metadata address 100.100.100.200
  • RFC 1918, all three blocks
  • 0.0.0.0, and the NAT64 / 6to4 wrappers around loopback

Driven through it:

  1. no vector in the set reaches fetch — the whole set, both
    https: under the default policy and http: under allowInsecure
    (the host block must not depend on the scheme gate's ordering),
    against an injected fetch spy. Asserts the spy is never called,
    then that every vector was refused with the right reason.
  2. the loopback spellings get zero TCP connections — the spellings
    that fold onto 127.0.0.1, through the real fetch at a local
    listener. Asserts zero TCP connections before it looks at the
    error. This ordering is the point: an https endpoint dialed at a
    plaintext listener dies in the TLS handshake and never produces a
    request, so a gate counting requests would call that a pass.
  3. every loopback spelling really does reach the listener
    positive control. With allowPrivate each spelling completes the full
    handshake against the listener, so the refusals above are known to be
    refusing hosts that genuinely work, not inert URLs.
  4. 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
    Authentication endpoint the challenge and packed auth message go to,
    and the WebSocket. Full set against each.
  5. redirects — a 302/307 from either auth endpoint is refused, and
    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 SSRF

Same 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.resolve and the method dispatcher's
resolve(did), which is the one unpackInbound reaches for an inbound
frame's skid before the frame is authenticated. The redirect gate
now 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 nothing
local-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.

  • mediator path — a public-looking name answering 127.0.0.1, driven
    end to end through authenticateToMediator with the README's
    lookup-wired fetch. Asserts zero TCP connections first, then the
    refusal; asserts the name was resolved, so the refusal is the address
    and not a lookup failure; positive control with allowPrivate completes
    the handshake and shows the Host header is the attacker's name.
  • answer classes — loopback, 169.254.169.254, CGNAT, RFC 1918 (incl.
    172.16/12), 0.0.0.0, the ULA metadata address, and the IPv4-mapped
    IPv6 spelling of loopback, driven at guardedLookup itself. Also
    asserts 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 allowPrivate is the opt-in.
  • did:webvh path — same, zero connections; the positive control
    connects and then fails on the handshake, which is what distinguishes
    "refused" from "dialed".

test/resolver.test.js — unbounded cache (already on main)

Not re-authored; verified to be a genuine gate. maxEntries (default
500) with LRU eviction, and did:key / did:peer never 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 remains
in the worktree; git diff origin/main touches only test/.

Revert A — private-host classification in assertSafeEndpoint

✖ did:webvh resolve: no vector in the set reaches fetch
  AssertionError: the guard must refuse before fetch is reached
  + [
  +   'https://log.local/.well-known/did.jsonl',
  +   'https://log.local/.well-known/did.jsonl',
  +   'https://log.local./.well-known/did.jsonl',
  ...
✖ did:webvh resolve: the loopback spellings get zero TCP connections
  AssertionError: no vector may open a socket to the internal listener
  12 !== 0
✖ authenticateToMediator: no vector in the set reaches fetch
  AssertionError: the guard must refuse before fetch is reached
  + [
  +   'https://localhost:8443/authenticate/challenge',
  +   'http://localhost:8080/authenticate/challenge',
  +   'https://localhost.:8443/authenticate/challenge',
  ...
✖ authenticateToMediator: the loopback spellings get zero TCP connections
  AssertionError: no vector may open a socket to the internal listener
  19 !== 0
✖ parseMediatorEndpoints: the vector set is refused in every egress the document carries
  AssertionError: Missing expected exception: {"endpoints":["https://m.example/v1"],"auth":"https://localhost/authenticate"}

19 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 guardedLookup

✖ DNS rebinding: a mediator endpoint whose name resolves to loopback gets zero connections
  AssertionError: the guard must refuse before the socket is dialed
  2 !== 0
✖ DNS rebinding: every non-public answer is refused, whichever form it arrives in
  + [
  +   'a.rebind.test -> 127.0.0.1: got addresses ["127.0.0.1",4]',
  +   'b.rebind.test -> 169.254.169.254: got addresses ["169.254.169.254",4]',
  +   'c.rebind.test -> 100.64.0.1: got addresses ["100.64.0.1",4]',
  +   'd.rebind.test -> 10.0.0.5: got addresses ["10.0.0.5",4]',
  +   'e.rebind.test -> ::ffff:127.0.0.1: got addresses ["::ffff:127.0.0.1",6]',
  +   'f.rebind.test -> 192.168.1.1: got addresses ["192.168.1.1",4]',
  +   'h.rebind.test -> 172.16.0.1: got addresses ["172.16.0.1",4]',
  +   'i.rebind.test -> 0.0.0.0: got addresses ["0.0.0.0",4]',
  +   'j.rebind.test -> fd00:ec2::254: got addresses ["fd00:ec2::254",6]',
  +   'g.rebind.test -> 93.184.216.34, 127.0.0.1: got addresses ["93.184.216.34",4]'
  + ]
  - []
✖ DNS rebinding: a did:webvh host that resolves to loopback gets zero connections
  AssertionError: expected E_BLOCKED_ENDPOINT, got: did:webvh resolve failed:
  did:webvh log https://mediator.rebind.test:58742/.well-known/did.jsonl —
  write EPROTO ... SSL routines:tls_validate_record_header:wrong version number

That 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 guardedFetch

✖ did:webvh resolve: a redirect from an allowed host is not followed
  AssertionError: expected E_BLOCKED_ENDPOINT, got: did:webvh resolve failed:
  did:webvh log http://localhost:59004/.well-known/did.jsonl — HTTP 302
✖ authenticateToMediator: redirects from the auth endpoint are not followed
  AssertionError: expected E_BLOCKED_ENDPOINT, got: mediator-auth: 302 from
  http://127.0.0.1:59006/authenticate/challenge
✖ guardedFetch: forces redirect:manual and keeps the rest of init
  AssertionError: 'follow' !== 'manual'
✖ guardedFetch: rejects 3xx and opaqueredirect responses
  AssertionError: Missing expected rejection.
✖ guardedFetch redirect vectors: the redirect target receives zero requests
✖ authenticate: allowInsecure + allowPrivate admits a local VTA; a redirect is refused

Revert D — resolver cache bound and method exclusion

✖ resolver cache: 1,000 distinct did:webvh resolutions stay inside maxEntries
  AssertionError: the bound holds, and a full cache stays full
  1000 !== 100
✖ resolver cache: the default bound is 500 entries
  600 !== 500
✖ resolver cache: 1,000 did:keys leave the cache empty
  AssertionError: did:key resolves offline; caching it only costs memory
  1000 !== 0
✖ resolver cache: the real did:key handler is not cached either
  1 !== 0
✖ resolver cache: eviction is least-recently-used
✖ resolver cache: an expired entry is dropped rather than evicting a live one

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:

  • a spy takes the full set and asserts it is never called — nothing
    can leave the process in any state, which is the property actually wanted
  • a local listener takes the spellings that fold onto 127.0.0.1 and
    asserts 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
  • the rebinding answer classes go through guardedLookup directly,
    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 skipped
  • npm run build:types — clean. tsconfig.types.json untouched; its
    explicit rootDir (TypeScript 7 / TS5011) is left exactly as it is.
  • No src/ change, no guard weakened to suit a test
  • No ssrf-cert.pem / ssrf-key.pem or any key material: nothing here
    needs TLS material, since the listeners are plaintext and the TLS
    failure against them is itself a signal the gates use

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>
@stormer78
stormer78 merged commit 654d77f into main Sep 12, 2026
2 checks passed
@stormer78
stormer78 deleted the sec-4045/regression-gates branch September 12, 2026 20:17
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