Skip to main content

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}