1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
25
26#[derive(Debug, Clone, thiserror::Error)]
28pub enum SsrfError {
29 #[error("{0}")]
33 InvalidUrl(String),
34 #[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
51pub const PRIVATE_TLD_SUFFIXES: &[&str] =
55 &[".local", ".internal", ".lan", ".intranet", ".corp", ".home"];
56
57#[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#[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#[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#[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
115pub 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 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
196pub 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", "100.64.0.1",
261 "100.127.255.255",
262 "0.5.5.5", "224.0.0.1", "240.0.0.1", "255.255.255.254", "255.255.255.255", "::1",
268 "fe80::1",
269 "fc00::1",
270 "fd00::1",
271 "ff02::1", "::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", "64:ff9b::7f00:1", "2002::1", "2001::1", ] {
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 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}