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]
|
[toolset.web_fetch]
|
||||||
proxy_endpoint = "https://proxy.example.com" # egress proxy URL (all requests routed through it)
|
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
|
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]
|
[shortcuts]
|
||||||
send = ["Enter"]
|
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_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` | 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_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_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_FEEDBACK_ENABLED` | Enable (`1`) or disable (`0`) feedback system independently from telemetry |
|
||||||
| `KIGI_DEPLOYMENT_KEY` | Management API key for enterprise deployments |
|
| `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.
|
/// default allowlist. An explicit empty list blocks all fetches.
|
||||||
/// Resolution: TOML > remote settings > built-in defaults.
|
/// Resolution: TOML > remote settings > built-in defaults.
|
||||||
pub allowed_domains: Option<Vec<String>>,
|
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 {
|
impl WebFetchToolConfig {
|
||||||
@@ -137,10 +141,15 @@ impl WebFetchToolConfig {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| remote_domains.map(|d| d.to_vec()));
|
.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 {
|
kigi_tools::implementations::kigi::web_fetch::WebFetchParams {
|
||||||
proxy_endpoint,
|
proxy_endpoint,
|
||||||
allowed_domains,
|
allowed_domains,
|
||||||
context_window_tokens,
|
context_window_tokens,
|
||||||
|
allow_local,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +493,7 @@ mod tests {
|
|||||||
let local = WebFetchToolConfig {
|
let local = WebFetchToolConfig {
|
||||||
proxy_endpoint: Some("https://toml-proxy.example.com".to_owned()),
|
proxy_endpoint: Some("https://toml-proxy.example.com".to_owned()),
|
||||||
allowed_domains: Some(vec!["toml.example.com".to_owned()]),
|
allowed_domains: Some(vec!["toml.example.com".to_owned()]),
|
||||||
|
allow_local: Some(true),
|
||||||
};
|
};
|
||||||
let params = local.resolve_params(
|
let params = local.resolve_params(
|
||||||
Some("https://remote-proxy.example.com"),
|
Some("https://remote-proxy.example.com"),
|
||||||
@@ -498,6 +508,7 @@ mod tests {
|
|||||||
params.allowed_domains,
|
params.allowed_domains,
|
||||||
Some(vec!["toml.example.com".to_owned()])
|
Some(vec!["toml.example.com".to_owned()])
|
||||||
);
|
);
|
||||||
|
assert!(params.allow_local(), "the opt-in must reach the tool");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -524,6 +535,7 @@ mod tests {
|
|||||||
let params = local.resolve_params(None, None, None);
|
let params = local.resolve_params(None, None, None);
|
||||||
assert!(params.proxy_endpoint.is_none());
|
assert!(params.proxy_endpoint.is_none());
|
||||||
assert!(params.allowed_domains.is_none());
|
assert!(params.allowed_domains.is_none());
|
||||||
|
assert!(!params.allow_local(), "local access is off by default");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -531,6 +543,7 @@ mod tests {
|
|||||||
let local = WebFetchToolConfig {
|
let local = WebFetchToolConfig {
|
||||||
proxy_endpoint: None,
|
proxy_endpoint: None,
|
||||||
allowed_domains: Some(vec![]),
|
allowed_domains: Some(vec![]),
|
||||||
|
allow_local: None,
|
||||||
};
|
};
|
||||||
let params = local.resolve_params(None, Some(&["remote.example.com".to_owned()]), None);
|
let params = local.resolve_params(None, Some(&["remote.example.com".to_owned()]), None);
|
||||||
assert_eq!(params.allowed_domains, Some(vec![]));
|
assert_eq!(params.allowed_domains, Some(vec![]));
|
||||||
|
|||||||
@@ -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
|
// Kimi fetch service first (OAuth sessions); local pipeline is the
|
||||||
// fallback on any service failure (kimi-cli fetch.py `__call__`).
|
// fallback on any service failure (kimi-cli fetch.py `__call__`).
|
||||||
if let Some(service_url) = self.params.service_url.clone() {
|
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 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,
|
Ok(result) => result,
|
||||||
Err(e @ WebFetchError::HttpRequest(_)) => {
|
Err(e @ WebFetchError::HttpRequest(_)) => {
|
||||||
self.http.invalidate();
|
self.http.invalidate();
|
||||||
@@ -380,6 +388,8 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
|||||||
|
|
||||||
if let Some(host) = parsed.host_str()
|
if let Some(host) = parsed.host_str()
|
||||||
&& host.split('.').count() < 2
|
&& 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 {
|
return Err(WebFetchError::SingleLabelHost {
|
||||||
host: host.to_string(),
|
host: host.to_string(),
|
||||||
@@ -390,9 +400,16 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn upgrade_to_https(url: &mut Url) {
|
fn upgrade_to_https(url: &mut Url) {
|
||||||
if url.scheme() == "http" {
|
if url.scheme() != "http" {
|
||||||
let _ = url.set_scheme("https");
|
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 {
|
enum FetchResult {
|
||||||
@@ -409,15 +426,21 @@ enum FetchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a URL with manual same-host redirect handling.
|
/// 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(
|
async fn fetch_url(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
url: &Url,
|
url: &Url,
|
||||||
max_content_length: usize,
|
max_content_length: usize,
|
||||||
|
allow_local: bool,
|
||||||
) -> Result<FetchResult, WebFetchError> {
|
) -> Result<FetchResult, WebFetchError> {
|
||||||
let mut current_url = url.clone();
|
let mut current_url = url.clone();
|
||||||
let mut hops = 0;
|
let mut hops = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
ssrf::check_ssrf(¤t_url, allow_local).await?;
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.get(current_url.as_str())
|
.get(current_url.as_str())
|
||||||
.header(USER_AGENT, USER_AGENT_STRING)
|
.header(USER_AGENT, USER_AGENT_STRING)
|
||||||
@@ -439,10 +462,13 @@ async fn fetch_url(
|
|||||||
|
|
||||||
if let Some(location) = resp.headers().get("location") {
|
if let Some(location) = resp.headers().get("location") {
|
||||||
let location_str = location.to_str().unwrap_or("");
|
let location_str = location.to_str().unwrap_or("");
|
||||||
let next_url = current_url
|
let mut next_url = current_url
|
||||||
.join(location_str)
|
.join(location_str)
|
||||||
.map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?;
|
.map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?;
|
||||||
if is_same_host(¤t_url, &next_url) {
|
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;
|
current_url = next_url;
|
||||||
continue;
|
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 is_same_host(a: &Url, b: &Url) -> bool {
|
||||||
fn strip_www(h: &str) -> &str {
|
a.host_str() == b.host_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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_media_session_folder(session_folder: Option<&Path>) -> Result<&Path, WebFetchError> {
|
fn require_media_session_folder(session_folder: Option<&Path>) -> Result<&Path, WebFetchError> {
|
||||||
@@ -963,6 +987,78 @@ mod tests {
|
|||||||
assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}");
|
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("<p>local</p>"))
|
||||||
|
.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 {
|
fn test_converter() -> htmd::HtmlToMarkdown {
|
||||||
htmd::HtmlToMarkdown::builder()
|
htmd::HtmlToMarkdown::builder()
|
||||||
.skip_tags(vec![
|
.skip_tags(vec![
|
||||||
@@ -1024,9 +1120,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_url_rejects_single_label_hosts() {
|
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://intranet/foo").is_err());
|
||||||
assert!(validate_url("http://metadata/computeMetadata").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]
|
#[test]
|
||||||
@@ -1068,6 +1167,19 @@ mod tests {
|
|||||||
assert_eq!(url.scheme(), "https");
|
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]
|
#[test]
|
||||||
fn same_host_exact_match() {
|
fn same_host_exact_match() {
|
||||||
let a = Url::parse("https://example.com/a").unwrap();
|
let a = Url::parse("https://example.com/a").unwrap();
|
||||||
@@ -1075,12 +1187,24 @@ mod tests {
|
|||||||
assert!(is_same_host(&a, &b));
|
assert!(is_same_host(&a, &b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An absolute `http://` Location must not downgrade a followed hop.
|
||||||
#[test]
|
#[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 a = Url::parse("https://example.com/a").unwrap();
|
||||||
let c = Url::parse("https://www.example.com/a").unwrap();
|
let c = Url::parse("https://www.example.com/a").unwrap();
|
||||||
assert!(is_same_host(&a, &c));
|
assert!(!is_same_host(&a, &c));
|
||||||
assert!(is_same_host(&c, &a));
|
assert!(!is_same_host(&c, &a));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -47,12 +47,20 @@ pub struct WebFetchParams {
|
|||||||
/// on any failure (kimi-cli `tools/web/fetch.py FetchURL.__call__`).
|
/// on any failure (kimi-cli `tools/web/fetch.py FetchURL.__call__`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub service_url: Option<String>,
|
pub service_url: Option<String>,
|
||||||
|
/// Opt-in for loopback targets; off means no local access.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_local: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
register_resource!("kigi", "WebFetch", WebFetchParams);
|
register_resource!("kigi", "WebFetch", WebFetchParams);
|
||||||
|
|
||||||
// Keep defaults here so call-sites don't have to manage unwrapping.
|
// Keep defaults here so call-sites don't have to manage unwrapping.
|
||||||
impl WebFetchParams {
|
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 {
|
pub fn cache_ttl_secs(&self) -> Duration {
|
||||||
Duration::from_secs(self.cache_ttl_secs.unwrap_or(15 * 60))
|
Duration::from_secs(self.cache_ttl_secs.unwrap_or(15 * 60))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
//! Non-public targets are blocked: loopback, RFC 1918, link-local,
|
||||||
//! cloud metadata ranges before allowing outbound HTTP requests.
|
//! 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/)
|
//! 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 url::Url;
|
||||||
|
|
||||||
use super::error::WebFetchError;
|
use super::error::WebFetchError;
|
||||||
|
|
||||||
/// Returns `true` if an IP address is in a private, link-local, or cloud
|
/// Hosts allowed to reach loopback when local access is on.
|
||||||
/// metadata range that should be blocked to prevent SSRF attacks.
|
|
||||||
///
|
///
|
||||||
/// **Allowed:** loopback (`127.x` / `::1`) for local development.
|
/// Names that merely RESOLVE to loopback are excluded: DNS rebinding.
|
||||||
/// **Blocked:** RFC 1918, link-local, CGNAT/cloud metadata, unspecified.
|
pub(crate) fn is_explicit_local_host(host: &str) -> bool {
|
||||||
pub(crate) fn is_blocked_ip(ip: &IpAddr) -> 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::<IpAddr>().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 {
|
match ip {
|
||||||
IpAddr::V4(v4) => {
|
IpAddr::V4(v4) => is_non_public_ipv4(*v4),
|
||||||
let octets = v4.octets();
|
IpAddr::V6(v6) => is_non_public_ipv6(*v6),
|
||||||
// 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 {
|
|
||||||
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
// RFC 1918: 172.16.0.0/12 — private network.
|
if let Some(v4) = embedded_ipv4(ip) {
|
||||||
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
|
return is_non_public_ipv4(v4);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
// RFC 1918: 192.168.0.0/16 — private network.
|
let seg = ip.segments();
|
||||||
if octets[0] == 192 && octets[1] == 168 {
|
ip.is_unique_local()
|
||||||
return true;
|
|| 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<Ipv4Addr> {
|
||||||
|
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]));
|
||||||
}
|
}
|
||||||
// RFC 3927: 169.254.0.0/16 — link-local.
|
if seg[0] == 0x2002 {
|
||||||
// Includes AWS/GCP/Azure metadata endpoint 169.254.169.254.
|
return Some(embedded(seg[1], seg[2]));
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
// 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) => {
|
IpAddr::V6(v6) => {
|
||||||
// ::1 — loopback, allowed for local dev.
|
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve hostname via DNS and verify none of the resolved addresses are
|
/// Dual gate: loopback opens only for an explicit local host.
|
||||||
/// in blocked private/link-local ranges.
|
///
|
||||||
pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
/// 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
|
let host = url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or_else(|| WebFetchError::SingleLabelHost {
|
.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::<IpAddr>() {
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||||
if is_blocked_ip(&ip) {
|
if is_blocked_for_host(&ip, host, allow_local) {
|
||||||
return Err(WebFetchError::SsrfBlocked {
|
return Err(WebFetchError::SsrfBlocked {
|
||||||
host: host.to_string(),
|
host: host.to_string(),
|
||||||
ip,
|
ip,
|
||||||
@@ -112,7 +169,7 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
|||||||
|
|
||||||
addrs
|
addrs
|
||||||
.iter()
|
.iter()
|
||||||
.find(|addr| is_blocked_ip(&addr.ip()))
|
.find(|addr| is_blocked_for_host(&addr.ip(), host, allow_local))
|
||||||
.map_or(Ok(()), |addr| {
|
.map_or(Ok(()), |addr| {
|
||||||
Err(WebFetchError::SsrfBlocked {
|
Err(WebFetchError::SsrfBlocked {
|
||||||
host: host.to_string(),
|
host: host.to_string(),
|
||||||
@@ -127,86 +184,201 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_rfc1918_10x() {
|
fn blocks_rfc1918_10x() {
|
||||||
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"10.0.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap()));
|
assert!(is_non_public_ip(&"10.255.255.255".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_rfc1918_172x() {
|
fn blocks_rfc1918_172x() {
|
||||||
assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"172.16.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap()));
|
assert!(is_non_public_ip(&"172.31.255.255".parse().unwrap()));
|
||||||
assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap()));
|
assert!(!is_non_public_ip(&"172.15.0.1".parse().unwrap()));
|
||||||
assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap()));
|
assert!(!is_non_public_ip(&"172.32.0.1".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_rfc1918_192168() {
|
fn blocks_rfc1918_192168() {
|
||||||
assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"192.168.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap()));
|
assert!(is_non_public_ip(&"192.168.255.255".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_link_local() {
|
fn blocks_link_local() {
|
||||||
assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"169.254.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap()));
|
assert!(is_non_public_ip(&"169.254.169.254".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_cgnat_cloud_metadata() {
|
fn blocks_cgnat_cloud_metadata() {
|
||||||
assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"100.64.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap()));
|
assert!(is_non_public_ip(&"100.127.255.255".parse().unwrap()));
|
||||||
assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap()));
|
assert!(!is_non_public_ip(&"100.63.0.1".parse().unwrap()));
|
||||||
assert!(!is_blocked_ip(&"100.128.0.1".parse().unwrap()));
|
assert!(!is_non_public_ip(&"100.128.0.1".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_unspecified() {
|
fn blocks_unspecified() {
|
||||||
assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap()));
|
assert!(is_non_public_ip(&"0.0.0.0".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"::".parse().unwrap()));
|
assert!(is_non_public_ip(&"::".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allows_loopback() {
|
fn blocks_loopback_by_default() {
|
||||||
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap()));
|
for ip in ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"] {
|
||||||
assert!(!is_blocked_ip(&"127.0.0.2".parse().unwrap()));
|
let ip: IpAddr = ip.parse().unwrap();
|
||||||
assert!(!is_blocked_ip(&"::1".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]
|
#[test]
|
||||||
fn allows_public_ips() {
|
fn allows_public_ips() {
|
||||||
assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap()));
|
for ip in [
|
||||||
assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap()));
|
"1.1.1.1",
|
||||||
assert!(!is_blocked_ip(&"142.250.80.46".parse().unwrap()));
|
"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]
|
#[test]
|
||||||
fn blocks_ipv6_link_local() {
|
fn blocks_ipv6_link_local() {
|
||||||
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
|
assert!(is_non_public_ip(&"fe80::1".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_ipv6_unique_local() {
|
fn blocks_ipv6_unique_local() {
|
||||||
assert!(is_blocked_ip(&"fc00::1".parse().unwrap()));
|
assert!(is_non_public_ip(&"fc00::1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"fd00::1".parse().unwrap()));
|
assert!(is_non_public_ip(&"fd00::1".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_ipv4_mapped_ipv6_private() {
|
fn blocks_ipv4_mapped_ipv6_private() {
|
||||||
assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
|
assert!(is_non_public_ip(
|
||||||
assert!(is_blocked_ip(
|
&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()
|
||||||
|
));
|
||||||
|
assert!(is_non_public_ip(
|
||||||
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
|
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allows_ipv4_mapped_ipv6_public() {
|
fn allows_ipv4_mapped_ipv6_public() {
|
||||||
assert!(!is_blocked_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
|
assert!(!is_non_public_ip(
|
||||||
|
&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ssrf_blocks_ip_literal_private() {
|
async fn ssrf_blocks_ip_literal_private() {
|
||||||
let url = Url::parse("https://10.0.0.1/secret").unwrap();
|
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.is_err());
|
||||||
assert!(result.unwrap_err().to_string().contains("private"));
|
assert!(result.unwrap_err().to_string().contains("private"));
|
||||||
}
|
}
|
||||||
@@ -214,7 +386,19 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ssrf_allows_ip_literal_public() {
|
async fn ssrf_allows_ip_literal_public() {
|
||||||
let url = Url::parse("https://1.1.1.1/").unwrap();
|
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());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,8 +225,16 @@ timeout_secs = 1800 # seconds to wait when enabled (default:
|
|||||||
[toolset.web_fetch]
|
[toolset.web_fetch]
|
||||||
proxy_endpoint = "https://proxy.example.com" # egress proxy URL
|
proxy_endpoint = "https://proxy.example.com" # egress proxy URL
|
||||||
allowed_domains = ["docs.rs", "x.ai"] # override the built-in allowlist
|
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
|
`[toolset.ask_user_question]` is honored across **requirements.toml**, **managed
|
||||||
config**, and **user `config.toml`**. Precedence: requirements → env
|
config**, and **user `config.toml`**. Precedence: requirements → env
|
||||||
(`KIGI_ASK_USER_QUESTION_TIMEOUT_ENABLED` /
|
(`KIGI_ASK_USER_QUESTION_TIMEOUT_ENABLED` /
|
||||||
|
|||||||
@@ -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") {
|
if let Ok(proxy) = std::env::var("KIGI_WEB_FETCH_PROXY") {
|
||||||
params.proxy_endpoint = Some(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 }
|
WebFetchConfig::Enabled { params }
|
||||||
}
|
}
|
||||||
#[cfg(any(test, feature = "test-support"))]
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
|
|||||||
Reference in New Issue
Block a user