返回 CodeWhale
guard.rs
根目录 / crates / tui / src / tools / web / guard.rs
1 //! Shared SSRF guard for LLM-initiated HTTP fetches (`fetch_url`, `web.run`).
2 //!
3 //! Validates scheme/host, enforces network policy, resolves DNS and rejects
4 //! private/loopback/link-local/metadata addresses, and returns an optional
5 //! DNS pin so callers can bind the HTTP client to the validated address
6 //! (preventing TOCTOU rebinding). Callers that follow redirects must
7 //! re-invoke [`validate_fetch_target`] on every new Location.
8
9 use crate::network_policy::{Decision, NetworkPolicyDecider};
10 use crate::tools::spec::{ToolContext, ToolError};
11 use std::net::IpAddr;
12 use std::time::Duration;
13
14 /// Wall-clock bound for one pre-flight DNS resolution. The resolution runs
15 /// before the guarded request (and once per redirect), so a hung resolver
16 /// would otherwise stall the tool far beyond the documented request timeout
17 /// envelope (`super::fetch::HARD_MAX_TIMEOUT`).
18 const DNS_PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(10);
19
20 /// DNS pin returned when a hostname was resolved to a validated public IP.
21 /// Callers should pass this to `reqwest::ClientBuilder::resolve` so the
22 /// connection uses the pre-validated address instead of re-resolving.
23 pub(crate) type DnsPin = Option<(String, IpAddr)>;
24
25 /// Build the transport used after a destination has passed SSRF validation.
26 /// Ambient HTTP(S)/SOCKS proxies are deliberately disabled: a proxy would
27 /// receive the original hostname, resolve it again outside this process, and
28 /// bypass the validated DNS pin.
29 pub(crate) fn guarded_reqwest_client_builder() -> reqwest::ClientBuilder {
30 crate::tls::reqwest_client_builder().no_proxy()
31 }
32
33 /// Check if an IP address is loopback, private, link-local, cloud-metadata,
34 /// multicast, or reserved — all addresses that should not be reachable via
35 /// an LLM-initiated fetch request (SSRF prevention).
36 pub(crate) fn is_restricted_ip(ip: &IpAddr) -> bool {
37 match ip {
38 IpAddr::V4(v4) => {
39 v4.is_loopback()
40 || v4.is_private()
41 || v4.is_link_local()
42 || v4.is_multicast()
43 || v4.is_broadcast()
44 || v4.is_unspecified()
45 // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)
46 || matches!(v4.octets(), [100, 64..=127, ..])
47 // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)
48 || *ip == IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))
49 // 198.18.0.0/15 — IETF benchmark testing
50 || matches!(v4.octets(), [198, 18..=19, ..])
51 // 240.0.0.0/4 — reserved (former Class E)
52 || v4.octets()[0] >= 240
53 }
54 IpAddr::V6(v6) => {
55 // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4
56 // to prevent bypass via ::ffff:127.0.0.1 etc.
57 if v6.is_unspecified()
58 || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])
59 {
60 return true;
61 }
62 if let Some(v4) = v6.to_ipv4_mapped() {
63 return is_restricted_ip(&IpAddr::V4(v4));
64 }
65 v6.is_loopback()
66 || v6.is_multicast()
67 || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7
68 || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10
69 }
70 }
71 }
72
73 /// Validate that `url` is a safe fetch target under SSRF and network policy.
74 ///
75 /// On success returns an optional DNS pin `(hostname, ip)` for hostnames that
76 /// were resolved; literal public IPs return `None` (no pin needed).
77 ///
78 /// `tool` is the policy/audit label (e.g. `"fetch_url"`, `"web_run"`).
79 pub(crate) async fn validate_fetch_target(
80 url: &reqwest::Url,
81 context: &ToolContext,
82 tool: &str,
83 ) -> Result<DnsPin, ToolError> {
84 if url.scheme() != "http" && url.scheme() != "https" {
85 return Err(ToolError::invalid_input(
86 "only http:// and https:// URLs are supported",
87 ));
88 }
89
90 let host = url
91 .host_str()
92 .map(str::to_ascii_lowercase)
93 .ok_or_else(|| ToolError::invalid_input("URL must include a host"))?;
94
95 validate_network_policy(&host, context, tool)?;
96
97 // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.
98 // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),
99 // localhost services, and internal networks.
100 if host == "localhost" || host == "localhost.localdomain" {
101 return Err(ToolError::permission_denied(
102 "requests to localhost are not allowed",
103 ));
104 }
105 // Normalize bracketed IPv6 literals before the literal-IP check so they
106 // route through the same restricted-IP policy as unbracketed forms
107 // (GHSA-88gh-2526-gfrr).
108 let ip_candidate = host
109 .strip_prefix('[')
110 .and_then(|s| s.strip_suffix(']'))
111 .unwrap_or(host.as_str());
112 if let Ok(ip) = ip_candidate.parse::<IpAddr>() {
113 if is_restricted_ip(&ip) {
114 return Err(ToolError::permission_denied(format!(
115 "IP {ip} is a restricted address (private/loopback/link-local)"
116 )));
117 }
118 return Ok(None);
119 }
120
121 let addrs = tokio::time::timeout(
122 DNS_PREFLIGHT_TIMEOUT,
123 tokio::net::lookup_host((host.as_str(), 0u16)),
124 )
125 .await
126 .map_err(|_| {
127 ToolError::permission_denied(format!("timed out resolving host before {tool} request"))
128 })?
129 .map_err(|e| {
130 ToolError::permission_denied(format!("could not resolve host before {tool} request: {e}"))
131 })?;
132 let mut first_valid: Option<IpAddr> = None;
133 for addr in addrs {
134 validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref(), tool)?;
135 if first_valid.is_none() {
136 first_valid = Some(addr.ip());
137 }
138 }
139
140 let Some(validated_ip) = first_valid else {
141 return Err(ToolError::permission_denied(format!(
142 "host resolved to no addresses before {tool} request"
143 )));
144 };
145 Ok(Some((host, validated_ip)))
146 }
147
148 pub(crate) fn validate_network_policy(
149 host: &str,
150 context: &ToolContext,
151 tool: &str,
152 ) -> Result<(), ToolError> {
153 let Some(decider) = context.network_policy.as_ref() else {
154 return Ok(());
155 };
156
157 match decider.evaluate(host, tool) {
158 Decision::Allow => Ok(()),
159 Decision::Deny => Err(ToolError::permission_denied(format!(
160 "network call to '{host}' blocked by network policy"
161 ))),
162 Decision::Prompt => Err(ToolError::permission_denied(format!(
163 "network call to '{host}' requires approval; \
164 re-run after `/network allow {host}` or set network.default = \"allow\" in config"
165 ))),
166 }
167 }
168
169 pub(crate) fn validate_dns_resolved_ip(
170 host: &str,
171 ip: &IpAddr,
172 decider: Option<&NetworkPolicyDecider>,
173 tool: &str,
174 ) -> Result<(), ToolError> {
175 if !is_restricted_ip(ip) {
176 return Ok(());
177 }
178
179 // A fake-IP exception requires both an explicitly trusted hostname and an
180 // explicitly trusted placeholder CIDR. The CIDR parser admits only subnets
181 // inside 198.18.0.0/15, so real private/loopback/link-local/metadata/ULA
182 // addresses remain blocked even when the hostname is trusted.
183 if let Some(decider) = decider
184 && decider.is_trusted_fakeip_addr(ip)
185 && decider.trusts_proxy_fakeip_host(host)
186 {
187 decider.record_trusted_proxy_fakeip_allow(host, tool);
188 return Ok(());
189 }
190
191 Err(ToolError::permission_denied(format!(
192 "resolved IP {ip} is a restricted address (private/loopback/link-local)"
193 )))
194 }
195
196 #[cfg(test)]
197 mod tests {
198 use super::*;
199 use crate::tools::spec::ToolContext;
200 #[cfg(not(windows))]
201 use std::io::{Read, Write};
202 #[cfg(not(windows))]
203 use std::net::{Ipv4Addr, SocketAddr, TcpListener};
204 use std::path::PathBuf;
205 #[cfg(not(windows))]
206 use std::process::Command;
207 #[cfg(not(windows))]
208 use std::sync::Arc;
209 #[cfg(not(windows))]
210 use std::sync::atomic::{AtomicBool, Ordering};
211 #[cfg(not(windows))]
212 use std::time::{Duration, Instant};
213
214 fn ctx() -> ToolContext {
215 ToolContext::new(PathBuf::from("."))
216 }
217
218 #[cfg(not(windows))]
219 #[derive(Clone, Copy, Debug)]
220 enum AmbientProxyKind {
221 Http,
222 HttpsConnect,
223 SocksRemoteDns,
224 }
225
226 #[cfg(not(windows))]
227 fn spawn_accept_probe(
228 stop: Arc<AtomicBool>,
229 respond_http: bool,
230 drain_http_headers: bool,
231 ) -> (u16, std::thread::JoinHandle<bool>) {
232 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind probe listener");
233 let port = listener.local_addr().expect("probe address").port();
234 listener
235 .set_nonblocking(true)
236 .expect("nonblocking probe listener");
237 let handle = std::thread::spawn(move || {
238 let deadline = Instant::now() + Duration::from_secs(5);
239 loop {
240 match listener.accept() {
241 Ok((mut stream, _)) => {
242 // Accepted sockets inherit nonblocking from the listener
243 // on several Unixes. Restore blocking mode before timed
244 // header reads so a not-yet-ready socket is not treated
245 // as a hard failure (WouldBlock / EAGAIN).
246 stream
247 .set_nonblocking(false)
248 .expect("blocking probe stream");
249 if drain_http_headers {
250 stream
251 .set_read_timeout(Some(Duration::from_secs(2)))
252 .expect("probe read timeout");
253 let mut request = Vec::new();
254 let mut chunk = [0_u8; 1024];
255 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
256 let read = match stream.read(&mut chunk) {
257 Ok(n) => n,
258 Err(err)
259 if matches!(
260 err.kind(),
261 std::io::ErrorKind::WouldBlock
262 | std::io::ErrorKind::TimedOut
263 | std::io::ErrorKind::Interrupted
264 ) =>
265 {
266 continue;
267 }
268 Err(err) => panic!("read probe request: {err}"),
269 };
270 if read == 0 {
271 break;
272 }
273 request.extend_from_slice(&chunk[..read]);
274 if Instant::now() >= deadline {
275 break;
276 }
277 }
278 }
279 if respond_http {
280 let _ = stream.write_all(
281 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
282 );
283 }
284 return true;
285 }
286 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {}
287 Err(err) => panic!("probe accept failed: {err}"),
288 }
289 if stop.load(Ordering::SeqCst) || Instant::now() >= deadline {
290 return false;
291 }
292 std::thread::sleep(Duration::from_millis(10));
293 }
294 });
295 (port, handle)
296 }
297
298 #[cfg(not(windows))]
299 fn run_ambient_proxy_probe(kind: AmbientProxyKind, guarded: bool) -> (bool, bool) {
300 let stop = Arc::new(AtomicBool::new(false));
301 let (target_port, target_handle) = spawn_accept_probe(
302 Arc::clone(&stop),
303 matches!(
304 kind,
305 AmbientProxyKind::Http | AmbientProxyKind::SocksRemoteDns
306 ),
307 matches!(
308 kind,
309 AmbientProxyKind::Http | AmbientProxyKind::SocksRemoteDns
310 ),
311 );
312 let (proxy_port, proxy_handle) = spawn_accept_probe(Arc::clone(&stop), true, false);
313
314 let mut command = Command::new(std::env::current_exe().expect("current test executable"));
315 command.args([
316 "--exact",
317 "tools::web::guard::tests::guarded_transport_proxy_probe_child",
318 "--ignored",
319 "--nocapture",
320 ]);
321 for key in [
322 "HTTP_PROXY",
323 "http_proxy",
324 "HTTPS_PROXY",
325 "https_proxy",
326 "ALL_PROXY",
327 "all_proxy",
328 "NO_PROXY",
329 "no_proxy",
330 "REQUEST_METHOD",
331 ] {
332 command.env_remove(key);
333 }
334 command
335 .env("CODEWHALE_PROXY_PROBE_CHILD", "1")
336 .env(
337 "CODEWHALE_PROXY_PROBE_GUARDED",
338 if guarded { "1" } else { "0" },
339 )
340 .env("CODEWHALE_PROXY_PROBE_TARGET_PORT", target_port.to_string());
341 match kind {
342 AmbientProxyKind::Http => {
343 let proxy = format!("http://127.0.0.1:{proxy_port}");
344 command
345 .env("CODEWHALE_PROXY_PROBE_SCHEME", "http")
346 .env("HTTP_PROXY", &proxy)
347 .env("http_proxy", proxy);
348 }
349 AmbientProxyKind::HttpsConnect => {
350 let proxy = format!("http://127.0.0.1:{proxy_port}");
351 command
352 .env("CODEWHALE_PROXY_PROBE_SCHEME", "https")
353 .env("HTTPS_PROXY", &proxy)
354 .env("https_proxy", proxy);
355 }
356 AmbientProxyKind::SocksRemoteDns => {
357 let proxy = format!("socks5h://127.0.0.1:{proxy_port}");
358 command
359 .env("CODEWHALE_PROXY_PROBE_SCHEME", "http")
360 .env("ALL_PROXY", &proxy)
361 .env("all_proxy", proxy);
362 }
363 }
364
365 let output = command.output().expect("run proxy probe child");
366 stop.store(true, Ordering::SeqCst);
367 let target_hit = target_handle.join().expect("join target probe");
368 let proxy_hit = proxy_handle.join().expect("join proxy probe");
369 assert!(
370 output.status.success(),
371 "proxy probe child failed for {kind:?} guarded={guarded}:\nstdout:\n{}\nstderr:\n{}",
372 String::from_utf8_lossy(&output.stdout),
373 String::from_utf8_lossy(&output.stderr),
374 );
375 (target_hit, proxy_hit)
376 }
377
378 #[cfg(not(windows))]
379 #[tokio::test]
380 #[ignore = "subprocess helper for ambient proxy regression test"]
381 async fn guarded_transport_proxy_probe_child() {
382 if std::env::var_os("CODEWHALE_PROXY_PROBE_CHILD").is_none() {
383 return;
384 }
385 let guarded = std::env::var("CODEWHALE_PROXY_PROBE_GUARDED").as_deref() == Ok("1");
386 let scheme = std::env::var("CODEWHALE_PROXY_PROBE_SCHEME").expect("probe scheme");
387 let target_port = std::env::var("CODEWHALE_PROXY_PROBE_TARGET_PORT")
388 .expect("probe target port")
389 .parse::<u16>()
390 .expect("numeric target port");
391 let host = "guarded-proxy-probe.example.invalid";
392 let builder = if guarded {
393 guarded_reqwest_client_builder()
394 } else {
395 crate::tls::reqwest_client_builder()
396 };
397 let client = builder
398 .timeout(Duration::from_secs(2))
399 .resolve(
400 host,
401 SocketAddr::new(Ipv4Addr::LOCALHOST.into(), target_port),
402 )
403 .build()
404 .expect("build proxy probe client");
405 let result = client
406 .get(format!("{scheme}://{host}:{target_port}/"))
407 .send()
408 .await;
409 if guarded && scheme == "http" {
410 assert!(
411 result.is_ok(),
412 "guarded HTTP target should answer: {result:?}"
413 );
414 }
415 }
416
417 #[cfg(not(windows))]
418 #[test]
419 fn guarded_transport_bypasses_ambient_http_https_and_remote_dns_socks_proxies() {
420 for kind in [
421 AmbientProxyKind::Http,
422 AmbientProxyKind::HttpsConnect,
423 AmbientProxyKind::SocksRemoteDns,
424 ] {
425 let (unguarded_target, unguarded_proxy) = run_ambient_proxy_probe(kind, false);
426 assert!(
427 unguarded_proxy && !unguarded_target,
428 "control client must demonstrate ambient {kind:?} proxy interception"
429 );
430
431 let (guarded_target, guarded_proxy) = run_ambient_proxy_probe(kind, true);
432 assert!(
433 guarded_target && !guarded_proxy,
434 "guarded client must preserve its DNS pin and bypass ambient {kind:?} proxy"
435 );
436 }
437 }
438
439 #[test]
440 fn rejects_private_localhost_literal() {
441 assert!(is_restricted_ip(&"127.0.0.1".parse().unwrap()));
442 assert!(is_restricted_ip(&"::1".parse().unwrap()));
443 }
444
445 #[test]
446 fn rejects_private_rfc1918() {
447 assert!(is_restricted_ip(&"10.0.0.1".parse().unwrap()));
448 assert!(is_restricted_ip(&"172.16.0.1".parse().unwrap()));
449 assert!(is_restricted_ip(&"192.168.1.1".parse().unwrap()));
450 }
451
452 #[test]
453 fn rejects_cloud_metadata() {
454 assert!(is_restricted_ip(&"169.254.169.254".parse().unwrap()));
455 }
456
457 #[test]
458 fn rejects_link_local() {
459 assert!(is_restricted_ip(&"169.254.1.1".parse().unwrap()));
460 }
461
462 #[test]
463 fn rejects_cgnat() {
464 assert!(is_restricted_ip(&"100.64.0.1".parse().unwrap()));
465 assert!(!is_restricted_ip(&"100.63.0.1".parse().unwrap()));
466 assert!(!is_restricted_ip(&"100.128.0.1".parse().unwrap()));
467 }
468
469 #[test]
470 fn rejects_ipv6_ula() {
471 assert!(is_restricted_ip(&"fc00::1".parse().unwrap()));
472 assert!(is_restricted_ip(&"fd12:3456::1".parse().unwrap()));
473 }
474
475 #[test]
476 fn rejects_ipv4_mapped_ipv6() {
477 // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass
478 assert!(is_restricted_ip(&"::ffff:127.0.0.1".parse().unwrap()));
479 assert!(is_restricted_ip(&"::ffff:10.0.0.1".parse().unwrap()));
480 assert!(is_restricted_ip(&"::ffff:169.254.169.254".parse().unwrap()));
481 assert!(is_restricted_ip(&"::ffff:192.168.1.1".parse().unwrap()));
482 // :: (unspecified)
483 assert!(is_restricted_ip(&"::".parse().unwrap()));
484 }
485
486 #[test]
487 fn allows_public_ips() {
488 assert!(!is_restricted_ip(&"8.8.8.8".parse().unwrap()));
489 assert!(!is_restricted_ip(&"1.1.1.1".parse().unwrap()));
490 assert!(!is_restricted_ip(&"93.184.216.34".parse().unwrap()));
491 assert!(!is_restricted_ip(&"2606:4700::1".parse().unwrap()));
492 }
493
494 #[tokio::test]
495 async fn redirected_localhost_hostname_is_rejected() {
496 let url = reqwest::Url::parse("http://localhost:8080/admin").unwrap();
497 let err = validate_fetch_target(&url, &ctx(), "fetch_url")
498 .await
499 .unwrap_err();
500 assert!(format!("{err}").contains("localhost"));
501 }
502
503 #[tokio::test]
504 async fn redirected_private_ip_literal_is_rejected() {
505 let url = reqwest::Url::parse("http://169.254.169.254/latest/meta-data").unwrap();
506 let err = validate_fetch_target(&url, &ctx(), "fetch_url")
507 .await
508 .unwrap_err();
509 assert!(format!("{err}").contains("restricted address"));
510 }
511
512 // GHSA-88gh-2526-gfrr — regression coverage for bracketed IPv6 literals.
513 #[tokio::test]
514 async fn rejects_ipv6_literal_loopback() {
515 let url = reqwest::Url::parse("http://[::1]/").unwrap();
516 let err = validate_fetch_target(&url, &ctx(), "fetch_url")
517 .await
518 .expect_err("[::1] must be rejected as restricted");
519 assert!(format!("{err}").contains("restricted"));
520 }
521
522 #[tokio::test]
523 async fn rejects_ipv6_literal_ula() {
524 let url = reqwest::Url::parse("http://[fc00::1]/").unwrap();
525 let err = validate_fetch_target(&url, &ctx(), "fetch_url")
526 .await
527 .expect_err("[fc00::1] must be rejected as restricted");
528 assert!(format!("{err}").contains("restricted"));
529 }
530
531 #[tokio::test]
532 async fn rejects_ipv6_literal_link_local() {
533 let url = reqwest::Url::parse("http://[fe80::1]/").unwrap();
534 let err = validate_fetch_target(&url, &ctx(), "fetch_url")
535 .await
536 .expect_err("[fe80::1] must be rejected as restricted");
537 assert!(format!("{err}").contains("restricted"));
538 }
539
540 #[tokio::test]
541 async fn rejects_ipv6_literal_ipv4_mapped_loopback() {
542 let url = reqwest::Url::parse("http://[::ffff:127.0.0.1]/").unwrap();
543 let err = validate_fetch_target(&url, &ctx(), "fetch_url")
544 .await
545 .expect_err("[::ffff:127.0.0.1] must be rejected as restricted");
546 assert!(format!("{err}").contains("restricted"));
547 }
548
549 #[tokio::test]
550 async fn rejects_ipv6_literal_unspecified() {
551 let url = reqwest::Url::parse("http://[::]/").unwrap();
552 let err = validate_fetch_target(&url, &ctx(), "fetch_url")
553 .await
554 .expect_err("[::] must be rejected as restricted");
555 assert!(format!("{err}").contains("restricted"));
556 }
557
558 #[tokio::test]
559 async fn redirected_host_respects_network_policy() {
560 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
561 let policy = NetworkPolicy {
562 default: Decision::Deny.into(),
563 allow: vec!["api.deepseek.com".to_string()],
564 deny: vec![],
565 proxy: Vec::new(),
566 proxy_fake_ip_cidrs: Vec::new(),
567 audit: false,
568 };
569 let decider = NetworkPolicyDecider::new(policy, None);
570 let ctx = ToolContext::new(PathBuf::from(".")).with_network_policy(decider);
571 let url = reqwest::Url::parse("https://example.com/redirect-target").unwrap();
572 let err = validate_fetch_target(&url, &ctx, "fetch_url")
573 .await
574 .unwrap_err();
575 assert!(format!("{err}").contains("blocked"));
576 }
577
578 #[tokio::test]
579 async fn unresolved_hostname_is_rejected_before_request() {
580 let url =
581 reqwest::Url::parse("https://codewhale-unresolvable-fetch-target.invalid/resource")
582 .unwrap();
583 let err = validate_fetch_target(&url, &ctx(), "fetch_url")
584 .await
585 .expect_err("unresolved host must fail preflight");
586 let message = format!("{err}");
587 assert!(
588 message.contains("could not resolve host") || message.contains("restricted address"),
589 "error must identify preflight DNS or restricted-IP failure; got {err}"
590 );
591 }
592
593 #[test]
594 fn restricted_dns_result_is_denied_without_proxy_opt_in() {
595 let ip = "198.18.0.1".parse().unwrap();
596
597 let err = validate_dns_resolved_ip("github.com", &ip, None, "fetch_url")
598 .expect_err("fake-IP DNS result must be denied by default");
599
600 assert!(format!("{err}").contains("resolved IP 198.18.0.1 is a restricted address"));
601 }
602
603 #[test]
604 fn proxy_host_and_fakeip_cidr_allow_matching_placeholder() {
605 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
606
607 let policy = NetworkPolicy {
608 default: Decision::Allow.into(),
609 allow: Vec::new(),
610 deny: Vec::new(),
611 proxy: vec!["github.com".to_string()],
612 proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
613 audit: false,
614 };
615 let decider = NetworkPolicyDecider::new(policy, None);
616 let ip = "198.18.0.1".parse().unwrap();
617
618 validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url")
619 .expect("matching host and fake-IP CIDR should allow the placeholder");
620 }
621
622 #[test]
623 fn proxy_host_without_fakeip_cidr_does_not_allow_restricted_dns() {
624 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
625
626 let policy = NetworkPolicy {
627 default: Decision::Allow.into(),
628 allow: Vec::new(),
629 deny: Vec::new(),
630 proxy: vec!["github.com".to_string()],
631 proxy_fake_ip_cidrs: Vec::new(),
632 audit: false,
633 };
634 let decider = NetworkPolicyDecider::new(policy, None);
635 let ip = "198.18.0.1".parse().unwrap();
636
637 validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url")
638 .expect_err("hostname trust alone must not allow a restricted address");
639 }
640
641 #[test]
642 fn fakeip_cidr_without_proxy_host_does_not_allow_restricted_dns() {
643 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
644
645 let policy = NetworkPolicy {
646 default: Decision::Allow.into(),
647 allow: Vec::new(),
648 deny: Vec::new(),
649 proxy: Vec::new(),
650 proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
651 audit: false,
652 };
653 let decider = NetworkPolicyDecider::new(policy, None);
654 let ip = "198.18.0.1".parse().unwrap();
655
656 validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url")
657 .expect_err("fake-IP CIDR alone must not allow an untrusted hostname");
658 }
659
660 #[test]
661 fn proxy_host_never_exempts_real_private_or_local_addresses() {
662 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
663
664 let policy = NetworkPolicy {
665 default: Decision::Allow.into(),
666 allow: Vec::new(),
667 deny: Vec::new(),
668 proxy: vec!["github.com".to_string()],
669 proxy_fake_ip_cidrs: vec![
670 "198.18.0.0/15".to_string(),
671 "127.0.0.0/8".to_string(),
672 "10.0.0.0/8".to_string(),
673 "169.254.0.0/16".to_string(),
674 ],
675 audit: false,
676 };
677 let decider = NetworkPolicyDecider::new(policy, None);
678
679 for ip in [
680 "127.0.0.1",
681 "10.0.0.1",
682 "192.168.1.1",
683 "169.254.169.254",
684 "fc00::1",
685 ] {
686 let ip = ip.parse().unwrap();
687 assert!(
688 validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url").is_err(),
689 "{ip} must remain restricted"
690 );
691 }
692 }
693
694 #[test]
695 fn proxy_opt_in_does_not_allow_unlisted_host() {
696 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
697
698 let policy = NetworkPolicy {
699 default: Decision::Allow.into(),
700 allow: Vec::new(),
701 deny: Vec::new(),
702 proxy: vec!["github.com".to_string()],
703 proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
704 audit: false,
705 };
706 let decider = NetworkPolicyDecider::new(policy, None);
707 let ip = "198.18.0.1".parse().unwrap();
708
709 let err = validate_dns_resolved_ip("example.com", &ip, Some(&decider), "fetch_url")
710 .expect_err("proxy opt-in must be scoped to configured hosts");
711
712 assert!(format!("{err}").contains("resolved IP 198.18.0.1 is a restricted address"));
713 }
714
715 #[test]
716 fn proxy_dns_allow_is_audited() {
717 use crate::network_policy::{
718 Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,
719 };
720 use tempfile::tempdir;
721
722 let dir = tempdir().expect("tempdir");
723 let auditor = NetworkAuditor::new(dir.path().join("audit.log"), true);
724 let policy = NetworkPolicy {
725 default: Decision::Allow.into(),
726 allow: Vec::new(),
727 deny: Vec::new(),
728 proxy: vec!["github.com".to_string()],
729 proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
730 audit: true,
731 };
732 let decider = NetworkPolicyDecider::new(policy, Some(auditor));
733 let ip = "198.18.0.1".parse().unwrap();
734
735 validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url")
736 .expect("proxy DNS allow");
737
738 let body = std::fs::read_to_string(dir.path().join("audit.log")).expect("audit log");
739 assert!(body.contains("github.com"));
740 assert!(body.contains("TrustedProxyFakeIp-Allow"));
741 }
742
743 #[tokio::test]
744 async fn web_run_tool_label_is_used_in_dns_error() {
745 let url =
746 reqwest::Url::parse("https://codewhale-unresolvable-web-run-target.invalid/resource")
747 .unwrap();
748 let err = validate_fetch_target(&url, &ctx(), "web_run")
749 .await
750 .expect_err("unresolved host must fail preflight");
751 let message = format!("{err}");
752 // Either DNS failure (mentions web_run) or a restricted resolution.
753 assert!(
754 message.contains("web_run") || message.contains("restricted address"),
755 "error should be labeled for web_run or report restricted IP; got {err}"
756 );
757 }
758
759 #[test]
760 fn dns_preflight_bound_stays_below_the_fetch_hard_cap() {
761 // The pre-flight resolution runs before every guarded request (and
762 // once per redirect); its own bound must leave room inside the fetch
763 // tool's hard cap instead of being able to outlast it. A wedged
764 // resolver surfaces as a timeout error, not a stalled tool.
765 assert!(
766 DNS_PREFLIGHT_TIMEOUT < super::super::fetch::HARD_MAX_TIMEOUT,
767 "DNS pre-flight bound {:?} must stay below the fetch hard cap {:?}",
768 DNS_PREFLIGHT_TIMEOUT,
769 super::super::fetch::HARD_MAX_TIMEOUT
770 );
771 }
772 }
773
773 lines RUST