From ad3840f9ec0e9b09862cceb70afb94373903014c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 27 Jul 2026 11:44:20 -0400 Subject: [PATCH] fix(web_fetch): block non-public targets by default and gate every hop kigi allowed loopback unconditionally and missed several non-public ranges, and the SSRF check ran only on the initial URL. Policy (ssrf.rs): - loopback is blocked unless `[toolset.web_fetch] allow_local` (or KIGI_WEB_FETCH_ALLOW_LOCAL) is on, AND the URL names it explicitly, so a public name resolving to loopback stays blocked (DNS rebinding) - add 0.0.0.0/8, 100.64/10, 192.0.0.0/24, TEST-NET-1/2/3, 198.18/15, 240/4, IPv6 site-local and documentation prefixes - inherit the IPv4 verdict through mapped, compatible, NAT64 and 6to4 wrappers; network-specific NAT64 prefixes remain uncovered (see doc) Plumbing (client.rs), where the exploitable half lived: - re-check every redirect hop, not just the first - compare hosts exactly; a `www` sibling has its own A records, so it is a cross-host redirect rather than an auto-followed hop - run the check before the fetch service, so a blocked URL is never posted to an endpoint that egresses elsewhere - exempt explicit local hosts from the https upgrade and from the single-label filter, and re-upgrade each followed hop Wiring: allow_local reaches WebFetchParams from both construction paths; documented in the config guide and the README env table. --- crates/codegen/kigi-shell/README.md | 2 + crates/codegen/kigi-shell/src/tools/config.rs | 13 + .../implementations/kigi/web_fetch/client.rs | 156 ++++++- .../implementations/kigi/web_fetch/config.rs | 8 + .../implementations/kigi/web_fetch/ssrf.rs | 382 +++++++++++++----- .../docs/user-guide/05-configuration.md | 8 + .../kigi-workspace/src/session/tool_config.rs | 3 + 7 files changed, 457 insertions(+), 115 deletions(-) diff --git a/crates/codegen/kigi-shell/README.md b/crates/codegen/kigi-shell/README.md index 9676946..7f8a225 100644 --- a/crates/codegen/kigi-shell/README.md +++ b/crates/codegen/kigi-shell/README.md @@ -1309,6 +1309,7 @@ output_byte_limit = 65536 # max output size (64KB) [toolset.web_fetch] proxy_endpoint = "https://proxy.example.com" # egress proxy URL (all requests routed through it) allowed_domains = ["docs.rs", "x.ai"] # override the built-in ~84-domain allowlist +allow_local = false # true = reach an explicit localhost / 127.0.0.0/8 / ::1 URL [shortcuts] send = ["Enter"] @@ -2384,6 +2385,7 @@ The agent persists all session updates automatically. Clients can reconnect and | `KIGI_AGENT` | Custom agent definition path or name (see [Agent Profiles](#agent-profiles)) | | `KIGI_WEB_FETCH` | Enable (`1`) or disable (`0`) the `web_fetch` tool | | `KIGI_WEB_FETCH_PROXY` | Egress proxy URL for `web_fetch` requests (overridden by `[toolset.web_fetch] proxy_endpoint`) | +| `KIGI_WEB_FETCH_ALLOW_LOCAL` | `1` lets `web_fetch` reach an explicit loopback URL; private and metadata ranges stay blocked | | `KIGI_RESPECT_GITIGNORE` | Disable `.gitignore` filtering in tools when set to `0` | | `KIGI_FEEDBACK_ENABLED` | Enable (`1`) or disable (`0`) feedback system independently from telemetry | | `KIGI_DEPLOYMENT_KEY` | Management API key for enterprise deployments | diff --git a/crates/codegen/kigi-shell/src/tools/config.rs b/crates/codegen/kigi-shell/src/tools/config.rs index 2f6935e..f413ed8 100644 --- a/crates/codegen/kigi-shell/src/tools/config.rs +++ b/crates/codegen/kigi-shell/src/tools/config.rs @@ -108,6 +108,10 @@ pub struct WebFetchToolConfig { /// default allowlist. An explicit empty list blocks all fetches. /// Resolution: TOML > remote settings > built-in defaults. pub allowed_domains: Option>, + /// Allow fetches to explicit loopback hosts only (`localhost` / + /// `127.0.0.0/8` / `::1`). Private and metadata ranges stay blocked. + /// Resolution: TOML > `KIGI_WEB_FETCH_ALLOW_LOCAL` env > false. + pub allow_local: Option, } impl WebFetchToolConfig { @@ -137,10 +141,15 @@ impl WebFetchToolConfig { .cloned() .or_else(|| remote_domains.map(|d| d.to_vec())); + let allow_local = self + .allow_local + .or_else(|| kigi_config::env_bool("KIGI_WEB_FETCH_ALLOW_LOCAL")); + kigi_tools::implementations::kigi::web_fetch::WebFetchParams { proxy_endpoint, allowed_domains, context_window_tokens, + allow_local, ..Default::default() } } @@ -484,6 +493,7 @@ mod tests { let local = WebFetchToolConfig { proxy_endpoint: Some("https://toml-proxy.example.com".to_owned()), allowed_domains: Some(vec!["toml.example.com".to_owned()]), + allow_local: Some(true), }; let params = local.resolve_params( Some("https://remote-proxy.example.com"), @@ -498,6 +508,7 @@ mod tests { params.allowed_domains, Some(vec!["toml.example.com".to_owned()]) ); + assert!(params.allow_local(), "the opt-in must reach the tool"); } #[test] @@ -524,6 +535,7 @@ mod tests { let params = local.resolve_params(None, None, None); assert!(params.proxy_endpoint.is_none()); assert!(params.allowed_domains.is_none()); + assert!(!params.allow_local(), "local access is off by default"); } #[test] @@ -531,6 +543,7 @@ mod tests { let local = WebFetchToolConfig { proxy_endpoint: None, allowed_domains: Some(vec![]), + allow_local: None, }; let params = local.resolve_params(None, Some(&["remote.example.com".to_owned()]), None); assert_eq!(params.allowed_domains, Some(vec![])); diff --git a/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/client.rs b/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/client.rs index 182d99e..fb4928b 100644 --- a/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/client.rs +++ b/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/client.rs @@ -155,6 +155,9 @@ impl WebFetchClient { } } + // Before any egress: the service must not see this. + ssrf::check_ssrf(&url, self.params.allow_local()).await?; + // Kimi fetch service first (OAuth sessions); local pipeline is the // fallback on any service failure (kimi-cli fetch.py `__call__`). if let Some(service_url) = self.params.service_url.clone() { @@ -182,10 +185,15 @@ impl WebFetchClient { } } - ssrf::check_ssrf(&url).await?; - let http = self.http.get_or_rebuild()?; - let result = match fetch_url(&http, &url, self.params.max_content_length()).await { + let result = match fetch_url( + &http, + &url, + self.params.max_content_length(), + self.params.allow_local(), + ) + .await + { Ok(result) => result, Err(e @ WebFetchError::HttpRequest(_)) => { self.http.invalidate(); @@ -380,6 +388,8 @@ fn validate_url(raw: &str) -> Result { if let Some(host) = parsed.host_str() && host.split('.').count() < 2 + // `localhost` is single-label; SSRF still gates it on allow_local. + && !ssrf::is_explicit_local_host(host) { return Err(WebFetchError::SingleLabelHost { host: host.to_string(), @@ -390,9 +400,16 @@ fn validate_url(raw: &str) -> Result { } fn upgrade_to_https(url: &mut Url) { - if url.scheme() == "http" { - let _ = url.set_scheme("https"); + if url.scheme() != "http" { + return; } + // Local dev servers rarely serve TLS; SSRF still gates them. + if let Some(host) = url.host_str() + && ssrf::is_explicit_local_host(host) + { + return; + } + let _ = url.set_scheme("https"); } enum FetchResult { @@ -409,15 +426,21 @@ enum FetchResult { } /// Fetch a URL with manual same-host redirect handling. +/// +/// Every hop is re-checked, so a rebinding name cannot pass. +/// Partial: reqwest hides the peer IP of the live connection. async fn fetch_url( client: &reqwest::Client, url: &Url, max_content_length: usize, + allow_local: bool, ) -> Result { let mut current_url = url.clone(); let mut hops = 0; loop { + ssrf::check_ssrf(¤t_url, allow_local).await?; + let resp = client .get(current_url.as_str()) .header(USER_AGENT, USER_AGENT_STRING) @@ -439,10 +462,13 @@ async fn fetch_url( if let Some(location) = resp.headers().get("location") { let location_str = location.to_str().unwrap_or(""); - let next_url = current_url + let mut next_url = current_url .join(location_str) .map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?; if is_same_host(¤t_url, &next_url) { + // An absolute `http://` Location would downgrade the hop. + upgrade_to_https(&mut next_url); + // check_ssrf runs at the top of the next iteration. current_url = next_url; continue; } @@ -479,13 +505,11 @@ async fn fetch_url( } } +/// Exact host equality, no `www.` stripping. +/// +/// A `www` sibling has separate records, so it is cross-host. fn is_same_host(a: &Url, b: &Url) -> bool { - fn strip_www(h: &str) -> &str { - h.strip_prefix("www.").unwrap_or(h) - } - let host_a = a.host_str().unwrap_or(""); - let host_b = b.host_str().unwrap_or(""); - strip_www(host_a) == strip_www(host_b) + a.host_str() == b.host_str() } fn require_media_session_folder(session_folder: Option<&Path>) -> Result<&Path, WebFetchError> { @@ -963,6 +987,78 @@ mod tests { assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}"); } + /// A blocked target must never reach the remote fetch service. + /// + /// It egresses elsewhere, so posting leaks an internal URL. + #[tokio::test] + async fn a_blocked_url_never_reaches_the_fetch_service() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/fetch")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .mount(&server) + .await; + let provider = crate::types::api_key_provider::test_support::fixed_provider("t"); + let params = WebFetchParams { + service_url: Some(format!("{}/fetch", server.uri())), + ..WebFetchParams::default() + }; + let client = WebFetchClient::new(¶ms, Some(provider)).unwrap(); + + let Err(err) = client + .fetch( + "http://localhost:8080/admin?token=secret", + "c", + None, + None, + None, + ) + .await + else { + panic!("a loopback target must be blocked"); + }; + assert!(matches!(err, WebFetchError::SsrfBlocked { .. }), "{err}"); + assert!( + server.received_requests().await.unwrap().is_empty(), + "the internal URL must never be posted anywhere" + ); + } + + /// `fetch_url` gates its own target, not trusting its caller. + /// + /// Only hop one is covered: same-host hops share one verdict, + /// so rebinding between them needs a live resolver to observe. + #[tokio::test] + async fn fetch_url_blocks_a_loopback_target_without_allow_local() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/x")) + .respond_with(ResponseTemplate::new(200).set_body_string("

