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.
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
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![]));
|
||||
|
||||
Reference in New Issue
Block a user