fix(hooks): share one SSRF policy instead of a second, stale copy

The hook runner carried its own `is_blocked_ip`, a line-for-line copy of
the pre-hardening web_fetch predicate: loopback allowed unconditionally,
and no TEST-NET, 198.18/15, 240/4, 0.0.0.0/8, multicast, IPv6 site-local
or embedded-v4 wrapper coverage. Hook URLs come from settings, and
project settings from an untrusted repo are loaded today, so the gap is
reachable.

`kigi-hooks` already depends on `kigi-tools`, so the fix is to delete the
copy and call the shared predicate — no new crate, and no third
implementation to drift.

`allow_local` is on for hooks: a loopback hook receiver is a legitimate
local setup, which the old copy also allowed. It is now allowed only
when the URL names the host literally, so a public name resolving to
loopback is refused.

Range coverage now lives with the policy; the runner's tests pin what it
adds on top. The URL-scrubbing regression test moves off TEST-NET-1,
which the policy now blocks, onto a closed loopback port — faster, and
no real egress from a test.
This commit is contained in:
2026-07-27 13:01:50 -04:00
parent 74402f3078
commit bac8470c80
3 changed files with 61 additions and 139 deletions
@@ -14,7 +14,7 @@ pub mod domain;
pub mod error;
mod http;
pub(crate) mod overflow;
mod ssrf;
pub mod ssrf;
pub use client::WebFetchClient;
pub use config::WebFetchParams;
@@ -16,7 +16,7 @@ use super::error::WebFetchError;
/// Hosts allowed to reach loopback when local access is on.
///
/// Names that merely RESOLVE to loopback are excluded: DNS rebinding.
pub(crate) fn is_explicit_local_host(host: &str) -> bool {
pub fn is_explicit_local_host(host: &str) -> bool {
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
let host = host
.strip_prefix('[')
@@ -32,7 +32,7 @@ pub(crate) fn is_explicit_local_host(host: &str) -> bool {
}
/// Whether an IP is not globally routable.
pub(crate) fn is_non_public_ip(ip: &IpAddr) -> bool {
pub fn is_non_public_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_non_public_ipv4(*v4),
IpAddr::V6(v6) => is_non_public_ipv6(*v6),
@@ -126,7 +126,8 @@ fn is_loopback_addr(ip: &IpAddr) -> bool {
/// 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 {
/// Shared with the hook runner: one policy, every outbound URL.
pub fn is_blocked_for_host(ip: &IpAddr, host: &str, allow_local: bool) -> bool {
if !is_non_public_ip(ip) {
return false;
}