diff --git a/Cargo.lock b/Cargo.lock index b2e1f87..79e70ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,9 +132,9 @@ dependencies = [ [[package]] name = "affinidi-did-resolver-cache-sdk" -version = "0.8.34" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d96b5e9a4f92941ae4262a1d15729e234c49e598bd32598df8bf3db4d160b64" +checksum = "be0b2e768c75df1d0bca72f51ecb6bbb37fccc13e16bf9c093f8fc04e429e952" dependencies = [ "affinidi-did-common", "affinidi-did-resolver-traits", @@ -175,11 +175,12 @@ dependencies = [ [[package]] name = "affinidi-did-web" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48bff79fe41fa2bd3e50b9ac14cdc660a83f61ebbb7fd321517b478781dd3046" +checksum = "08ba8e331d31ff1c6bf9f5684d71471779e5f9fbf7167d93a4a34f2daece202b" dependencies = [ "affinidi-did-common", + "affinidi-net-guard", "percent-encoding", "reqwest", "serde_json", @@ -326,6 +327,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "affinidi-net-guard" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43736e64c051b4689d39c08e781dae750a79fee9bbf44e4eb7ed93e081485184" +dependencies = [ + "reqwest", + "thiserror", + "tokio", + "tracing", + "url", +] + [[package]] name = "affinidi-rdf-encoding" version = "0.1.6" @@ -1285,9 +1299,9 @@ dependencies = [ [[package]] name = "did-scid" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f36397874c3c2b88ba76e39e2e4599bf8a71ca36b9ce0b92b79a84f17d27ff" +checksum = "32bb54e6769f547fc641bbe63bb06f253fe41ad552e47e24cbd6c62a7b6436d8" dependencies = [ "affinidi-did-common", "didwebvh-rs", @@ -1299,9 +1313,9 @@ dependencies = [ [[package]] name = "didwebvh-rs" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b131547e3380c910c2cdc078299bd07ec510d6aa5c074f83bdbf3852c77ba8c0" +checksum = "1aa08dc5af02f6bc159c10271195f467a894b3b4fe67da9796009ee2da785043" dependencies = [ "affinidi-data-integrity", "affinidi-did-common", @@ -3664,7 +3678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 23c24d9..420c866 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,13 @@ uuid = { version = "1", features = ["v4"] } affinidi-messaging-didcomm-service = "0.7" affinidi-tdk = "0.13" affinidi-secrets-resolver = "0.5" -affinidi-did-resolver-cache-sdk = { version = "0.8", features = ["network"] } +# 0.8.37 is a floor, not cosmetic: it is the first release whose did:web **and** +# did:webvh resolvers refuse non-public hosts by default (didwebvh-rs 0.7's +# `HostPolicy`). A DID names the host its document is fetched from, and the +# DIDComm transport resolves DIDs chosen by whoever sends the gateway a message, +# so resolving below this floor would reintroduce a resolver-side SSRF. Pinning +# the patch stops a fresh `cargo update` from resolving back under it. +affinidi-did-resolver-cache-sdk = { version = "0.8.37", features = ["network"] } tokio-util = "0.7" # Async push senders (real delivery — Web Push / APNs — is async HTTP). async-trait = "0.1" diff --git a/README.md b/README.md index 33e9e7d..779cf8b 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,14 @@ cargo run # GATEWAY_DID_NETWORK_TIMEOUT_MS=10000 per-resolution timeout (SDK default 5000) # GATEWAY_DID_RESOLVER_URL=wss://… resolve via a remote resolver service # instead of locally (unset = local resolution) +# GATEWAY_DID_ALLOW_PRIVATE_HOSTS=1 let did:web/did:webvh resolution reach +# non-public hosts (loopback, RFC 1918, link-local). +# Default off: a DID names the host its document is +# fetched from, and inbound DIDComm senders choose the +# DIDs the gateway resolves. Needed only for a local +# stack whose VTA/mediator DIDs are +# did:webvh:{SCID}:localhost%3A3000 — without it those +# resolve as BlockedHost. # RUST_LOG=vti_push_gateway=debug ``` diff --git a/src/resolver.rs b/src/resolver.rs index 351dad5..daa26ca 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -9,16 +9,39 @@ //! takes via `ListenerConfig.tdk_config` (replacing the implicit //! `TDKConfig::headless()`). //! +//! ## Which hosts resolution may contact +//! +//! A `did:web`/`did:webvh` identifier *is* a network location: everything after +//! the method prefix is the host the DID document (or verifiable log) is fetched +//! from. On the DIDComm path the gateway resolves DIDs it did not choose — every +//! authcrypt sender that reaches it through the mediator — so an inbound message +//! naming `did:webvh:{SCID}:169.254.169.254` or an internal host would otherwise +//! make the gateway issue that request from inside its own network. +//! +//! `affinidi-did-resolver-cache-sdk` 0.8.37 defaults to +//! [`HostPolicy::PublicOnly`], which refuses loopback, private, CGNAT, +//! link-local and other non-public hosts — both when the DID names one directly +//! and when a public-looking name resolves to one. This module keeps that +//! default and exposes one opt-out for local development, where the gateway's own +//! identity and its mediator are typically `did:webvh:{SCID}:localhost%3A3000` +//! and resolution would otherwise fail with `BlockedHost`. +//! //! Env knobs (all optional; defaults below): //! - `GATEWAY_DID_CACHE_CAPACITY` — max cached DID docs (default 250) //! - `GATEWAY_DID_CACHE_TTL_SECS` — cache entry TTL (default 900 = 15 min) //! - `GATEWAY_DID_NETWORK_TIMEOUT_MS` — per-resolution timeout (default 10000) //! - `GATEWAY_DID_RESOLVER_URL` — resolve via a remote resolver service //! (`ws[s]://…`) instead of locally; unset = local resolution. +//! - `GATEWAY_DID_ALLOW_PRIVATE_HOSTS` — allow did:web/did:webvh resolution to +//! non-public hosts (default off). For local stacks only. use affinidi_did_resolver_cache_sdk::config::{DIDCacheConfig, DIDCacheConfigBuilder}; +use affinidi_did_resolver_cache_sdk::network_resolvers::HostPolicy; use affinidi_tdk::common::config::TDKConfig; +/// Env var opting resolution out of the public-host-only default. +pub const ENV_ALLOW_PRIVATE_DID_HOSTS: &str = "GATEWAY_DID_ALLOW_PRIVATE_HOSTS"; + /// Resolved DID-resolver tuning (post-env). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolverTuning { @@ -27,6 +50,9 @@ pub struct ResolverTuning { pub network_timeout_ms: u32, /// Remote resolver service address (`ws[s]://…`); `None` = local resolution. pub service_address: Option, + /// Whether did:web/did:webvh resolution may contact non-public hosts. + /// `false` (the default) is [`HostPolicy::PublicOnly`]. + pub allow_private_did_hosts: bool, } impl Default for ResolverTuning { @@ -39,6 +65,10 @@ impl Default for ResolverTuning { cache_ttl_secs: 900, network_timeout_ms: 10_000, service_address: None, + // Secure default: a DID that names a private or loopback host is + // refused, because inbound DIDComm senders choose the DIDs the + // gateway resolves. + allow_private_did_hosts: false, } } } @@ -56,6 +86,16 @@ impl ResolverTuning { .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()), + allow_private_did_hosts: env_flag(ENV_ALLOW_PRIVATE_DID_HOSTS), + } + } + + /// The host policy resolution runs under. + fn host_policy(&self) -> HostPolicy { + if self.allow_private_did_hosts { + HostPolicy::AllowPrivate + } else { + HostPolicy::PublicOnly } } @@ -63,7 +103,11 @@ impl ResolverTuning { let mut builder = DIDCacheConfigBuilder::default() .with_cache_capacity(self.cache_capacity) .with_cache_ttl(self.cache_ttl_secs) - .with_network_timeout(self.network_timeout_ms); + .with_network_timeout(self.network_timeout_ms) + // Explicit rather than implicit: this is the same value the builder + // defaults to, stated here so the gateway's stance is visible at the + // one place it configures resolution. + .with_host_policy(self.host_policy()); if let Some(addr) = &self.service_address { builder = builder.with_network_mode(addr); } @@ -85,15 +129,32 @@ impl ResolverTuning { /// One-line summary for the startup log. pub fn summary(&self) -> String { format!( - "cache_capacity={} cache_ttl={}s network_timeout={}ms resolver={}", + "cache_capacity={} cache_ttl={}s network_timeout={}ms resolver={} did_hosts={}", self.cache_capacity, self.cache_ttl_secs, self.network_timeout_ms, - self.service_address.as_deref().unwrap_or("local") + self.service_address.as_deref().unwrap_or("local"), + if self.allow_private_did_hosts { + "private-allowed" + } else { + "public-only" + } ) } } +/// Parse a boolean env flag: set to `1`/`true`/`yes`/`on` enables it. +fn env_flag(key: &str) -> bool { + std::env::var(key) + .map(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + /// Parse a `u32` env var, logging and falling back to `default` when unset or /// unparseable. Split from the parsing so the latter is unit-testable. fn env_u32(key: &str, default: u32) -> u32 { @@ -140,6 +201,38 @@ mod tests { assert!(d.service_address.is_none()); // local by default } + /// The gateway resolves DIDs chosen by inbound DIDComm senders, so the + /// default must be the public-only policy — and must be visible in the + /// startup log. + #[test] + fn did_host_policy_defaults_to_public_only() { + let d = ResolverTuning::default(); + assert!(!d.allow_private_did_hosts); + assert_eq!(d.host_policy(), HostPolicy::PublicOnly); + assert!( + d.summary().contains("did_hosts=public-only"), + "{}", + d.summary() + ); + } + + /// The local-development opt-in flips the policy, and says so in the log. + #[test] + fn private_did_hosts_opt_in_is_visible() { + let t = ResolverTuning { + allow_private_did_hosts: true, + ..ResolverTuning::default() + }; + assert_eq!(t.host_policy(), HostPolicy::AllowPrivate); + assert!( + t.summary().contains("did_hosts=private-allowed"), + "{}", + t.summary() + ); + // Both policies build a usable config. + assert!(t.tdk_config().is_ok()); + } + #[test] fn builds_a_tdk_config_local_and_remote() { // Local.