Skip to content

security(resolver)!: refuse did:web/did:webvh resolution to non-public hosts - #29

Merged
stormer78 merged 1 commit into
mainfrom
sec-4045/resolver-host-policy-bump
Sep 12, 2026
Merged

security(resolver)!: refuse did:web/did:webvh resolution to non-public hosts#29
stormer78 merged 1 commit into
mainfrom
sec-4045/resolver-host-policy-bump

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Raises affinidi-did-resolver-cache-sdk to 0.8.37, which moves did:webvh
resolution onto didwebvh-rs 0.7 and closes a resolver-side SSRF. This
repo's lockfile was the furthest behind in the fleet.

What was wrong

A did:web/did:webvh identifier is a network location: everything after the
method prefix is the host its DID document (or verifiable log) is fetched from.
Through didwebvh-rs 0.6, resolve() rejected IP-literal hosts and nothing
else — did:webvh:{SCID}:localhost%3A<port> was fetched over plain http://,
and any other name was fetched from whatever address it resolved to.

It is remotely reachable here. The DIDComm transport resolves DIDs the
gateway did not choose: affinidi-messaging-didcomm-service resolves each
authcrypt sender in order to unpack the message, so any party that can route a
frame to the gateway through its mediator could name
did:webvh:{SCID}:169.254.169.254 and get a GET issued from inside the
gateway's network. Blind and GET-only, which is why it is not a P0, but it is
the same class of bug as PG-1 and it sits behind the resolver rather than the
push client that #25 hardened.

Lockfile before: affinidi-did-resolver-cache-sdk 0.8.34, didwebvh-rs 0.6.0,
affinidi-did-web 0.1.3 — all predating the guards. did:web had been guarded
since 0.8.35; did:webvh had not.

What changes

didwebvh-rs 0.7 refuses localhost, *.localhost, *.local, *.internal,
home.arpa and single-label names before any request is made, and refuses at
connect time a name whose DNS answer contains a loopback, RFC 1918, CGNAT
(100.64.0.0/10), link-local or unique-local address — connecting only to
addresses it checked. Its client refuses redirects and ignores HTTP_PROXY /
HTTPS_PROXY / ALL_PROXY, because a proxy resolves the target name itself and
a DNS guard would never see it. A refused DID fails with BlockedHost.

DIDCacheConfigBuilder now defaults to HostPolicy::PublicOnly and governs
did:web and did:webvh together. src/resolver.rs — the gateway's single
resolver-construction site — states that policy explicitly rather than relying on
the dependency's default, so the stance is visible where resolution is
configured, and the startup log line gains did_hosts=public-only.

The requirement moves from "0.8" to "0.8.37". That is the point of the
change, not tidiness: a floor of 0.8 lets a fresh cargo update resolve back
onto a pre-policy patch.

Dependencies

affinidi-did-resolver-cache-sdk 0.8.34 → 0.8.37, didwebvh-rs 0.6.0 → 0.7.0,
affinidi-did-web 0.1.3 → 0.1.5, did-scid 0.2.5 → 0.2.7, and
affinidi-net-guard 0.1.0 added. 23 insertions, 9 deletions in Cargo.lock;
nothing else in the tree moved, and no new direct dependency was needed
(HostPolicy is re-exported at
affinidi_did_resolver_cache_sdk::network_resolvers).

Exactly one didwebvh-rs node. Worth stating because the equivalent VTI
change needed a mediator bump to collapse a second, pre-policy 0.6.1 node
reachable through affinidi-messaging-test-mediator as a dev-dependency. This
repo has no such path — cargo tree -i didwebvh-rs --edges normal,build,dev:

didwebvh-rs v0.7.0
├── affinidi-did-resolver-cache-sdk v0.8.37
│   ├── affinidi-did-authentication v0.3.12
│   │   ├── affinidi-meeting-place v0.4.5
│   │   │   └── affinidi-tdk v0.13.0
│   │   │       └── vti-push-gateway v0.1.0
│   │   ├── affinidi-messaging-sdk v0.23.0
│   │   │   ├── affinidi-messaging-didcomm-service v0.7.0
│   │   │   │   └── vti-push-gateway v0.1.0
│   │   │   └── affinidi-tdk v0.13.0 (*)
│   │   ├── affinidi-tdk v0.13.0 (*)
│   │   └── affinidi-tdk-common v0.6.10
│   │       ├── affinidi-meeting-place v0.4.5 (*)
│   │       ├── affinidi-messaging-didcomm-service v0.7.0 (*)
│   │       ├── affinidi-messaging-sdk v0.23.0 (*)
│   │       └── affinidi-tdk v0.13.0 (*)
│   ├── affinidi-meeting-place v0.4.5 (*)
│   ├── affinidi-tdk v0.13.0 (*)
│   ├── affinidi-tdk-common v0.6.10 (*)
│   └── vti-push-gateway v0.1.0
└── did-scid v0.2.7
    └── affinidi-did-resolver-cache-sdk v0.8.37 (*)

