1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
35use std::sync::Arc;
36
37use thiserror::Error;
38
39#[derive(Debug, Error)]
41pub enum SsrfError {
42 #[error("invalid URL: {0}")]
43 InvalidUrl(String),
44
45 #[error("URL scheme '{0}' not allowed — only http/https")]
46 DisallowedScheme(String),
47
48 #[error("URL has no host component")]
49 MissingHost,
50
51 #[error("DNS resolution failed for '{host}': {source}")]
52 DnsResolutionFailed {
53 host: String,
54 #[source]
55 source: std::io::Error,
56 },
57
58 #[error("DNS resolution returned no addresses for '{0}'")]
59 NoAddressesResolved(String),
60
61 #[error(
62 "target '{host}' resolves to {ip} which is in a blocked range ({reason}); \
63 pointing the cloud runner at internal addresses is not allowed"
64 )]
65 BlockedAddress {
66 host: String,
67 ip: IpAddr,
68 reason: &'static str,
69 },
70}
71
72#[derive(Debug, Clone, Copy, Default)]
75pub struct Policy {
76 pub allow_loopback: bool,
80}
81
82impl Policy {
83 pub const fn strict() -> Self {
85 Self {
86 allow_loopback: false,
87 }
88 }
89
90 pub const fn for_test() -> Self {
93 Self {
94 allow_loopback: true,
95 }
96 }
97}
98
99pub async fn validate_target_url(url: &str, policy: Policy) -> Result<(), SsrfError> {
105 let parsed = url::Url::parse(url).map_err(|e| SsrfError::InvalidUrl(e.to_string()))?;
106
107 let scheme = parsed.scheme();
108 if scheme != "http" && scheme != "https" {
109 return Err(SsrfError::DisallowedScheme(scheme.to_string()));
110 }
111
112 let host = parsed.host_str().ok_or(SsrfError::MissingHost)?.to_string();
113 let port = parsed.port_or_known_default().unwrap_or(80);
114
115 if let Ok(ip) = host.parse::<IpAddr>() {
117 check_ip(&host, ip, policy)?;
118 return Ok(());
119 }
120
121 let lookup_target = format!("{}:{}", host, port);
126 let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&lookup_target)
127 .await
128 .map_err(|source| SsrfError::DnsResolutionFailed {
129 host: host.clone(),
130 source,
131 })?
132 .collect();
133
134 if addrs.is_empty() {
135 return Err(SsrfError::NoAddressesResolved(host));
136 }
137
138 for addr in addrs {
139 check_ip(&host, addr.ip(), policy)?;
140 }
141
142 Ok(())
143}
144
145pub fn check_url(url: &url::Url, policy: Policy) -> Result<(), SsrfError> {
153 let scheme = url.scheme();
154 if scheme != "http" && scheme != "https" {
155 return Err(SsrfError::DisallowedScheme(scheme.to_string()));
156 }
157 match url.host() {
158 None => Err(SsrfError::MissingHost),
159 Some(url::Host::Ipv4(ip)) => check_ip(&ip.to_string(), IpAddr::V4(ip), policy),
160 Some(url::Host::Ipv6(ip)) => check_ip(&ip.to_string(), IpAddr::V6(ip), policy),
161 Some(url::Host::Domain(_)) => Ok(()),
162 }
163}
164
165static PROCESS_GUARD: std::sync::OnceLock<Policy> = std::sync::OnceLock::new();
166
167pub fn install_process_guard(policy: Policy) -> bool {
176 PROCESS_GUARD.set(policy).is_ok()
177}
178
179pub fn process_guard() -> Option<Policy> {
181 PROCESS_GUARD.get().copied()
182}
183
184pub fn client_builder_for(guard: Option<Policy>) -> reqwest::ClientBuilder {
187 match guard {
188 Some(policy) => guarded_client_builder(policy),
189 None => reqwest::Client::builder(),
190 }
191}
192
193#[derive(Debug, Clone, Copy)]
198pub struct GuardedResolver {
199 policy: Policy,
200}
201
202impl GuardedResolver {
203 pub const fn new(policy: Policy) -> Self {
205 Self { policy }
206 }
207}
208
209impl reqwest::dns::Resolve for GuardedResolver {
210 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
211 let policy = self.policy;
212 let host = name.as_str().to_string();
213 Box::pin(async move {
214 let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
215 .await
216 .map_err(|source| SsrfError::DnsResolutionFailed {
217 host: host.clone(),
218 source,
219 })?
220 .collect();
221 if addrs.is_empty() {
222 return Err(SsrfError::NoAddressesResolved(host).into());
223 }
224 for addr in &addrs {
225 check_ip(&host, addr.ip(), policy)?;
226 }
227 let addrs: reqwest::dns::Addrs = Box::new(addrs.into_iter());
228 Ok(addrs)
229 })
230 }
231}
232
233pub fn guarded_redirect_policy(policy: Policy, max: usize) -> reqwest::redirect::Policy {
237 reqwest::redirect::Policy::custom(move |attempt| {
238 if attempt.previous().len() >= max {
239 return attempt.error(format!("more than {max} redirects"));
240 }
241 match check_url(attempt.url(), policy) {
242 Ok(()) => attempt.follow(),
243 Err(e) => attempt.error(e),
244 }
245 })
246}
247
248pub fn guarded_client_builder(policy: Policy) -> reqwest::ClientBuilder {
255 reqwest::Client::builder()
256 .no_proxy()
257 .dns_resolver(Arc::new(GuardedResolver::new(policy)))
258 .redirect(guarded_redirect_policy(policy, 10))
259}
260
261fn check_ip(host: &str, ip: IpAddr, policy: Policy) -> Result<(), SsrfError> {
262 if let Some(reason) = blocked_reason(ip, policy) {
263 return Err(SsrfError::BlockedAddress {
264 host: host.to_string(),
265 ip,
266 reason,
267 });
268 }
269 Ok(())
270}
271
272fn blocked_reason(ip: IpAddr, policy: Policy) -> Option<&'static str> {
273 match ip {
274 IpAddr::V4(v4) => blocked_reason_v4(v4, policy),
275 IpAddr::V6(v6) => blocked_reason_v6(v6, policy),
276 }
277}
278
279fn blocked_reason_v4(ip: Ipv4Addr, policy: Policy) -> Option<&'static str> {
280 if ip.is_loopback() {
281 if policy.allow_loopback {
282 return None;
283 }
284 return Some("IPv4 loopback (127.0.0.0/8)");
285 }
286 if ip.octets()[0] == 0 {
287 return Some("IPv4 \"this network\" (0.0.0.0/8)");
288 }
289 if ip.is_broadcast() {
290 return Some("IPv4 broadcast");
291 }
292 if ip.is_link_local() {
293 return Some("IPv4 link-local (169.254.0.0/16, includes cloud metadata IP)");
294 }
295 if ip.is_private() {
296 return Some("IPv4 RFC1918 private (10/8, 172.16/12, 192.168/16)");
297 }
298 if ip.is_documentation() {
299 return Some("IPv4 documentation range (RFC5737)");
300 }
301 let octets = ip.octets();
305 if octets[0] == 100 && (64..=127).contains(&octets[1]) {
306 return Some("IPv4 CGNAT (100.64.0.0/10)");
307 }
308 if octets[0] == 198 && (octets[1] == 18 || octets[1] == 19) {
310 return Some("IPv4 benchmark (198.18.0.0/15)");
311 }
312 None
313}
314
315fn blocked_reason_v6(ip: Ipv6Addr, policy: Policy) -> Option<&'static str> {
316 if ip.is_loopback() {
317 if policy.allow_loopback {
318 return None;
319 }
320 return Some("IPv6 loopback (::1)");
321 }
322 if ip.is_unspecified() {
323 return Some("IPv6 unspecified (::)");
324 }
325 let segments = ip.segments();
326 if (segments[0] & 0xffc0) == 0xfe80 {
328 return Some("IPv6 link-local (fe80::/10)");
329 }
330 if (segments[0] & 0xfe00) == 0xfc00 {
332 return Some("IPv6 unique-local (fc00::/7)");
333 }
334 if let Some(v4) = ip.to_ipv4_mapped() {
337 return blocked_reason_v4(v4, policy);
338 }
339 let [a, b, c, d, e, f, g, h] = segments;
340 let embedded = Ipv4Addr::new((g >> 8) as u8, g as u8, (h >> 8) as u8, h as u8);
341 if [a, b, c, d, e, f] == [0x64, 0xff9b, 0, 0, 0, 0] {
345 return blocked_reason_v4(embedded, policy);
346 }
347 if [a, b, c] == [0x64, 0xff9b, 1] {
350 return Some("IPv6 NAT64 local-use (64:ff9b:1::/48)");
351 }
352 if [a, b, c, d, e, f] == [0; 6] {
355 return blocked_reason_v4(embedded, policy);
356 }
357 None
358}
359
360#[cfg(test)]
362pub(crate) mod test_server {
363 use std::sync::atomic::{AtomicUsize, Ordering};
364 use std::sync::Arc;
365
366 pub(crate) const OK: &str =
367 "HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok";
368
369 pub(crate) fn redirect_to(location: &str) -> &'static str {
371 Box::leak(
372 format!(
373 "HTTP/1.1 302 Found\r\nlocation: {location}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
374 )
375 .into_boxed_str(),
376 )
377 }
378
379 pub(crate) async fn serve(response: &'static str) -> (u16, Arc<AtomicUsize>) {
382 use tokio::io::{AsyncReadExt, AsyncWriteExt};
383 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
384 let port = listener.local_addr().unwrap().port();
385 let hits = Arc::new(AtomicUsize::new(0));
386 let counter = hits.clone();
387 tokio::spawn(async move {
388 while let Ok((mut sock, _)) = listener.accept().await {
389 let mut buf = [0u8; 1024];
390 if matches!(sock.read(&mut buf).await, Ok(n) if n > 0) {
391 counter.fetch_add(1, Ordering::SeqCst);
392 }
393 let _ = sock.write_all(response.as_bytes()).await;
394 }
395 });
396 (port, hits)
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn assert_blocked(addr: &str, policy: Policy, fragment: &str) {
405 let ip: IpAddr = addr.parse().unwrap();
406 let reason =
407 blocked_reason(ip, policy).unwrap_or_else(|| panic!("expected {addr} to be blocked"));
408 assert!(
409 reason.contains(fragment),
410 "{addr} blocked but reason '{reason}' missing fragment '{fragment}'"
411 );
412 }
413
414 fn assert_allowed(addr: &str, policy: Policy) {
415 let ip: IpAddr = addr.parse().unwrap();
416 assert!(blocked_reason(ip, policy).is_none(), "{addr} unexpectedly blocked");
417 }
418
419 #[test]
420 fn blocks_loopback_v4_strict() {
421 assert_blocked("127.0.0.1", Policy::strict(), "loopback");
422 assert_blocked("127.255.255.254", Policy::strict(), "loopback");
423 }
424
425 #[test]
426 fn allows_loopback_v4_in_test_policy() {
427 assert_allowed("127.0.0.1", Policy::for_test());
428 }
429
430 #[test]
431 fn blocks_link_local_aws_metadata() {
432 assert_blocked("169.254.169.254", Policy::strict(), "link-local");
433 }
434
435 #[test]
436 fn blocks_rfc1918_ranges() {
437 assert_blocked("10.0.0.1", Policy::strict(), "RFC1918");
438 assert_blocked("172.16.0.1", Policy::strict(), "RFC1918");
439 assert_blocked("172.31.255.255", Policy::strict(), "RFC1918");
440 assert_blocked("192.168.0.1", Policy::strict(), "RFC1918");
441 }
442
443 #[test]
444 fn blocks_cgnat() {
445 assert_blocked("100.64.0.1", Policy::strict(), "CGNAT");
446 assert_blocked("100.127.255.255", Policy::strict(), "CGNAT");
447 }
448
449 #[test]
450 fn allows_ranges_outside_cgnat() {
451 assert_allowed("100.63.255.255", Policy::strict());
453 assert_allowed("100.128.0.1", Policy::strict());
454 }
455
456 #[test]
457 fn blocks_benchmark_range() {
458 assert_blocked("198.18.0.1", Policy::strict(), "benchmark");
459 assert_blocked("198.19.255.255", Policy::strict(), "benchmark");
460 }
461
462 #[test]
463 fn allows_public_v4() {
464 assert_allowed("8.8.8.8", Policy::strict());
465 assert_allowed("1.1.1.1", Policy::strict());
466 assert_allowed("142.250.190.78", Policy::strict()); }
468
469 #[test]
470 fn blocks_loopback_v6_strict() {
471 assert_blocked("::1", Policy::strict(), "loopback");
472 }
473
474 #[test]
475 fn blocks_link_local_v6() {
476 assert_blocked("fe80::1", Policy::strict(), "link-local");
477 assert_blocked("febf::1", Policy::strict(), "link-local");
478 }
479
480 #[test]
481 fn blocks_ula() {
482 assert_blocked("fc00::1", Policy::strict(), "unique-local");
483 assert_blocked("fd12:3456::1", Policy::strict(), "unique-local");
484 }
485
486 #[test]
487 fn blocks_ipv4_mapped_private() {
488 assert_blocked("::ffff:10.0.0.1", Policy::strict(), "RFC1918");
489 assert_blocked("::ffff:127.0.0.1", Policy::strict(), "loopback");
490 }
491
492 #[test]
493 fn allows_public_v6() {
494 assert_allowed("2606:4700:4700::1111", Policy::strict()); assert_allowed("2001:4860:4860::8888", Policy::strict()); }
497
498 #[tokio::test]
499 async fn validate_rejects_non_http_scheme() {
500 let err = validate_target_url("file:///etc/passwd", Policy::strict()).await.unwrap_err();
501 assert!(matches!(err, SsrfError::DisallowedScheme(s) if s == "file"));
502 }
503
504 #[tokio::test]
505 async fn validate_rejects_garbage_url() {
506 let err = validate_target_url("not a url", Policy::strict()).await.unwrap_err();
507 assert!(matches!(err, SsrfError::InvalidUrl(_)));
508 }
509
510 #[tokio::test]
511 async fn validate_rejects_literal_loopback() {
512 let err = validate_target_url("http://127.0.0.1/", Policy::strict()).await.unwrap_err();
513 assert!(matches!(err, SsrfError::BlockedAddress { .. }));
514 }
515
516 #[tokio::test]
517 async fn validate_rejects_literal_metadata_ip() {
518 let err = validate_target_url("http://169.254.169.254/latest/meta-data/", Policy::strict())
519 .await
520 .unwrap_err();
521 match err {
522 SsrfError::BlockedAddress { reason, .. } => assert!(reason.contains("link-local")),
523 other => panic!("expected BlockedAddress, got {other:?}"),
524 }
525 }
526
527 #[tokio::test]
528 async fn validate_rejects_literal_rfc1918() {
529 let err = validate_target_url("http://10.0.0.1/", Policy::strict()).await.unwrap_err();
530 assert!(matches!(err, SsrfError::BlockedAddress { .. }));
531 }
532
533 #[tokio::test]
534 async fn validate_allows_loopback_in_test_policy() {
535 validate_target_url("http://127.0.0.1:8080/", Policy::for_test()).await.unwrap();
536 }
537
538 fn url(s: &str) -> url::Url {
539 url::Url::parse(s).unwrap()
540 }
541
542 #[test]
543 fn check_url_blocks_literal_ips_in_every_spelling() {
544 for u in [
545 "http://127.0.0.1/",
546 "http://2130706433/",
547 "http://0x7f000001/",
548 "http://10.0.0.1:6379/",
549 "http://[::1]/",
550 "http://[::ffff:172.18.0.2]/",
551 "http://169.254.169.254/latest/meta-data/",
552 "http://example.com@10.0.0.1/",
553 ] {
554 let err = check_url(&url(u), Policy::strict()).unwrap_err();
555 assert!(matches!(err, SsrfError::BlockedAddress { .. }), "{u}: {err:?}");
556 }
557 }
558
559 #[test]
560 fn check_url_rejects_other_schemes_and_defers_hostnames_to_the_resolver() {
561 let err = check_url(&url("file:///etc/passwd"), Policy::strict()).unwrap_err();
562 assert!(matches!(err, SsrfError::DisallowedScheme(_)));
563 check_url(&url("https://example.com/"), Policy::strict()).unwrap();
564 check_url(&url("http://127.0.0.1:3000/"), Policy::for_test()).unwrap();
565 }
566
567 async fn serve(response: &'static str) -> u16 {
568 test_server::serve(response).await.0
569 }
570
571 use test_server::OK;
572
573 #[tokio::test]
574 async fn guarded_client_refuses_a_name_resolving_to_loopback() {
575 let port = serve(OK).await;
576 let client = guarded_client_builder(Policy::strict()).build().unwrap();
577 let err = client.get(format!("http://localhost:{port}/")).send().await.unwrap_err();
578 assert!(err.is_connect(), "{err:?}");
579
580 let relaxed = guarded_client_builder(Policy::for_test()).build().unwrap();
581 let resp = relaxed.get(format!("http://localhost:{port}/")).send().await.unwrap();
582 assert_eq!(resp.status(), 200);
583 }
584
585 #[tokio::test]
586 async fn guarded_client_refuses_a_redirect_to_a_private_address() {
587 use std::sync::atomic::Ordering;
588 let (inner, hits) = test_server::serve(OK).await;
589 for location in [
590 "http://10.0.0.1/".to_string(),
591 format!("http://0.0.0.0:{inner}/"),
592 ] {
593 let port = serve(test_server::redirect_to(&location)).await;
594 let client = guarded_client_builder(Policy::for_test()).build().unwrap();
596 let err = client.get(format!("http://127.0.0.1:{port}/")).send().await.unwrap_err();
597 assert!(err.is_redirect(), "{location}: {err:?}");
598 }
599 assert_eq!(hits.load(Ordering::SeqCst), 0);
602 }
603
604 #[test]
605 fn embedded_v4_in_v6_is_checked() {
606 for addr in [
607 "64:ff9b::a00:1", "64:ff9b::7f00:1", "64:ff9b::a9fe:a9fe", "64:ff9b:1::a00:1", "::a00:1", "::ac12:5", "::7f00:1", ] {
615 let ip: IpAddr = addr.parse().unwrap();
616 assert!(blocked_reason(ip, Policy::strict()).is_some(), "{addr} allowed");
617 }
618 for addr in ["64:ff9b::808:808", "::808:808", "2606:4700::1"] {
619 let ip: IpAddr = addr.parse().unwrap();
620 assert!(blocked_reason(ip, Policy::strict()).is_none(), "{addr} blocked");
621 }
622 let err = check_url(&url("http://[64:ff9b::a00:1]/"), Policy::strict()).unwrap_err();
623 assert!(matches!(err, SsrfError::BlockedAddress { .. }));
624 }
625
626 #[test]
627 fn this_network_range_is_blocked() {
628 for addr in ["0.0.0.0", "0.1.2.3"] {
629 let ip: IpAddr = addr.parse().unwrap();
630 assert!(blocked_reason(ip, Policy::strict()).is_some(), "{addr} allowed");
631 }
632 }
633}