Skip to main content

soma_auth/cimd/
ssrf.rs

1//! SSRF preflight guard for CIMD `client_id` URL fetches.
2//!
3//! This is a *static* preflight — it does not perform DNS resolution. It
4//! rejects non-https schemes, userinfo, query/fragment components, a
5//! missing or root-only path, private-TLD-suffixed hostnames, textual
6//! loopback hostnames, and IP-literal hosts that fall in a private/
7//! loopback/link-local/CGNAT/ULA/transition-mechanism/multicast range.
8//! Callers that resolve a domain name to an IP address MUST additionally
9//! run each resolved address through [`check_ip_not_private`] before
10//! connecting, and MUST re-validate the actual TCP peer post-connect (see
11//! `cimd::document::resolve_and_validate_address` and
12//! `cimd::document::fetch_document_at`) — this module alone does not close
13//! the DNS-rebinding/proxy-interception gap by itself.
14//!
15//! Adapted (not imported — this crate has no path dependency on the
16//! sibling `lab` repo) from the equivalent guard in
17//! `labby-primitives::ssrf` and its caller,
18//! `labby-apis::acp_registry::installer`, which additionally documents and
19//! implements post-connect peer re-validation as "the load-bearing line of
20//! the SSRF TOCTOU / DNS-rebinding defense" — that additional layer is
21//! implemented in `cimd::document`, not here (this module only covers the
22//! static/pre-DNS portion of the reference's rigor).
23
24use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
25
26/// Reason a CIMD `client_id` URL was rejected.
27#[derive(Debug, Clone, thiserror::Error)]
28pub enum SsrfError {
29    /// URL could not be parsed, used a non-https scheme, lacked a host,
30    /// carried forbidden userinfo/query/fragment, or lacked a path
31    /// component.
32    #[error("{0}")]
33    InvalidUrl(String),
34    /// Host resolved (or parsed) to a private/loopback/link-local/CGNAT/
35    /// ULA/transition-mechanism/multicast address, or matched a
36    /// private-TLD suffix denylist.
37    #[error("{0}")]
38    Blocked(String),
39}
40
41impl SsrfError {
42    #[must_use]
43    pub fn kind(&self) -> &'static str {
44        match self {
45            Self::InvalidUrl(_) => "invalid_param",
46            Self::Blocked(_) => "ssrf_blocked",
47        }
48    }
49}
50
51/// Private-DNS suffix denylist applied to non-IP hosts. Belt-and-suspenders
52/// on top of the resolved-IP checks (an internal name might resolve through
53/// split-horizon DNS to a public-looking record at validation time).
54pub const PRIVATE_TLD_SUFFIXES: &[&str] =
55    &[".local", ".internal", ".lan", ".intranet", ".corp", ".home"];
56
57/// Returns `true` for the IPv4 carrier-grade NAT range `100.64.0.0/10`.
58#[must_use]
59pub fn is_cgnat(ip: Ipv4Addr) -> bool {
60    let octets = ip.octets();
61    octets[0] == 100 && (64..=127).contains(&octets[1])
62}
63
64/// Returns `true` for `0.0.0.0/8` ("this network", broader than the single
65/// unspecified address), IPv4 multicast (`224.0.0.0/4`), Class E reserved
66/// space (`240.0.0.0/4`, RFC 1112 §4 — `Ipv4Addr::is_reserved` covers this
67/// but is unstable, so the range check is inlined here), and the limited
68/// broadcast address `255.255.255.255`.
69#[must_use]
70pub fn is_ipv4_reserved_broadcast_or_multicast(ip: Ipv4Addr) -> bool {
71    ip.octets()[0] == 0 || ip.is_multicast() || ip.is_broadcast() || (ip.octets()[0] & 0xf0) == 0xf0
72}
73
74fn is_ipv6_link_local(ip: Ipv6Addr) -> bool {
75    (ip.segments()[0] & 0xffc0) == 0xfe80
76}
77
78fn is_ipv6_ula(ip: Ipv6Addr) -> bool {
79    (ip.segments()[0] & 0xfe00) == 0xfc00
80}
81
82/// Returns `true` for the deprecated IPv4-compatible IPv6 form (`::a.b.c.d`,
83/// distinct from the IPv4-*mapped* `::ffff:a.b.c.d` form already handled
84/// separately via `to_ipv4_mapped()`). Some stacks still route this form;
85/// treat any non-loopback, non-unspecified address here as embedding a
86/// target IPv4 address this guard has not yet validated.
87#[must_use]
88pub fn is_ipv6_ipv4_compatible(ip: Ipv6Addr) -> bool {
89    let s = ip.segments();
90    s[0] == 0
91        && s[1] == 0
92        && s[2] == 0
93        && s[3] == 0
94        && s[4] == 0
95        && s[5] == 0
96        && !ip.is_loopback()
97        && !ip.is_unspecified()
98}
99
100/// Returns `true` for IPv6 transition-mechanism prefixes that embed an
101/// IPv4 address reachable through the mechanism — NAT64 (`64:ff9b::/96`),
102/// 6to4 (`2002::/16`), and Teredo (`2001::/32`). These are more credible
103/// SSRF bypasses than IPv6 documentation ranges and are blocked wholesale
104/// (not selectively unwrapped) since this is a conservative preflight for
105/// an OAuth Authorization Server fetching an attacker-supplied URL, not a
106/// general-purpose network client that needs transition-mechanism support.
107#[must_use]
108pub fn is_ipv6_transition_mechanism(ip: Ipv6Addr) -> bool {
109    let s = ip.segments();
110    (s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 0 && s[3] == 0 && s[4] == 0 && s[5] == 0)
111        || s[0] == 0x2002
112        || (s[0] == 0x2001 && s[1] == 0)
113}
114
115/// Reject an IP that targets private, loopback, link-local, CGNAT, ULA,
116/// IPv4-mapped-private, IPv4-compatible, transition-mechanism, reserved,
117/// multicast, or broadcast space. `context` is a non-secret label (redacted
118/// URL or bare host) used only to build the error message.
119///
120/// # Errors
121/// Returns [`SsrfError::Blocked`] when `ip` falls in any blocked range.
122pub fn check_ip_not_private(ip: IpAddr, context: &str) -> Result<(), SsrfError> {
123    let normalized = match ip {
124        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
125            Some(v4) => IpAddr::V4(v4),
126            None => IpAddr::V6(v6),
127        },
128        other => other,
129    };
130
131    let blocked = match normalized {
132        IpAddr::V4(v4) => {
133            v4.is_private()
134                || v4.is_loopback()
135                || v4.is_link_local()
136                || v4.is_unspecified()
137                || is_cgnat(v4)
138                || is_ipv4_reserved_broadcast_or_multicast(v4)
139        }
140        IpAddr::V6(v6) => {
141            v6.is_loopback()
142                || v6.is_unspecified()
143                || v6.is_multicast()
144                || is_ipv6_link_local(v6)
145                || is_ipv6_ula(v6)
146                || is_ipv6_ipv4_compatible(v6)
147                || is_ipv6_transition_mechanism(v6)
148        }
149    };
150
151    if blocked {
152        return Err(SsrfError::Blocked(format!(
153            "`{context}` resolves to a private, loopback, link-local, CGNAT, ULA, transition-mechanism, reserved, multicast, or broadcast address {ip}; blocked to prevent SSRF"
154        )));
155    }
156
157    Ok(())
158}
159
160fn check_host_not_private(host: &str) -> Result<(), SsrfError> {
161    let host_lower = host.to_ascii_lowercase();
162    let host_lower = host_lower.strip_suffix('.').unwrap_or(&host_lower);
163    if host_lower == "localhost"
164        || host_lower.starts_with("127.")
165        || host_lower == "::1"
166        || host_lower.contains("::ffff:")
167        || host_lower == "0.0.0.0"
168        || PRIVATE_TLD_SUFFIXES.iter().any(|s| host_lower.ends_with(s))
169    {
170        return Err(SsrfError::Blocked(format!(
171            "host `{host}` is a local/loopback/private address"
172        )));
173    }
174    Ok(())
175}
176
177fn redact_url(raw: &str) -> String {
178    match url::Url::parse(raw) {
179        Ok(mut url) => {
180            // `set_username`/`set_password` return `Err(())` for URLs that
181            // "cannot be a base" (e.g. non-hierarchical schemes). This
182            // function exists solely to produce a safe-to-log string, so a
183            // redaction step that can't be confirmed to have removed
184            // userinfo must not silently emit the original unredacted URL.
185            if url.set_username("").is_err() || url.set_password(None).is_err() {
186                return "<url-with-unredactable-userinfo>".to_string();
187            }
188            url.set_query(None);
189            url.set_fragment(None);
190            url.to_string()
191        }
192        Err(_) => "<invalid-url>".to_string(),
193    }
194}
195
196/// Parse and statically validate a CIMD `client_id` URL: require https,
197/// forbid userinfo/query/fragment, require a non-root path component,
198/// require a host, and reject the private-TLD/loopback host denylist. If
199/// the host is an IP literal it is additionally run through
200/// [`check_ip_not_private`].
201///
202/// This performs **no DNS** — see the module doc for what callers must do
203/// after resolving a domain-name host.
204///
205/// # Errors
206/// Returns [`SsrfError`] when any static rule is violated.
207pub fn validate_url_shape(url: &str) -> Result<url::Url, SsrfError> {
208    let redacted = redact_url(url);
209    let parsed = url::Url::parse(url)
210        .map_err(|e| SsrfError::InvalidUrl(format!("invalid URL `{redacted}`: {e}")))?;
211
212    if parsed.scheme() != "https" {
213        return Err(SsrfError::InvalidUrl(format!(
214            "URL `{redacted}` must use https to prevent SSRF"
215        )));
216    }
217    if !parsed.username().is_empty() || parsed.password().is_some() {
218        return Err(SsrfError::InvalidUrl(format!(
219            "URL `{redacted}` must not include userinfo"
220        )));
221    }
222    if parsed.query().is_some() || parsed.fragment().is_some() {
223        return Err(SsrfError::InvalidUrl(format!(
224            "URL `{redacted}` must not include query or fragment components"
225        )));
226    }
227    if parsed.path().is_empty() || parsed.path() == "/" {
228        return Err(SsrfError::InvalidUrl(format!(
229            "URL `{redacted}` must contain a path component (see docs/references/mcp/client-id-metadata-document.md)"
230        )));
231    }
232
233    match parsed.host() {
234        Some(url::Host::Domain(domain)) => check_host_not_private(domain)?,
235        Some(url::Host::Ipv4(ip)) => check_ip_not_private(IpAddr::V4(ip), &redacted)?,
236        Some(url::Host::Ipv6(ip)) => check_ip_not_private(IpAddr::V6(ip), &redacted)?,
237        None => {
238            return Err(SsrfError::InvalidUrl(format!(
239                "URL `{redacted}` must include a host"
240            )));
241        }
242    }
243
244    Ok(parsed)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn blocks_private_ranges_exactly() {
253        for ip in [
254            "127.0.0.1",
255            "10.1.2.3",
256            "172.16.0.1",
257            "192.168.1.1",
258            "169.254.1.1",
259            "169.254.169.254", // cloud metadata service
260            "100.64.0.1",
261            "100.127.255.255",
262            "0.5.5.5",         // 0.0.0.0/8 "this network"
263            "224.0.0.1",       // multicast
264            "240.0.0.1",       // Class E reserved
265            "255.255.255.254", // Class E reserved (top of range, below broadcast)
266            "255.255.255.255", // limited broadcast
267            "::1",
268            "fe80::1",
269            "fc00::1",
270            "fd00::1",
271            "ff02::1", // IPv6 multicast
272            "::ffff:127.0.0.1",
273            "::ffff:10.1.2.3",
274            "::ffff:100.64.0.1",
275            "::ffff:169.254.169.254",
276            "::7f00:1",        // IPv4-compatible IPv6 form of 127.0.0.1
277            "64:ff9b::7f00:1", // NAT64-embedded loopback
278            "2002::1",         // 6to4
279            "2001::1",         // Teredo
280        ] {
281            let parsed: IpAddr = ip.parse().expect(ip);
282            let err = check_ip_not_private(parsed, "app.example.com").unwrap_err();
283            assert_eq!(err.kind(), "ssrf_blocked", "{ip}");
284        }
285    }
286
287    #[test]
288    fn allows_public_addresses() {
289        for ip in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] {
290            let parsed: IpAddr = ip.parse().expect(ip);
291            check_ip_not_private(parsed, "app.example.com").expect(ip);
292        }
293    }
294
295    #[test]
296    fn rejects_non_https_as_invalid_param() {
297        let err =
298            validate_url_shape("http://app.example.com/oauth/client-metadata.json").unwrap_err();
299        assert_eq!(err.kind(), "invalid_param");
300    }
301
302    #[test]
303    fn rejects_missing_path_as_invalid_param() {
304        let err = validate_url_shape("https://app.example.com").unwrap_err();
305        assert_eq!(err.kind(), "invalid_param");
306        let err_root = validate_url_shape("https://app.example.com/").unwrap_err();
307        assert_eq!(err_root.kind(), "invalid_param");
308    }
309
310    #[test]
311    fn rejects_userinfo() {
312        let err = validate_url_shape("https://user@app.example.com/client.json").unwrap_err();
313        assert_eq!(err.kind(), "invalid_param");
314    }
315
316    #[test]
317    fn rejects_query_and_fragment() {
318        let err_query = validate_url_shape("https://app.example.com/client.json?x=1").unwrap_err();
319        assert_eq!(err_query.kind(), "invalid_param");
320        let err_fragment = validate_url_shape("https://app.example.com/client.json#x").unwrap_err();
321        assert_eq!(err_fragment.kind(), "invalid_param");
322    }
323
324    #[test]
325    fn rejects_private_and_loopback_hosts_as_blocked() {
326        for url in [
327            "https://app.local/client.json",
328            "https://127.0.0.1/client.json",
329            "https://[::ffff:127.0.0.1]/client.json",
330            "https://192.168.1.20/client.json",
331        ] {
332            let err = validate_url_shape(url).unwrap_err();
333            assert_eq!(err.kind(), "ssrf_blocked", "{url}");
334        }
335    }
336
337    #[test]
338    fn rejects_bracketed_ipv6_literals() {
339        for url in [
340            "https://[::1]/client.json",
341            "https://[fe80::1]/client.json",
342            "https://[fc00::1]/client.json",
343        ] {
344            let err = validate_url_shape(url).unwrap_err();
345            assert_eq!(err.kind(), "ssrf_blocked", "{url}");
346        }
347    }
348
349    #[test]
350    fn private_tld_suffixes_are_blocked() {
351        for host_url in [
352            "https://box.local/c.json",
353            "https://svc.internal/c.json",
354            "https://host.lan/c.json",
355        ] {
356            let err = validate_url_shape(host_url).unwrap_err();
357            assert_eq!(err.kind(), "ssrf_blocked", "{host_url}");
358        }
359    }
360
361    #[test]
362    fn private_tld_suffix_bypass_via_trailing_dot_is_blocked() {
363        // "svc.internal." (FQDN trailing dot) resolves identically to
364        // "svc.internal" and must not bypass the suffix denylist.
365        let err = validate_url_shape("https://svc.internal./c.json").unwrap_err();
366        assert_eq!(err.kind(), "ssrf_blocked");
367    }
368
369    #[test]
370    fn allows_valid_public_https_url_with_path() {
371        let parsed = validate_url_shape("https://app.example.com/oauth/client-metadata.json")
372            .expect("should validate");
373        assert_eq!(parsed.host_str(), Some("app.example.com"));
374    }
375
376    #[test]
377    fn redact_url_strips_userinfo_query_and_fragment() {
378        let redacted = redact_url("https://user:pass@app.example.com/path?token=secret#frag");
379        assert!(!redacted.contains("user"), "{redacted}");
380        assert!(!redacted.contains("pass"), "{redacted}");
381        assert!(!redacted.contains("token=secret"), "{redacted}");
382        assert!(!redacted.contains("frag"), "{redacted}");
383        assert!(redacted.contains("app.example.com"), "{redacted}");
384        assert!(redacted.contains("/path"), "{redacted}");
385    }
386
387    #[test]
388    fn redact_url_reports_a_placeholder_for_an_unparseable_url() {
389        assert_eq!(redact_url("not a url"), "<invalid-url>");
390    }
391
392    #[test]
393    fn rejects_userinfo_without_leaking_credentials_in_the_error() {
394        let err =
395            validate_url_shape("https://secretuser:secretpass@app.example.com/c.json").unwrap_err();
396        let message = err.to_string();
397        assert!(!message.contains("secretuser"), "{message}");
398        assert!(!message.contains("secretpass"), "{message}");
399    }
400}