Skip to content

test(sec-4045): regression gates for the wake SSRF, refusal oracle, register counter, DNS guard and snapshot mode - #30

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

test(sec-4045): regression gates for the wake SSRF, refusal oracle, register counter, DNS guard and snapshot mode#30
stormer78 merged 3 commits into
mainfrom
sec-4045/regression-gates

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Six regression gates for the SEC-4045 PoC steps that PRs #25#29 fixed but left ungated, plus one fix to a gate that passed for the wrong reason. Tests only — no source behaviour changes.

Gap analysis first

Those PRs already added substantial coverage (tests/api.rs, src/store.rs and src/egress.rs unit tests), so this adds only what was missing:

PoC behaviour Already gated? Added here
Web Push endpoint SSRF, unit level yes — sender::tests::production_policy_never_dials_loopback, egress::tests::webpush_endpoint_rejections
SSRF through the dispatch path with a stored record no wake_to_a_stored_internal_endpoint_never_dials
Refusal differences as a port-scanning oracle no register_refusals_reveal_nothing_about_the_target
Refused registration not counted in gateway_register_total partly — the cap paths were counted, two refusals were not refused_registrations_are_never_counted
/metrics off the public router yes — metrics_is_not_served_on_the_public_router default_metrics_bind_is_loopback (pins the default the move relies on)
Guarded resolver in isolation yes — guarded_resolver_refuses_loopback_names
Guarded resolver installed in the real client, refusing before the dial no hardened_client_refuses_a_name_that_resolves_to_loopback
Snapshot mode at open (0644) and at write yes
Snapshot mode across the whole lifecycle, every loose shape no every_loose_snapshot_mode_is_tightened_for_the_whole_lifecycle

A gate that passed for the wrong reason

wake_to_a_stored_internal_endpoint_never_dials originally used an http:// endpoint. The hardened client sets https_only(!policy.allows_loopback()), so reqwest refused that URL client-side before validate_webpush_endpoint was consulted at all. Reverting the endpoint policy to accept anything parseable left the gate green — it was pinning the scheme check twice and the SSRF guard not at all, and would have survived exactly the regression it is named for.

Switched to https, where the scheme check does not apply and the endpoint policy is the only thing between the wake and the listener. The same revert now fails.

This is why each gate below was checked by reverting its fix rather than by reading it.

Red-then-green evidence

Every revert was restored in the same shell invocation and the restore proven with git diff before anything else ran.

every_loose_snapshot_mode_is_tightened_for_the_whole_lifecycle — revert: secretfile::tighten_to_owner_only(&path) removed from Store::open_with_limits.

panicked at src/store.rs:1148:13:
assertion `left == right` failed: open must tighten a snapshot left at 0604
  left: 388
 right: 384

(388 = 0o604, 384 = 0o600.) 1.7s.

hardened_client_refuses_a_name_that_resolves_to_loopback — revert: GuardedResolver::resolve returns every resolved address unfiltered.

test egress::tests::hardened_client_refuses_a_name_that_resolves_to_loopback ... FAILED
panicked at src/egress.rs:789:9

5.0s.

wake_to_a_stored_internal_endpoint_never_dials (after the fix above) — revert: validate_webpush_endpoint accepts anything parseable.

panicked at tests/api.rs:1128:5:
  left: 1
 right: 0

One connection reached the listener. 5.3s.

Hermetic when red, not only when green

Every socket-using gate binds loopback only and asserts on a listener it owns that accepted nothing. Reverting a fix to watch one go red cannot send traffic off the machine — the slowest red run above is 5.3s against 127.0.0.1. The link-local, CGNAT and private-range vectors live in is_public_ip_table, which performs no I/O at all.

This matters because the first cut of the equivalent tests in vti-didcomm-js was hermetic only while passing: reverted, its unroutable vectors dialed the real network for 181–226s per file.

Two repo-specific traps these avoid

  • /metrics is on the management router, so the gates build the public and management routers over one shared AppState, as the existing tests do.
  • Snapshot writes are debounced, so anything asserting on file contents calls flush() first.

Verification

cargo fmt --all --check clean, cargo clippy --all-targets -- -D warnings clean, cargo test 83 passed / 0 failed (59 unit + 24 integration).

Not gated

Nothing from the PoC harness is left unrepresented, but two of the six gates were not proven by revert in this pass — register_refusals_reveal_nothing_about_the_target and refused_registrations_are_never_counted. They assert on the dispatch-level reason string and the counter respectively, both of which are covered by narrower existing unit tests; I would rather say they are unproven than imply a revert check I did not run.

…er counter

Four regression gates for the SEC-4045 PoC steps that the merge of PRs #25-#29
left ungated at the dispatch level:

- a stored Web Push record naming an internal service is provisioned and woken
  through the router with a real WebPushSender; the wake fails and a live
  loopback listener accepts nothing. This is the test PG-1's remediation plan
  asked for by name, one layer above
  sender::tests::production_policy_never_dials_loopback.
- all four poc-harness fixtures (open port, 404 path, closed port, redirector)
  are refused with one identical reason and nothing about the target comes back,
  so the difference between refusals is no longer a port-scanning oracle.
- the two register refusals no existing gate scraped -- no sender for the
  platform, and the per-token cap -- leave gateway_register_total alone. That
  counter is what the PoC quoted as proof the flood landed.
- DEFAULT_METRICS_BIND is a loopback address, so moving the counters onto their
  own router cannot be undone by a change to its default.

Both socket-using gates bind loopback only and assert on a listener that
accepted nothing, so reverting a fix to watch them go red cannot send traffic
off the machine.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…mode lifecycle

Two more gaps the merge of PRs #25-#26 left open:

- egress: guarded_resolver_refuses_loopback_names exercises the resolver on its
  own, but nothing checked that hardened_client_builder installs it, that the
  refusal is the resolver's rather than an incidental TLS or connection-refused
  failure, or that it lands before the dial. A live loopback listener is the
  only witness that can tell "refused" from "tried and failed", so the gate
  binds one and asserts it accepted nothing. localhost is the whole vector set
  deliberately: this is the one that opens a socket, so a revert must dial
  127.0.0.1 and nothing else -- the link-local and private-range hosts stay in
  is_public_ip_table, which performs no I/O.

- store: the snapshot mode was gated at 0644 on open and at 0600 on write, but
  not across the lifecycle, and not for the other ways an older build could have
  left it loose. The new gate walks a loose file of each shape through open ->
  tighten -> reload -> mutate -> rewrite, and asserts the raw token is really in
  the file, so the mode is protecting something rather than an empty fixture. A
  fix that tightened on open but let the rewrite path fall back to the umask
  default would pass the existing gates and fail this one.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…scheme

`wake_to_a_stored_internal_endpoint_never_dials` used an `http://` endpoint,
and passed for the wrong reason. The hardened client sets
`https_only(!policy.allows_loopback())`, so reqwest refused that URL
client-side before `validate_webpush_endpoint` was ever consulted. Proved by
reverting the endpoint policy to accept anything parseable: the gate stayed
green.

As written it pinned the scheme check twice and the SSRF guard not at all — it
would have stayed green through a regression that let internal hosts back into
the policy, which is the finding it is named for.

Over `https` the scheme check does not apply and the endpoint policy is the
only thing between the wake and the listener. Same revert now fails with
`left: 1, right: 0` — one accepted connection — in 5.3s, against a loopback
listener the test owns, so watching it go red still sends nothing off the
machine.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit d1970e6 into main Sep 13, 2026
3 checks passed
@stormer78
stormer78 deleted the sec-4045/regression-gates branch September 13, 2026 04:54
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