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