security(resolver)!: refuse did:web/did:webvh resolution to non-public hosts - #29
Merged
Merged
Conversation
… 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>
Contributor
Author
|
Merge order. All four of #26, #27, #28 and #29 merge cleanly into
This PR is first in that order. |
This was referenced Sep 12, 2026
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.
Raises
affinidi-did-resolver-cache-sdkto 0.8.37, which moves did:webvhresolution onto
didwebvh-rs0.7 and closes a resolver-side SSRF. Thisrepo's lockfile was the furthest behind in the fleet.
What was wrong
A
did:web/did:webvhidentifier is a network location: everything after themethod prefix is the host its DID document (or verifiable log) is fetched from.
Through
didwebvh-rs0.6,resolve()rejected IP-literal hosts and nothingelse —
did:webvh:{SCID}:localhost%3A<port>was fetched over plainhttp://,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-serviceresolves eachauthcrypt 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.254and get a GET issued from inside thegateway'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-sdk0.8.34,didwebvh-rs0.6.0,affinidi-did-web0.1.3 — all predating the guards. did:web had been guardedsince 0.8.35; did:webvh had not.
What changes
didwebvh-rs0.7 refuseslocalhost,*.localhost,*.local,*.internal,home.arpaand single-label names before any request is made, and refuses atconnect 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 anda DNS guard would never see it. A refused DID fails with
BlockedHost.DIDCacheConfigBuildernow defaults toHostPolicy::PublicOnlyand governsdid:web and did:webvh together.
src/resolver.rs— the gateway's singleresolver-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 thechange, not tidiness: a floor of
0.8lets a freshcargo updateresolve backonto a pre-policy patch.
Dependencies
affinidi-did-resolver-cache-sdk0.8.34 → 0.8.37,didwebvh-rs0.6.0 → 0.7.0,affinidi-did-web0.1.3 → 0.1.5,did-scid0.2.5 → 0.2.7, andaffinidi-net-guard0.1.0 added. 23 insertions, 9 deletions inCargo.lock;nothing else in the tree moved, and no new direct dependency was needed
(
HostPolicyis re-exported ataffinidi_did_resolver_cache_sdk::network_resolvers).Exactly one
didwebvh-rsnode. Worth stating because the equivalent VTIchange needed a mediator bump to collapse a second, pre-policy 0.6.1 node
reachable through
affinidi-messaging-test-mediatoras a dev-dependency. Thisrepo has no such path —
cargo tree -i didwebvh-rs --edges normal,build,dev: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:webvhidentity, its mediator's DID, and each inbound sender's DID. Inproduction all three are public and nothing changes. In a local stack the VTA
publishes
http://localhost:3000, so the gateway's identity and mediator aredid:webvh:{SCID}:localhost%3A3000and the listener would fail to start withBlockedHost. For that case: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
localhostDID isfetched over plain
http://— a local-testing special case, not something tocarry 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.rsgains two tests asserting the default isHostPolicy::PublicOnlyand 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_policyon
DIDCacheConfigispub(crate), so there is no honest way to observe thebuilt config from here, and
didwebvh-rs0.7 carries its own loopback-spellingand 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) andcargo deny check(advisories/bans/licenses/sources ok).cargo denyemits one pre-existing warning,advisory-not-detectedforRUSTSEC-2025-0134(rustls-pemfile unmaintained) — the ignore entry indeny.tomlno longer matches anything in the tree. It is a warning, not afailure, and it is identical on
origin/main: an untouched worktree atae9a09bproduces the same line and exits 0. Removing the stale ignore is aone-line cleanup that belongs with a deps refresh, not with this security fix.
Not in this PR
controller VTAs are legitimate in production.
expiresAt.