Does anything here resolve a localhost DID?

Yes, but only in a local stack — and it needs the opt-in.

The gateway resolves DIDs in one place: the DIDComm listener. It resolves its own
did:webvh identity, its mediator's DID, and each inbound sender's DID. In
production all three are public and nothing changes. In a local stack the VTA
publishes http://localhost:3000, so the gateway's identity and mediator are
did:webvh:{SCID}:localhost%3A3000 and the listener would fail to start with
BlockedHost. For that case:

export GATEWAY_DID_ALLOW_PRIVATE_HOSTS=1

It is deliberately one flag covering did:web and did:webvh together (that is the
granularity the dependency offers), it is off by default, and the startup log
says which way it is set. Note that under the private policy a localhost DID is
fetched over plain http:// — a local-testing special case, not something to
carry into a deployment.

There is no separate opt-in for "foreign" DIDs, and that is intentional: the
inbound-sender DIDs are exactly the attacker-controlled input the guard exists
for. An operator who sets this flag to make a local stack work is also allowing
inbound senders to name private hosts, which is acceptable on a developer
machine and not acceptable in production.

Test

resolver.rs gains two tests asserting the default is HostPolicy::PublicOnly
and that the opt-in flips it and is visible in the startup summary. They assert
through this crate's own construction path.

They deliberately do not re-test the dependency's enforcement: host_policy
on DIDCacheConfig is pub(crate), so there is no honest way to observe the
built config from here, and didwebvh-rs 0.7 carries its own loopback-spelling
and DNS-rebinding tests. What this repo needs to guarantee is that it does not
silently configure the policy away, which is what these tests pin.

Verification

cargo fmt --all --check, cargo clippy --all-targets -- -D warnings,
cargo test --all-targets (36 unit + 12 integration, all passing) and
cargo deny check (advisories/bans/licenses/sources ok).

cargo deny emits one pre-existing warning, advisory-not-detected for
RUSTSEC-2025-0134 (rustls-pemfile unmaintained) — the ignore entry in
deny.toml no longer matches anything in the tree. It is a warning, not a
failure, and it is identical on origin/main: an untouched worktree at
ae9a09b produces the same line and exits 0. Removing the stale ignore is a
one-line cleanup that belongs with a deps refresh, not with this security fix.

Not in this PR

  • PG-9, a controller-DID policy — needs an owner decision on whether did:key
    controller VTAs are legitimate in production.
  • PG-N1, a provision replay guard — blocked on the VTA setting expiresAt.

… hosts

Raise affinidi-did-resolver-cache-sdk to 0.8.37, which moves did:webvh
resolution onto didwebvh-rs 0.7 and closes a resolver-side SSRF. This repo's
lockfile was the furthest behind in the fleet: 0.8.34 with didwebvh-rs 0.6.0
and affinidi-did-web 0.1.3, all predating the host guards.

A did:web/did:webvh identifier names the host its document is fetched from,
and the DIDComm transport resolves DIDs the gateway did not choose — every
authcrypt sender that reaches it through the mediator. Through didwebvh-rs
0.6, resolve() rejected IP-literal hosts and nothing else, so an inbound
message naming an internal host produced a GET from inside the gateway's own
network.

0.8.37 defaults to HostPolicy::PublicOnly, which refuses loopback, private,
CGNAT and link-local hosts both when a DID names one directly and when a
public-looking name resolves to one. resolver.rs now states that policy
explicitly at the one place it configures resolution, and adds
GATEWAY_DID_ALLOW_PRIVATE_HOSTS for local stacks whose VTA and mediator DIDs
are did:webvh:{SCID}:localhost%3A3000 and would otherwise fail as BlockedHost.

The version requirement is pinned to 0.8.37 rather than 0.8 so a later resolve
cannot land back on a build without the policy. Exactly one didwebvh-rs node
remains in the tree; no second, pre-policy node is reachable through
dev-dependencies here.

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

Copy link
Copy Markdown
Contributor Author

Merge order. All four of #26, #27, #28 and #29 merge cleanly into main as it stands, but they conflict with each other, so the second one merged will need a rebase. Suggested order, and I will do each rebase as the one before it lands:

  1. security(resolver)!: refuse did:web/did:webvh resolution to non-public hosts #29 (resolver bump) — clean against the other three, so it can go any time.
  2. fix(store): write the handle snapshot and the key files owner-only #26 (snapshot and key-file permissions) — smallest of the remaining three.
  3. feat(store): expire, cap and rate-limit the anonymous registration path #28 (expiry, caps, rate limiting) — overlaps fix(store): write the handle snapshot and the key files owner-only #26 in Cargo.toml, README.md, src/main.rs and src/store.rs.
  4. feat(api): move metrics off the public listener; bound provision and errors #27 (metrics listener and bounds) — overlaps both in README.md, src/main.rs and tests/api.rs.

This PR is first in that order.

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