local

")) + .mount(&server) + .await; + let url = Url::parse(&format!("{}/x", server.uri())).unwrap(); + let http = HttpClient::new(&WebFetchParams::default()) + .unwrap() + .get_or_rebuild() + .unwrap(); + + let Err(err) = fetch_url(&http, &url, 1_000_000, false).await else { + panic!("a blocked host must not be fetched"); + }; + assert!(matches!(err, WebFetchError::SsrfBlocked { .. }), "{err}"); + assert!( + server.received_requests().await.unwrap().is_empty(), + "a blocked host must not be contacted at all" + ); + + let ok = fetch_url(&http, &url, 1_000_000, true).await.unwrap(); + assert!(matches!(ok, FetchResult::Content { .. })); + } + fn test_converter() -> htmd::HtmlToMarkdown { htmd::HtmlToMarkdown::builder() .skip_tags(vec![ @@ -1024,9 +1120,12 @@ mod tests { #[test] fn validate_url_rejects_single_label_hosts() { - assert!(validate_url("http://localhost:8080/foo").is_err()); assert!(validate_url("http://intranet/foo").is_err()); assert!(validate_url("http://metadata/computeMetadata").is_err()); + assert!( + validate_url("http://localhost:8080/foo").is_ok(), + "localhost reaches the SSRF gate, which blocks it unless opted in" + ); } #[test] @@ -1068,6 +1167,19 @@ mod tests { assert_eq!(url.scheme(), "https"); } + /// Upgrading a local host breaks the only target `allow_local` opens. + #[test] + fn upgrade_to_https_skips_explicit_local_hosts() { + for raw in ["http://127.0.0.1:8080/", "http://localhost:3000/"] { + let mut url = Url::parse(raw).unwrap(); + upgrade_to_https(&mut url); + assert_eq!(url.scheme(), "http", "{raw}"); + } + let mut public = Url::parse("http://example.com/").unwrap(); + upgrade_to_https(&mut public); + assert_eq!(public.scheme(), "https"); + } + #[test] fn same_host_exact_match() { let a = Url::parse("https://example.com/a").unwrap(); @@ -1075,12 +1187,24 @@ mod tests { assert!(is_same_host(&a, &b)); } + /// An absolute `http://` Location must not downgrade a followed hop. #[test] - fn same_host_www_stripping() { + fn same_host_redirect_location_reupgrades_http() { + let origin = Url::parse("https://example.com/start").unwrap(); + let mut next = origin.join("http://example.com/next").unwrap(); + assert_eq!(next.scheme(), "http"); + assert!(is_same_host(&origin, &next)); + upgrade_to_https(&mut next); + assert_eq!(next.as_str(), "https://example.com/next"); + } + + /// A `www` sibling is a separate name, with separate records. + #[test] + fn www_subdomain_is_cross_host() { let a = Url::parse("https://example.com/a").unwrap(); let c = Url::parse("https://www.example.com/a").unwrap(); - assert!(is_same_host(&a, &c)); - assert!(is_same_host(&c, &a)); + assert!(!is_same_host(&a, &c)); + assert!(!is_same_host(&c, &a)); } #[test] diff --git a/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/config.rs b/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/config.rs index 8baf99c..b620737 100644 --- a/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/config.rs +++ b/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/config.rs @@ -47,12 +47,20 @@ pub struct WebFetchParams { /// on any failure (kimi-cli `tools/web/fetch.py FetchURL.__call__`). #[serde(default)] pub service_url: Option, + /// Opt-in for loopback targets; off means no local access. + #[serde(default)] + pub allow_local: Option, } register_resource!("kigi", "WebFetch", WebFetchParams); // Keep defaults here so call-sites don't have to manage unwrapping. impl WebFetchParams { + /// From config or `KIGI_WEB_FETCH_ALLOW_LOCAL`, never tool input. + pub fn allow_local(&self) -> bool { + self.allow_local.unwrap_or(false) + } + pub fn cache_ttl_secs(&self) -> Duration { Duration::from_secs(self.cache_ttl_secs.unwrap_or(15 * 60)) } diff --git a/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/ssrf.rs b/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/ssrf.rs index eb17d77..5123291 100644 --- a/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/ssrf.rs +++ b/crates/codegen/kigi-tools/src/implementations/kigi/web_fetch/ssrf.rs @@ -1,85 +1,142 @@ -//! SSRF (Server-Side Request Forgery) protection for `web_fetch`. +//! SSRF protection for `web_fetch`. //! -//! Validates that resolved IP addresses are not in private, link-local, or -//! cloud metadata ranges before allowing outbound HTTP requests. +//! Non-public targets are blocked: loopback, RFC 1918, link-local, +//! CGNAT, TEST-NET, reserved, ULA. Loopback is opt-in via +//! `[toolset.web_fetch] allow_local` or `KIGI_WEB_FETCH_ALLOW_LOCAL`, +//! and even then only for a literal local host. //! //! Reference: [IANA IPv4 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv4-special-registry/) -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use url::Url; use super::error::WebFetchError; -/// Returns `true` if an IP address is in a private, link-local, or cloud -/// metadata range that should be blocked to prevent SSRF attacks. +/// Hosts allowed to reach loopback when local access is on. /// -/// **Allowed:** loopback (`127.x` / `::1`) for local development. -/// **Blocked:** RFC 1918, link-local, CGNAT/cloud metadata, unspecified. -pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool { +/// Names that merely RESOLVE to loopback are excluded: DNS rebinding. +pub(crate) fn is_explicit_local_host(host: &str) -> bool { + let host = host.trim().trim_end_matches('.').to_ascii_lowercase(); + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(&host); + // Drop an IPv6 zone id such as `fe80::1%lo0`. + let host = host.split('%').next().unwrap_or(host); + + if host == "localhost" { + return true; + } + host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +/// Whether an IP is not globally routable. +pub(crate) fn is_non_public_ip(ip: &IpAddr) -> bool { match ip { - IpAddr::V4(v4) => { - let octets = v4.octets(); - // Loopback (127.0.0.0/8) — allowed for local dev servers. - if octets[0] == 127 { - return false; - } - // RFC 1918: 10.0.0.0/8 — private network. - if octets[0] == 10 { - return true; - } - // RFC 1918: 172.16.0.0/12 — private network. - if octets[0] == 172 && (16..=31).contains(&octets[1]) { - return true; - } - // RFC 1918: 192.168.0.0/16 — private network. - if octets[0] == 192 && octets[1] == 168 { - return true; - } - // RFC 3927: 169.254.0.0/16 — link-local. - // Includes AWS/GCP/Azure metadata endpoint 169.254.169.254. - if octets[0] == 169 && octets[1] == 254 { - return true; - } - // RFC 6598: 100.64.0.0/10 — CGNAT / shared address space. - // Used by some cloud providers for internal metadata services. - if octets[0] == 100 && (64..=127).contains(&octets[1]) { - return true; - } - if v4.is_unspecified() { - return true; - } - false - } + IpAddr::V4(v4) => is_non_public_ipv4(*v4), + IpAddr::V6(v6) => is_non_public_ipv6(*v6), + } +} + +fn is_non_public_ipv4(ip: Ipv4Addr) -> bool { + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_broadcast() + // "This network" (RFC 1122) 0.0.0.0/8 + || ipv4_in_cidr(ip, [0, 0, 0, 0], 8) + // CGNAT (RFC 6598) — some clouds serve metadata here + || ipv4_in_cidr(ip, [100, 64, 0, 0], 10) + // IETF Protocol Assignments (RFC 6890) + || ipv4_in_cidr(ip, [192, 0, 0, 0], 24) + // TEST-NET-1 (RFC 5737) + || ipv4_in_cidr(ip, [192, 0, 2, 0], 24) + // Benchmarking (RFC 2544) + || ipv4_in_cidr(ip, [198, 18, 0, 0], 15) + // TEST-NET-2 / TEST-NET-3 + || ipv4_in_cidr(ip, [198, 51, 100, 0], 24) + || ipv4_in_cidr(ip, [203, 0, 113, 0], 24) + // Reserved (RFC 6890) + || ipv4_in_cidr(ip, [240, 0, 0, 0], 4) +} + +fn ipv4_in_cidr(ip: Ipv4Addr, base: [u8; 4], prefix: u8) -> bool { + debug_assert!(prefix <= 32, "IPv4 prefix out of range"); + let ip = u32::from(ip); + let base = u32::from(Ipv4Addr::from(base)); + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + (ip & mask) == (base & mask) +} + +fn is_non_public_ipv6(ip: Ipv6Addr) -> bool { + // Identity wins: `::1` is not judged as `0.0.0.1`. + if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() { + return true; + } + if let Some(v4) = embedded_ipv4(ip) { + return is_non_public_ipv4(v4); + } + let seg = ip.segments(); + ip.is_unique_local() + || ip.is_unicast_link_local() + // Deprecated site-local (RFC 3879) fec0::/10 + || (seg[0] & 0xffc0) == 0xfec0 + // Documentation (RFC 3849) 2001:db8::/32 + || (seg[0] == 0x2001 && seg[1] == 0x0db8) +} + +/// IPv4 reachable through a known IPv6 wrapper, if any. +/// +/// Covers mapped, compatible, well-known NAT64, and 6to4. Not complete: +/// network-specific NAT64 prefixes (RFC 6052) cannot be enumerated. +fn embedded_ipv4(ip: Ipv6Addr) -> Option { + let seg = ip.segments(); + let embedded = |hi: u16, lo: u16| Ipv4Addr::from(u32::from(hi) << 16 | u32::from(lo)); + + if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0] { + return Some(embedded(seg[6], seg[7])); + } + if seg[0] == 0x2002 { + return Some(embedded(seg[1], seg[2])); + } + // Covers `::ffff:a.b.c.d` and the deprecated `::a.b.c.d`. + ip.to_ipv4() +} + +/// Loopback including IPv4-mapped forms like `::ffff:127.0.0.1`. +/// +/// `IpAddr::is_loopback` is false for mapped addresses, so the opt-in path +/// cannot use it directly. +fn is_loopback_addr(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_loopback(), IpAddr::V6(v6) => { - // ::1 — loopback, allowed for local dev. - if v6.is_loopback() { - return false; - } - if v6.is_unspecified() { - return true; - } - // IPv4-mapped IPv6 (::ffff:x.x.x.x) — delegate to v4 checks. - if let Some(v4) = v6.to_ipv4_mapped() { - return is_blocked_ip(&IpAddr::V4(v4)); - } - let segments = v6.segments(); - // RFC 4291: fe80::/10 — link-local unicast. - if segments[0] & 0xffc0 == 0xfe80 { - return true; - } - // RFC 4193: fc00::/7 — unique local address (ULA). - if segments[0] & 0xfe00 == 0xfc00 { - return true; - } - false + v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) } } } -/// Resolve hostname via DNS and verify none of the resolved addresses are -/// in blocked private/link-local ranges. -pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> { +/// Dual gate: loopback opens only for an explicit local host. +/// +/// Private and link-local never open through this flag. +pub(crate) fn is_blocked_for_host(ip: &IpAddr, host: &str, allow_local: bool) -> bool { + if !is_non_public_ip(ip) { + return false; + } + !(allow_local && is_loopback_addr(ip) && is_explicit_local_host(host)) +} + +/// Verifies no resolved address is blocked by the SSRF policy. +/// +/// `allow_local` is config-only so the model cannot flip it. +pub(crate) async fn check_ssrf(url: &Url, allow_local: bool) -> Result<(), WebFetchError> { let host = url .host_str() .ok_or_else(|| WebFetchError::SingleLabelHost { @@ -87,7 +144,7 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> { })?; if let Ok(ip) = host.parse::() { - if is_blocked_ip(&ip) { + if is_blocked_for_host(&ip, host, allow_local) { return Err(WebFetchError::SsrfBlocked { host: host.to_string(), ip, @@ -112,7 +169,7 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> { addrs .iter() - .find(|addr| is_blocked_ip(&addr.ip())) + .find(|addr| is_blocked_for_host(&addr.ip(), host, allow_local)) .map_or(Ok(()), |addr| { Err(WebFetchError::SsrfBlocked { host: host.to_string(), @@ -127,86 +184,201 @@ mod tests { #[test] fn blocks_rfc1918_10x() { - assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap())); + assert!(is_non_public_ip(&"10.0.0.1".parse().unwrap())); + assert!(is_non_public_ip(&"10.255.255.255".parse().unwrap())); } #[test] fn blocks_rfc1918_172x() { - assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap())); - assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap())); - assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap())); + assert!(is_non_public_ip(&"172.16.0.1".parse().unwrap())); + assert!(is_non_public_ip(&"172.31.255.255".parse().unwrap())); + assert!(!is_non_public_ip(&"172.15.0.1".parse().unwrap())); + assert!(!is_non_public_ip(&"172.32.0.1".parse().unwrap())); } #[test] fn blocks_rfc1918_192168() { - assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap())); + assert!(is_non_public_ip(&"192.168.0.1".parse().unwrap())); + assert!(is_non_public_ip(&"192.168.255.255".parse().unwrap())); } #[test] fn blocks_link_local() { - assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap())); + assert!(is_non_public_ip(&"169.254.0.1".parse().unwrap())); + assert!(is_non_public_ip(&"169.254.169.254".parse().unwrap())); } #[test] fn blocks_cgnat_cloud_metadata() { - assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap())); - assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap())); - assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap())); - assert!(!is_blocked_ip(&"100.128.0.1".parse().unwrap())); + assert!(is_non_public_ip(&"100.64.0.1".parse().unwrap())); + assert!(is_non_public_ip(&"100.127.255.255".parse().unwrap())); + assert!(!is_non_public_ip(&"100.63.0.1".parse().unwrap())); + assert!(!is_non_public_ip(&"100.128.0.1".parse().unwrap())); } #[test] fn blocks_unspecified() { - assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap())); - assert!(is_blocked_ip(&"::".parse().unwrap())); + assert!(is_non_public_ip(&"0.0.0.0".parse().unwrap())); + assert!(is_non_public_ip(&"::".parse().unwrap())); } #[test] - fn allows_loopback() { - assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap())); - assert!(!is_blocked_ip(&"127.0.0.2".parse().unwrap())); - assert!(!is_blocked_ip(&"::1".parse().unwrap())); + fn blocks_loopback_by_default() { + for ip in ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"] { + let ip: IpAddr = ip.parse().unwrap(); + assert!(is_non_public_ip(&ip), "{ip} must not be public"); + assert!( + is_blocked_for_host(&ip, "localhost", false), + "{ip} must be blocked without allow_local" + ); + } + } + + #[test] + fn allow_local_opens_loopback_only_for_an_explicit_local_host() { + for (ip, host) in [ + ("127.0.0.1", "localhost"), + ("127.0.0.1", "127.0.0.1"), + ("::1", "::1"), + ("::ffff:127.0.0.1", "localhost"), + ] { + let ip: IpAddr = ip.parse().unwrap(); + assert!(!is_blocked_for_host(&ip, host, true), "{ip} via {host}"); + } + assert!( + is_blocked_for_host(&"127.0.0.1".parse().unwrap(), "evil.example.com", true), + "a public name resolving to loopback is DNS rebinding" + ); + } + + #[test] + fn allow_local_never_opens_private_or_link_local() { + for ip in ["10.0.0.1", "169.254.169.254", "192.168.1.1"] { + let ip: IpAddr = ip.parse().unwrap(); + assert!( + is_blocked_for_host(&ip, "localhost", true), + "{ip} must stay blocked even with allow_local" + ); + } + } + + #[test] + fn blocks_test_net_and_reserved_ranges() { + for ip in [ + "0.0.0.1", + "192.0.0.1", + "192.0.2.1", + "198.18.0.1", + "198.19.255.255", + "198.51.100.1", + "203.0.113.1", + "240.0.0.1", + ] { + assert!(is_non_public_ip(&ip.parse().unwrap()), "{ip}"); + } + // Neighbours of every range above must stay reachable. + for ip in [ + "1.0.0.1", + "192.0.1.1", + "192.0.3.1", + "198.17.255.255", + "198.20.0.1", + "198.51.101.1", + "203.0.114.1", + "223.255.255.255", + ] { + assert!(!is_non_public_ip(&ip.parse().unwrap()), "{ip}"); + } + } + + /// A v6 record can smuggle v4 through four wrapper prefixes. + #[test] + fn blocks_ipv4_smuggled_through_ipv6_wrappers() { + for ip in [ + "64:ff9b::a9fe:a9fe", + "64:ff9b::7f00:1", + "2002:7f00:1::", + "::7f00:1", + "::a00:1", + "fec0::1", + "2001:db8::1", + ] { + assert!(is_non_public_ip(&ip.parse().unwrap()), "{ip}"); + } + for ip in ["64:ff9b::808:808", "2002:808:808::", "2001:db9::1"] { + assert!(!is_non_public_ip(&ip.parse().unwrap()), "{ip}"); + } + } + + #[test] + fn explicit_local_host_tolerates_brackets_dots_and_zone_ids() { + for host in [ + "localhost", + "LOCALHOST.", + "127.0.0.1", + "127.1.2.3", + "::1", + "[::1]", + "::1%lo0", + ] { + assert!(is_explicit_local_host(host), "{host}"); + } + for host in [ + "example.com", + "notlocalhost", + "localhost.evil.com", + "10.0.0.1", + ] { + assert!(!is_explicit_local_host(host), "{host}"); + } } #[test] fn allows_public_ips() { - assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap())); - assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap())); - assert!(!is_blocked_ip(&"142.250.80.46".parse().unwrap())); + for ip in [ + "1.1.1.1", + "8.8.8.8", + "142.250.80.46", + // Global unicast v6: guards the new masks against over-matching. + "2606:4700::1111", + "2001:4860:4860::8888", + ] { + assert!(!is_non_public_ip(&ip.parse().unwrap()), "{ip}"); + } } #[test] fn blocks_ipv6_link_local() { - assert!(is_blocked_ip(&"fe80::1".parse().unwrap())); + assert!(is_non_public_ip(&"fe80::1".parse().unwrap())); } #[test] fn blocks_ipv6_unique_local() { - assert!(is_blocked_ip(&"fc00::1".parse().unwrap())); - assert!(is_blocked_ip(&"fd00::1".parse().unwrap())); + assert!(is_non_public_ip(&"fc00::1".parse().unwrap())); + assert!(is_non_public_ip(&"fd00::1".parse().unwrap())); } #[test] fn blocks_ipv4_mapped_ipv6_private() { - assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::().unwrap())); - assert!(is_blocked_ip( + assert!(is_non_public_ip( + &"::ffff:10.0.0.1".parse::().unwrap() + )); + assert!(is_non_public_ip( &"::ffff:192.168.1.1".parse::().unwrap() )); } #[test] fn allows_ipv4_mapped_ipv6_public() { - assert!(!is_blocked_ip(&"::ffff:8.8.8.8".parse::().unwrap())); + assert!(!is_non_public_ip( + &"::ffff:8.8.8.8".parse::().unwrap() + )); } #[tokio::test] async fn ssrf_blocks_ip_literal_private() { let url = Url::parse("https://10.0.0.1/secret").unwrap(); - let result = check_ssrf(&url).await; + let result = check_ssrf(&url, false).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("private")); } @@ -214,7 +386,19 @@ mod tests { #[tokio::test] async fn ssrf_allows_ip_literal_public() { let url = Url::parse("https://1.1.1.1/").unwrap(); - let result = check_ssrf(&url).await; + let result = check_ssrf(&url, false).await; assert!(result.is_ok()); } + + #[tokio::test] + async fn ssrf_blocks_loopback_literal_by_default() { + let url = Url::parse("http://127.0.0.1:8080/").unwrap(); + assert!(check_ssrf(&url, false).await.is_err()); + } + + #[tokio::test] + async fn ssrf_allows_loopback_literal_when_opted_in() { + let url = Url::parse("http://127.0.0.1:8080/").unwrap(); + assert!(check_ssrf(&url, true).await.is_ok()); + } } diff --git a/crates/codegen/kigi-tui/docs/user-guide/05-configuration.md b/crates/codegen/kigi-tui/docs/user-guide/05-configuration.md index 8c29b4e..835e65f 100644 --- a/crates/codegen/kigi-tui/docs/user-guide/05-configuration.md +++ b/crates/codegen/kigi-tui/docs/user-guide/05-configuration.md @@ -225,8 +225,16 @@ timeout_secs = 1800 # seconds to wait when enabled (default: [toolset.web_fetch] proxy_endpoint = "https://proxy.example.com" # egress proxy URL allowed_domains = ["docs.rs", "x.ai"] # override the built-in allowlist +allow_local = false # true = reach localhost / 127.0.0.0/8 / ::1 ``` +`allow_local` opens **loopback only**, and only when the URL names it +explicitly (`http://127.0.0.1:8080/`, `http://localhost:3000/`). A public +domain whose DNS record points at loopback stays blocked — that is DNS +rebinding, not local development. Private, link-local, CGNAT and cloud +metadata ranges are never reachable, with or without this flag. Precedence: +user config → `KIGI_WEB_FETCH_ALLOW_LOCAL` → off. + `[toolset.ask_user_question]` is honored across **requirements.toml**, **managed config**, and **user `config.toml`**. Precedence: requirements → env (`KIGI_ASK_USER_QUESTION_TIMEOUT_ENABLED` / diff --git a/crates/codegen/kigi-workspace/src/session/tool_config.rs b/crates/codegen/kigi-workspace/src/session/tool_config.rs index eeff262..1528d5d 100644 --- a/crates/codegen/kigi-workspace/src/session/tool_config.rs +++ b/crates/codegen/kigi-workspace/src/session/tool_config.rs @@ -325,6 +325,9 @@ fn build_web_fetch_config() -> kigi_tools::implementations::kigi::web_fetch::Web if let Ok(proxy) = std::env::var("KIGI_WEB_FETCH_PROXY") { params.proxy_endpoint = Some(proxy); } + if kigi_config::env_bool("KIGI_WEB_FETCH_ALLOW_LOCAL") == Some(true) { + params.allow_local = Some(true); + } WebFetchConfig::Enabled { params } } #[cfg(any(test, feature = "test-support"))]