aph_resolver/web.rs
1//! The §8.4.4 `did:web` document fetch adapter, over `reqwest`.
2//!
3//! Like the DNS adapter beside it, this module contributes I/O only — parsing the
4//! document and picking the key the proof names is
5//! `aph_core::discovery::did_document`. What it does own is the guard, and
6//! the guard is the reason this crate exists as more than a five-line
7//! convenience.
8//!
9//! **The host being fetched is chosen by whoever wrote the envelope.** A
10//! verifier reads `proof.verificationMethod`, derives a URL from it, and
11//! connects. That is an outbound request an untrusted party specified, so
12//! without controls a verifier is a request forger with a credential-shaped
13//! excuse. All five SSRF controls are implemented here
14//! in the order they must run:
15//!
16//! 1. **Address deny table over the FULL resolved set, before any connect**,
17//! then the validated set is PINNED into the client so the connect cannot
18//! re-resolve. Classifying and then letting the HTTP stack resolve again
19//! is the TOCTOU hole DNS rebinding walks through.
20//! 2. **HTTPS only** — asserted on the parsed scheme AND enforced by the
21//! client. No `danger_accept_invalid_*` appears anywhere in this crate;
22//! §8.4.4 step 3 makes certificate validation failure a fetch failure,
23//! because the certificate chain IS the trust anchor for this mechanism.
24//! 3. **No redirects, and an origin-equality re-check on the response.** A
25//! redirect that changed origin would move the trust anchor to a host the
26//! notary's certificate never covered.
27//! 4. **Bounded** — 5 s connect, 5 s total, and a body cap enforced WHILE
28//! streaming. A real DID Document is under 2 KiB.
29//! 5. **One opaque error outward.** Every distinguishable failure collapses
30//! to a bare `APH_E008`.
31//!
32//! Control 5 deserves its own sentence, because it is the one that looks like
33//! bad ergonomics. A verifier's result is disclosed to whoever sent the
34//! envelope, and that party chose the DID — so an error that said "connection
35//! refused" where another said "certificate invalid", or that simply came
36//! back faster, would turn every verifier into a network scanner steerable
37//! from outside. "No key was obtained" is the whole of what a verifier may
38//! learn and report.
39
40/// Total time allowed for the whole request, connect included.
41const TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
42
43/// Time allowed to establish the connection.
44const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
45
46/// Hard ceiling on the response body.
47///
48/// Spec §8.4.4 documents are a handful of verification methods; the largest
49/// real one this implementation has seen is under 2 KiB. 64 KiB leaves two
50/// orders of magnitude of headroom and still means a hostile origin cannot
51/// stream a verifier out of memory.
52const MAX_BODY_BYTES: usize = 64 * 1024;
53
54/// The one failure this adapter ever reports.
55///
56/// A free function rather than an inline literal at each site so the promise
57/// is checkable by reading one place: every arm below returns THIS, and the
58/// variant carries no payload, so opacity is structural rather than a
59/// discipline someone has to maintain.
60fn refused() -> aph_core::errors::AphError {
61 aph_core::errors::AphError::NotaryServiceUnreachable
62}
63
64/// A [`aph_core::discovery::ports::DidDocumentFetch`] over `reqwest`, with
65/// all five SSRF controls applied.
66#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::default::Default)]
67pub struct ReqwestDocumentFetch {
68 // A zero-sized struct on purpose: see `fetch_did_document` for why no
69 // client is cached here.
70 _private: (),
71}
72
73impl ReqwestDocumentFetch {
74 /// Builds the guarded fetch adapter.
75 pub fn new() -> Self {
76 Self { _private: () }
77 }
78}
79
80impl aph_core::discovery::ports::DidDocumentFetch for ReqwestDocumentFetch {
81 /// Fetches `url` under the five controls and returns the body unparsed.
82 ///
83 /// `url` is taken AS DERIVED. `DidUrl::web_document_url` already encoded
84 /// the colon-to-path-segment mapping, the `/.well-known/` special case and
85 /// the percent-decode ordering that keeps a `%3A`-written port attached to
86 /// its host; re-deriving here would get one of them wrong, and a wrong
87 /// document that happens to parse yields a wrong key rather than an error.
88 ///
89 /// # Errors
90 ///
91 /// `APH_E008`, always the same bare value — see the module note on
92 /// control 5.
93 fn fetch_did_document<'a>(
94 &'a self,
95 url: &'a str,
96 ) -> aph_core::discovery::ports::DiscoveryFuture<'a, String> {
97 std::boxed::Box::pin(async move {
98 // Annotated because the async block's output type is pinned only by
99 // the unsizing coercion to `dyn Future`.
100 let out: std::result::Result<String, aph_core::errors::AphError> = fetch_guarded(url).await;
101 out
102 })
103 }
104}
105
106/// The guarded fetch, written as a free async fn so the control order reads
107/// top to bottom.
108async fn fetch_guarded(url: &str) -> std::result::Result<String, aph_core::errors::AphError> {
109 let target = match reqwest::Url::parse(url) {
110 std::result::Result::Ok(parsed) => parsed,
111 std::result::Result::Err(_) => return std::result::Result::Err(refused()),
112 };
113
114 // ── Control 2: HTTPS only, asserted on the parsed scheme ──────────────
115 // The client is also built with `https_only(true)`. Both, not either: the
116 // explicit assertion refuses BEFORE any resolution work happens, so a
117 // plaintext URL never even causes a DNS query, and the client setting
118 // remains as the enforcement a future refactor cannot drop by accident.
119 if target.scheme() != "https" {
120 return std::result::Result::Err(refused());
121 }
122
123 let host = match target.host_str() {
124 std::option::Option::Some(host) if !host.is_empty() => host,
125 _ => return std::result::Result::Err(refused()),
126 };
127 let port = match target.port_or_known_default() {
128 std::option::Option::Some(port) => port,
129 std::option::Option::None => return std::result::Result::Err(refused()),
130 };
131
132 let mut builder = reqwest::Client::builder()
133 .https_only(true)
134 // ── Control 3: no redirects ────────────────────────────────────────
135 // The origin is the entire trust anchor for §8.4.4, so following a
136 // redirect would let the named host hand resolution to a host whose
137 // certificate proves nothing about the notary.
138 .redirect(reqwest::redirect::Policy::none())
139 // ── Control 4: bounds ──────────────────────────────────────────────
140 .timeout(TOTAL_TIMEOUT)
141 .connect_timeout(CONNECT_TIMEOUT);
142
143 // ── Control 1: address deny table, then pin ───────────────────────────
144 // `host_str` brackets an IPv6 literal; strip them before parsing so a
145 // literal host is recognised as one rather than falling through to a DNS
146 // query for the text "[::1]".
147 let bare_host = match host.strip_prefix('[') {
148 std::option::Option::Some(rest) => rest.strip_suffix(']').unwrap_or(rest),
149 std::option::Option::None => host,
150 };
151 match <std::net::IpAddr as std::str::FromStr>::from_str(bare_host) {
152 std::result::Result::Ok(literal) => {
153 // A literal needs no pin: there is no name, so there is nothing that
154 // could resolve differently between this check and the connect.
155 if crate::address::classify_ip(literal) == crate::address::AddressClass::Refused {
156 return std::result::Result::Err(refused());
157 }
158 }
159 std::result::Result::Err(_) => {
160 let resolved: std::vec::Vec<std::net::SocketAddr> =
161 match tokio::net::lookup_host((bare_host, port)).await {
162 std::result::Result::Ok(addresses) => addresses.collect(),
163 std::result::Result::Err(_) => return std::result::Result::Err(refused()),
164 };
165 // A name that resolves to nothing is not reachable; refusing here also
166 // means `resolve_to_addrs` is never handed an empty set, which reqwest
167 // would treat as "no override" and resolve normally.
168 if resolved.is_empty() {
169 return std::result::Result::Err(refused());
170 }
171 // EVERY address, not the first: a name that answers with one public
172 // and one loopback address is a rebinding attempt, and connecting to
173 // whichever the stack happened to try first is a coin flip an attacker
174 // gets to re-toss.
175 for address in &resolved {
176 if crate::address::classify_ip(address.ip()) == crate::address::AddressClass::Refused {
177 return std::result::Result::Err(refused());
178 }
179 }
180 // The pin. Without it the classification above is advisory: the client
181 // would resolve the name again at connect time, and a DNS answer with
182 // a one-second TTL can be public for the check and loopback for the
183 // connect. This is also why no `Client` is cached on the adapter — the
184 // pin is per-URL, so a shared client could not carry it.
185 builder = builder.resolve_to_addrs(bare_host, &resolved);
186 }
187 }
188
189 let client = match builder.build() {
190 std::result::Result::Ok(client) => client,
191 std::result::Result::Err(_) => return std::result::Result::Err(refused()),
192 };
193 let response = match client.get(target.clone()).send().await {
194 std::result::Result::Ok(response) => response,
195 // Connect failure, TLS failure and timeout all land here, and all three
196 // leave by the same door (control 5).
197 std::result::Result::Err(_) => return std::result::Result::Err(refused()),
198 };
199
200 // ── Control 3, second half: the response really came from the origin ──
201 // `Policy::none()` already refuses to follow a redirect, so this can only
202 // fire if that policy stopped being applied. It is cheap, and the failure
203 // it guards against — a body attributed to an origin that did not serve
204 // it — is not detectable anywhere downstream.
205 if !same_origin(&target, response.url()) {
206 return std::result::Result::Err(refused());
207 }
208
209 // A non-success status is `APH_E008` per the shipped port contract, 404
210 // included: `did:web` has no served-absence form in v0.1, so "the origin
211 // answered 404" and "the origin answered 503" are the same fact to a
212 // verifier — no key was obtained. Inventing an absence code from a status
213 // would also make the status readable from outside, which is control 5's
214 // whole concern.
215 if !response.status().is_success() {
216 return std::result::Result::Err(refused());
217 }
218
219 read_capped(response).await
220}
221
222/// Scheme, host and effective port all equal.
223///
224/// Compared field by field rather than by `Url::origin()` so the comparison
225/// is visible at the call site and so a URL whose port is written explicitly
226/// (`:443`) matches the same URL with it omitted.
227fn same_origin(requested: &reqwest::Url, answered: &reqwest::Url) -> bool {
228 requested.scheme() == answered.scheme()
229 && requested.host_str() == answered.host_str()
230 && requested.port_or_known_default() == answered.port_or_known_default()
231}
232
233/// Reads the body, aborting the moment it would exceed [`MAX_BODY_BYTES`].
234///
235/// Streaming rather than `Response::bytes().await` and then measuring: a
236/// read-then-measure cap has already allocated whatever the origin sent by
237/// the time it decides the body was too large, which makes the "cap" a
238/// memory amplifier rather than a bound. `Content-Length` is no substitute
239/// either — it is a claim by the same origin the cap exists to distrust.
240async fn read_capped(
241 response: reqwest::Response,
242) -> std::result::Result<String, aph_core::errors::AphError> {
243 let mut body: std::vec::Vec<u8> = std::vec::Vec::new();
244 // `bytes_stream` is not `Unpin`; pinning to the stack is what lets
245 // `StreamExt::next` take it by `&mut`.
246 let mut stream = std::pin::pin!(response.bytes_stream());
247 loop {
248 match futures_util::StreamExt::next(&mut stream).await {
249 std::option::Option::Some(std::result::Result::Ok(chunk)) => {
250 if body.len() + chunk.len() > MAX_BODY_BYTES {
251 return std::result::Result::Err(refused());
252 }
253 body.extend_from_slice(&chunk);
254 }
255 std::option::Option::Some(std::result::Result::Err(_)) => {
256 return std::result::Result::Err(refused());
257 }
258 std::option::Option::None => break,
259 }
260 }
261 // The port hands back a UTF-8 string and forms no opinion about the JSON
262 // inside it; bytes that are not UTF-8 at all are not a document.
263 match String::from_utf8(body) {
264 std::result::Result::Ok(text) => std::result::Result::Ok(text),
265 std::result::Result::Err(_) => std::result::Result::Err(refused()),
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 /// The exact text a bare `APH_E008` renders as.
272 ///
273 /// Spelled out rather than derived from the error under test, so a change
274 /// that started interpolating detail into the message fails here instead
275 /// of comparing a leaky message against itself.
276 const OPAQUE: &str = "APH_E008: notary service unreachable";
277
278 #[tokio::test]
279 async fn a_loopback_url_is_refused_before_any_socket_is_opened() {
280 // No listener is started, and none is needed — that absence IS the
281 // assertion. The refusal happens at address classification, which runs
282 // on the parsed literal before a client is built or a connect attempted,
283 // so this test would pass identically on a machine with something bound
284 // to :443 and on one with nothing running at all. `did:web:127.0.0.1` is
285 // a well-formed DID any envelope may name, and a verifier that fetched
286 // it would be reading its own loopback services on a stranger's
287 // instruction.
288 let fetch = super::ReqwestDocumentFetch::new();
289 let error = aph_core::discovery::ports::DidDocumentFetch::fetch_did_document(
290 &fetch,
291 "https://127.0.0.1/.well-known/did.json",
292 )
293 .await
294 .unwrap_err();
295 std::assert_eq!(error, aph_core::errors::AphError::NotaryServiceUnreachable);
296 }
297
298 #[tokio::test]
299 async fn cloud_metadata_and_ipv6_loopback_are_refused_too() {
300 // The two literals an SSRF attempt actually reaches for. Covered here as
301 // well as in the classifier tests because these go through the whole
302 // fetch path — a guard that classified correctly but ran AFTER the
303 // client was built would still pass the pure tests.
304 let fetch = super::ReqwestDocumentFetch::new();
305 for url in [
306 "https://169.254.169.254/.well-known/did.json",
307 "https://[::1]/.well-known/did.json",
308 "https://[::ffff:127.0.0.1]/.well-known/did.json",
309 ] {
310 let error = aph_core::discovery::ports::DidDocumentFetch::fetch_did_document(&fetch, url)
311 .await
312 .unwrap_err();
313 std::assert_eq!(
314 error,
315 aph_core::errors::AphError::NotaryServiceUnreachable,
316 "{} was not refused",
317 url
318 );
319 }
320 }
321
322 #[tokio::test]
323 async fn a_plaintext_url_is_refused_with_the_same_opaque_error() {
324 // §8.4.4 step 3 anchors this mechanism in the TLS certificate chain, so
325 // a plaintext fetch is not a weaker success — it proves nothing at all.
326 // The host here is public and would resolve, which is the point: the
327 // scheme assertion refuses before any DNS query is made, so this test
328 // needs no network either. It refuses with the SAME error as the
329 // loopback case, so nothing about which control fired is readable from
330 // outside.
331 let fetch = super::ReqwestDocumentFetch::new();
332 let error = aph_core::discovery::ports::DidDocumentFetch::fetch_did_document(
333 &fetch,
334 "http://notary.example.com/.well-known/did.json",
335 )
336 .await
337 .unwrap_err();
338 std::assert_eq!(error, aph_core::errors::AphError::NotaryServiceUnreachable);
339 }
340
341 #[tokio::test]
342 async fn a_malformed_url_is_refused_rather_than_panicking() {
343 // The URL is derived from an attacker-chosen DID. `web_document_url`
344 // builds it from percent-decoded segments and does not itself validate
345 // the result, so a URL that will not parse must be an ordinary refusal
346 // here rather than an unwrap on the parse.
347 let fetch = super::ReqwestDocumentFetch::new();
348 for url in ["not a url", "https://", "file:///etc/passwd", ""] {
349 let error = aph_core::discovery::ports::DidDocumentFetch::fetch_did_document(&fetch, url)
350 .await
351 .unwrap_err();
352 std::assert_eq!(
353 error,
354 aph_core::errors::AphError::NotaryServiceUnreachable,
355 "{:?} was not refused",
356 url
357 );
358 }
359 }
360
361 #[tokio::test]
362 async fn the_refusal_message_discloses_nothing_about_what_was_refused() {
363 // Control 5, pinned as text. The verifier's result reaches whoever chose
364 // the DID, so a message naming the address, echoing the URL, or carrying
365 // a status code would let that party probe an address space through this
366 // process and read the answers. Comparing the two refusals to ONE
367 // constant is the strong form of the claim: not merely "no detail
368 // leaked" but "these two outcomes are indistinguishable".
369 let fetch = super::ReqwestDocumentFetch::new();
370 let loopback = aph_core::discovery::ports::DidDocumentFetch::fetch_did_document(
371 &fetch,
372 "https://127.0.0.1:8443/.well-known/did.json",
373 )
374 .await
375 .unwrap_err();
376 let plaintext = aph_core::discovery::ports::DidDocumentFetch::fetch_did_document(
377 &fetch,
378 "http://notary.example.com/.well-known/did.json",
379 )
380 .await
381 .unwrap_err();
382 let rendered = std::string::ToString::to_string(&loopback);
383 std::assert_eq!(rendered, OPAQUE);
384 std::assert_eq!(std::string::ToString::to_string(&plaintext), OPAQUE);
385 for leak in ["127.0.0.1", "8443", "notary.example.com", "well-known", "http"] {
386 std::assert!(
387 !rendered.contains(leak),
388 "refusal message leaked `{}`: {}",
389 leak,
390 rendered
391 );
392 }
393 }
394
395 #[test]
396 fn origin_equality_rejects_a_moved_response() {
397 // The check behind control 3. Same scheme and same path but a different
398 // host is precisely what an open redirect produces, and it is the case
399 // that must fail: the trust anchor for §8.4.4 is the certificate that
400 // validated THIS origin, and a document served by another host carries
401 // no claim about this notary. The implicit-port row pins the other
402 // direction — `:443` written out must not be read as a different origin,
403 // or every explicitly-ported URL would refuse its own answer.
404 let requested = reqwest::Url::parse("https://notary.example.com/.well-known/did.json").unwrap();
405 std::assert!(super::same_origin(
406 &requested,
407 &reqwest::Url::parse("https://notary.example.com:443/.well-known/did.json").unwrap()
408 ));
409 std::assert!(!super::same_origin(
410 &requested,
411 &reqwest::Url::parse("https://evil.example.net/.well-known/did.json").unwrap()
412 ));
413 std::assert!(!super::same_origin(
414 &requested,
415 &reqwest::Url::parse("https://notary.example.com:8443/.well-known/did.json").unwrap()
416 ));
417 }
418
419 #[test]
420 fn the_body_cap_is_a_bound_not_a_suggestion() {
421 // A constant test looks trivial and is not: this number is the only
422 // thing standing between a verifier and an origin that streams
423 // indefinitely, and "it was 64 KiB when reviewed" is the claim worth
424 // pinning. A real DID Document is under 2 KiB, so any change large
425 // enough to matter is a change of posture rather than of tuning.
426 std::assert_eq!(super::MAX_BODY_BYTES, 65536);
427 std::assert_eq!(super::TOTAL_TIMEOUT, std::time::Duration::from_secs(5));
428 std::assert_eq!(super::CONNECT_TIMEOUT, std::time::Duration::from_secs(5));
429 }
430}