Skip to main content

xdid_method_web/
lib.rs

1//! [xdid](https://github.com/unavi-xyz/xdid) implementation of [did:web](https://w3c-ccg.github.io/did-method-web/).
2
3use std::time::Duration;
4
5use reqwest::{
6    Client,
7    ClientBuilder,
8    Response,
9    Url,
10};
11use thiserror::Error;
12use xdid_core::{
13    Method,
14    MethodFuture,
15    ResolutionError,
16    did::Did,
17    document::Document,
18};
19
20mod parse;
21mod policy;
22
23const NAME: &str = "web";
24const USER_AGENT: &str = concat!("xdid/", env!("CARGO_PKG_VERSION"));
25
26/// Limits applied while resolving. The defaults assume DIDs arrive from
27/// untrusted input.
28#[derive(Debug, Clone)]
29pub struct Config {
30    /// Rejects documents larger than this, streaming the body so an oversized
31    /// or dishonestly-framed response is abandoned rather than buffered.
32    pub max_document_bytes: u64,
33    pub connect_timeout:    Duration,
34    pub request_timeout:    Duration,
35    /// Permits loopback, private and link-local targets, and plaintext HTTP for
36    /// `localhost`. Needed to resolve against a local server; an SSRF vector
37    /// whenever the DID being resolved is attacker-controlled.
38    pub allow_local:        bool,
39}
40
41impl Default for Config {
42    fn default() -> Self {
43        Self {
44            max_document_bytes: 64 * 1024,
45            connect_timeout:    Duration::from_secs(5),
46            request_timeout:    Duration::from_secs(10),
47            allow_local:        false,
48        }
49    }
50}
51
52/// Keeps `reqwest` out of the public API, where its version would otherwise be
53/// part of this crate's semver contract.
54#[derive(Debug, Error)]
55#[error("failed to build the HTTP client: {0}")]
56pub struct ClientError(String);
57
58pub struct MethodDidWeb {
59    client: Client,
60    config: Config,
61}
62
63impl MethodDidWeb {
64    /// Create a new did:web resolver.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if the HTTP client cannot be constructed.
69    pub fn new() -> Result<Self, ClientError> {
70        Self::with_config(Config::default())
71    }
72
73    /// Create a new did:web resolver with the given [`Config`].
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if the HTTP client cannot be constructed.
78    pub fn with_config(config: Config) -> Result<Self, ClientError> {
79        let client = build_client(&config).map_err(|e| ClientError(e.to_string()))?;
80        Ok(Self { client, config })
81    }
82}
83
84#[cfg(not(target_family = "wasm"))]
85fn build_client(config: &Config) -> Result<Client, reqwest::Error> {
86    ClientBuilder::new()
87        .user_agent(USER_AGENT)
88        // A redirect would escape the target checks applied to the initial URL.
89        .redirect(reqwest::redirect::Policy::none())
90        .https_only(!config.allow_local)
91        .connect_timeout(config.connect_timeout)
92        .timeout(config.request_timeout)
93        .build()
94}
95
96// The wasm client is the browser's; it applies its own transport policy and
97// exposes no knobs for redirects or timeouts.
98#[cfg(target_family = "wasm")]
99fn build_client(_config: &Config) -> Result<Client, reqwest::Error> {
100    ClientBuilder::new().user_agent(USER_AGENT).build()
101}
102
103impl Method for MethodDidWeb {
104    fn method_name(&self) -> &'static str {
105        NAME
106    }
107
108    #[cfg(not(target_family = "wasm"))]
109    fn resolve(&self, did: Did) -> MethodFuture<Result<Document, ResolutionError>> {
110        Box::pin(resolve_inner(self.client.clone(), self.config.clone(), did))
111    }
112
113    #[cfg(target_family = "wasm")]
114    fn resolve(&self, did: Did) -> MethodFuture<Result<Document, ResolutionError>> {
115        // Sound only because wasm is single-threaded; the future is never polled
116        // from a thread other than the one that created it.
117        Box::pin(send_wrapper::SendWrapper::new(resolve_inner(
118            self.client.clone(),
119            self.config.clone(),
120            did,
121        )))
122    }
123}
124
125async fn resolve_inner(
126    client: Client,
127    config: Config,
128    did: Did,
129) -> Result<Document, ResolutionError> {
130    if did.method_name.as_str() != NAME {
131        return Err(ResolutionError::InvalidDid);
132    }
133
134    let url =
135        parse::parse_url(&did, config.allow_local).map_err(|_| ResolutionError::InvalidDid)?;
136
137    if !config.allow_local {
138        check_target(&url).await?;
139    }
140
141    let res = client
142        .get(url)
143        .header(
144            reqwest::header::ACCEPT,
145            "application/did+json, application/json",
146        )
147        .send()
148        .await
149        .map_err(fetch_failed)?
150        .error_for_status()
151        .map_err(fetch_failed)?;
152
153    let body = read_capped(res, config.max_document_bytes).await?;
154
155    let doc = serde_json::from_slice::<Document>(&body)
156        .map_err(|e| ResolutionError::ResolutionFailed(e.to_string()))?;
157
158    // Without this the host of `did:web:evil.com` can serve a document claiming
159    // to be any other DID, and callers keying off `doc.id` attribute the
160    // attacker's keys to that identifier.
161    if doc.id != did {
162        return Err(ResolutionError::DocumentMismatch);
163    }
164
165    Ok(doc)
166}
167
168/// Strips the URL from transport errors, which would otherwise let a caller use
169/// resolution failures to probe internal hosts.
170fn fetch_failed(e: reqwest::Error) -> ResolutionError {
171    ResolutionError::ResolutionFailed(e.without_url().to_string())
172}
173
174/// Rejects targets outside public unicast space before any connection is made.
175///
176/// The addresses are re-resolved by the connector, so a hostile resolver can
177/// still return a public address here and a private one there. Closing that
178/// race needs a custom connector that checks the peer at connect time.
179#[cfg(not(target_family = "wasm"))]
180async fn check_target(url: &Url) -> Result<(), ResolutionError> {
181    use std::net::IpAddr;
182
183    let host = url.host_str().ok_or(ResolutionError::InvalidDid)?;
184    let port = url.port_or_known_default().unwrap_or(443);
185
186    let bare = host.trim_start_matches('[').trim_end_matches(']');
187    let addrs = if let Ok(ip) = bare.parse::<IpAddr>() {
188        vec![ip]
189    } else {
190        tokio::net::lookup_host((host, port))
191            .await
192            .map_err(|e| ResolutionError::ResolutionFailed(e.to_string()))?
193            .map(|addr| addr.ip())
194            .collect()
195    };
196
197    if addrs.is_empty() || addrs.iter().copied().any(policy::is_restricted) {
198        return Err(ResolutionError::TargetNotAllowed);
199    }
200
201    Ok(())
202}
203
204#[cfg(target_family = "wasm")]
205async fn check_target(_url: &Url) -> Result<(), ResolutionError> {
206    Ok(())
207}
208
209#[cfg(not(target_family = "wasm"))]
210async fn read_capped(mut res: Response, max: u64) -> Result<Vec<u8>, ResolutionError> {
211    if res.content_length().is_some_and(|len| len > max) {
212        return Err(ResolutionError::DocumentTooLarge);
213    }
214
215    let cap = res.content_length().unwrap_or(0).min(max);
216    let mut buf = Vec::with_capacity(usize::try_from(cap).unwrap_or(0));
217
218    while let Some(chunk) = res.chunk().await.map_err(fetch_failed)? {
219        if buf.len() as u64 + chunk.len() as u64 > max {
220            return Err(ResolutionError::DocumentTooLarge);
221        }
222        buf.extend_from_slice(&chunk);
223    }
224
225    Ok(buf)
226}
227
228// The browser has already buffered the body, so the cap can only be enforced
229// after the fact.
230#[cfg(target_family = "wasm")]
231async fn read_capped(res: Response, max: u64) -> Result<Vec<u8>, ResolutionError> {
232    if res.content_length().is_some_and(|len| len > max) {
233        return Err(ResolutionError::DocumentTooLarge);
234    }
235
236    let body = res.bytes().await.map_err(fetch_failed)?;
237    if body.len() as u64 > max {
238        return Err(ResolutionError::DocumentTooLarge);
239    }
240
241    Ok(body.to_vec())
242}