diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 99439bec9..56119f7c1 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -384,6 +384,7 @@ Network access is disabled by default. When enabled, strict controls apply. | IPv4-mapped IPv6 SSRF bypass (TM-NET-022) | AAAA returns `::ffff:127.0.0.1` / metadata IP | `is_private_ip` normalizes v4-mapped/compatible v6 to v4 and applies the v4 classifier | FIXED | | HTTP-transport SSRF via fail-open precheck (TM-NET-023) | Malformed/no-host URL or rebind window bypasses the IP filter | Precheck fails closed on bad URLs; transports receive pinned addresses + `is_private_ip` | MITIGATED | | Repeated curl data bypasses body cap (TM-NET-028) | Many data/file parts plus encoding expansion exceed 10 MB in aggregate | Checked aggregate appends; file metadata checked against remaining capacity before reads | MITIGATED | +| Private IP behind NAT64/6to4 or reserved range (TM-NET-029) | AAAA `64:ff9b::a9fe:a9fe` / `2002:7f00:1::`, or A in `0.0.0.0/8`, `198.18.0.0/15`, multicast | `is_private_ip` checks the embedded IPv4 of NAT64/6to4 and blocks all non-global special-purpose ranges | MITIGATED | **Credential Injection (TM-NET-024–027):** diff --git a/crates/bashkit/src/network/allowlist.rs b/crates/bashkit/src/network/allowlist.rs index 01af49b5f..9535a7c62 100644 --- a/crates/bashkit/src/network/allowlist.rs +++ b/crates/bashkit/src/network/allowlist.rs @@ -22,16 +22,26 @@ use url::Url; /// Check if an IP address is in a private/reserved range. /// -/// Blocks: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, -/// 169.254.0.0/16, 100.64.0.0/10, 0.0.0.0, ::1, fd00::/8, fe80::/10, :: +/// Blocks every range that is not globally reachable unicast: +/// - v4: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, +/// 172.16.0.0/12, 192.0.0.0/24, 192.168.0.0/16, 198.18.0.0/15, +/// 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved + broadcast) +/// - v6: ::, ::1, 100::/64, 2001::/32 (Teredo), fc00::/7, fe80::/10, +/// fec0::/10, ff00::/8, 64:ff9b:1::/48 (local-use NAT64) /// -/// IPv4-mapped IPv6 addresses (`::ffff:0:0/96`) are normalized to their -/// embedded IPv4 form and classified using the v4 rules. This is critical: -/// without it, an attacker who controls DNS for an allowlisted hostname -/// can return an AAAA record pointing at e.g. `::ffff:127.0.0.1` or -/// `::ffff:169.254.169.254` and bypass the v4 private-range checks. +/// Documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, +/// 2001:db8::/32) are not blocked: they are unroutable, and tests use them +/// as stand-in public addresses. /// -/// # Security (TM-NET-002, TM-NET-004, TM-NET-008) +/// v6 forms that carry a v4 address are classified by that v4 address: +/// IPv4-mapped (`::ffff:0:0/96`), IPv4-compatible (`::a.b.c.d`), NAT64 +/// (`64:ff9b::/96`, RFC 6052) and 6to4 (`2002::/16`, RFC 3056). This is +/// critical: without it, an attacker who controls DNS for an allowlisted +/// hostname can return an AAAA record such as `::ffff:127.0.0.1`, +/// `64:ff9b::a9fe:a9fe` or `2002:7f00:1::` and reach loopback or cloud +/// metadata past the v4 checks. +/// +/// # Security (TM-NET-002, TM-NET-004, TM-NET-008, TM-NET-029) /// /// Used to prevent SSRF attacks where an allowed hostname resolves /// to an internal/cloud metadata IP address. @@ -47,27 +57,47 @@ pub fn is_private_ip(ip: &IpAddr) -> bool { if let Some(v4) = v6.to_ipv4_mapped() { return is_private_ipv4(&v4); } + let seg = v6.segments(); if let Some(v4) = v6.to_ipv4() - && v6.segments()[..6].iter().all(|s| *s == 0) - && v6.segments()[6] != 0 + && seg[..6].iter().all(|s| *s == 0) + && seg[6] != 0 { // ::a.b.c.d (IPv4-compatible) — apply v4 rules. return is_private_ipv4(&v4); } + // SECURITY (TM-NET-029): NAT64 and 6to4 gateways forward to the + // embedded v4 address, so classify by it. + if seg[..6] == [0x64, 0xff9b, 0, 0, 0, 0] { + let o = v6.octets(); + return is_private_ipv4(&std::net::Ipv4Addr::new(o[12], o[13], o[14], o[15])); + } + if seg[0] == 0x2002 { + let o = v6.octets(); + return is_private_ipv4(&std::net::Ipv4Addr::new(o[2], o[3], o[4], o[5])); + } v6.is_loopback() // ::1 || v6.is_unspecified() // :: - || (v6.segments()[0] & 0xfe00) == 0xfc00 // fd00::/8 (unique local) - || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 (link-local) + || seg[..4] == [0x100, 0, 0, 0] // 100::/64 (discard-only) + || seg[..2] == [0x2001, 0] // 2001::/32 (Teredo, embeds obfuscated v4) + || seg[..3] == [0x64, 0xff9b, 1] // 64:ff9b:1::/48 (local-use NAT64) + || (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 (unique local) + || (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 (link-local) + || (seg[0] & 0xffc0) == 0xfec0 // fec0::/10 (deprecated site-local) + || (seg[0] & 0xff00) == 0xff00 // ff00::/8 (multicast) } } } fn is_private_ipv4(v4: &std::net::Ipv4Addr) -> bool { - v4.is_loopback() // 127.0.0.0/8 + let o = v4.octets(); + o[0] == 0 // 0.0.0.0/8 ("this network") + || v4.is_loopback() // 127.0.0.0/8 || v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 || v4.is_link_local() // 169.254.0.0/16 - || v4.is_unspecified() // 0.0.0.0 - || v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT) + || o[0] == 100 && (o[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT) + || o[0] == 192 && o[1] == 0 && o[2] == 0 // 192.0.0.0/24 (IETF protocol assignments) + || o[0] == 198 && (o[1] & 0xFE) == 18 // 198.18.0.0/15 (benchmarking) + || o[0] >= 224 // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, broadcast } /// Redact credentials from a URL for safe inclusion in error messages. @@ -579,6 +609,53 @@ mod tests { )); } + #[test] + fn test_is_private_ip_nat64_embeds_v4() { + // NAT64 (RFC 6052 well-known prefix 64:ff9b::/96) routes to the + // embedded v4 address: 64:ff9b::7f00:1 reaches 127.0.0.1 on a + // NAT64 network. Classify by the embedded address. + assert!(is_private_ip(&"64:ff9b::7f00:1".parse().unwrap())); + assert!(is_private_ip(&"64:ff9b::a9fe:a9fe".parse().unwrap())); // 169.254.169.254 + assert!(is_private_ip(&"64:ff9b::a00:1".parse().unwrap())); // 10.0.0.1 + assert!(!is_private_ip(&"64:ff9b::808:808".parse().unwrap())); // 8.8.8.8 + // RFC 8215 local-use NAT64 prefix: translator is site-specific. + assert!(is_private_ip(&"64:ff9b:1::808:808".parse().unwrap())); + } + + #[test] + fn test_is_private_ip_6to4_embeds_v4() { + // 6to4 (2002::/16) carries a v4 address in bits 16..48. + assert!(is_private_ip(&"2002:7f00:1::1".parse().unwrap())); // 127.0.0.1 + assert!(is_private_ip(&"2002:a9fe:a9fe::".parse().unwrap())); // 169.254.169.254 + assert!(is_private_ip(&"2002:c0a8:101::1".parse().unwrap())); // 192.168.1.1 + assert!(!is_private_ip(&"2002:808:808::1".parse().unwrap())); // 8.8.8.8 + } + + #[test] + fn test_is_private_ip_reserved_v4_ranges() { + assert!(is_private_ip(&"0.1.2.3".parse().unwrap())); // 0.0.0.0/8 "this network" + assert!(is_private_ip(&"192.0.0.8".parse().unwrap())); // 192.0.0.0/24 IETF assignments + assert!(is_private_ip(&"198.18.0.1".parse().unwrap())); // 198.18.0.0/15 benchmarking + assert!(is_private_ip(&"198.19.255.255".parse().unwrap())); + assert!(is_private_ip(&"224.0.0.1".parse().unwrap())); // multicast + assert!(is_private_ip(&"239.255.255.250".parse().unwrap())); + assert!(is_private_ip(&"240.0.0.1".parse().unwrap())); // reserved + assert!(is_private_ip(&"255.255.255.255".parse().unwrap())); // broadcast + assert!(!is_private_ip(&"198.20.0.1".parse().unwrap())); + assert!(!is_private_ip(&"223.255.255.255".parse().unwrap())); + } + + #[test] + fn test_is_private_ip_reserved_v6_ranges() { + assert!(is_private_ip(&"ff02::1".parse().unwrap())); // multicast + assert!(is_private_ip(&"fec0::1".parse().unwrap())); // deprecated site-local + assert!(is_private_ip(&"fc00::1".parse().unwrap())); // ULA, fc00::/7 lower half + assert!(is_private_ip(&"100::1".parse().unwrap())); // discard-only 100::/64 + assert!(is_private_ip(&"2001::1".parse().unwrap())); // Teredo 2001::/32 + assert!(!is_private_ip(&"2001:4860:4860::8888".parse().unwrap())); + assert!(!is_private_ip(&"2606:4700:4700::1111".parse().unwrap())); + } + #[test] fn test_is_private_ip_v4_mapped_v6_loopback() { // Regression for #1569 (TM-NET-008): IPv4-mapped IPv6 addresses diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 97712b050..99dd3b98f 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -789,6 +789,7 @@ matches literal host strings exactly, never resolved IPs; no DNS lookup. | TM-NET-022 | IPv4-mapped IPv6 SSRF bypass | AAAA record returns `::ffff:127.0.0.1`, `::ffff:10.0.0.1`, `::ffff:169.254.169.254`, etc., embedded v4 address would re-enter the v4 address space at the kernel/socket layer, but the v6 branch of `is_private_ip` only inspected `is_loopback`/`is_unspecified`/`fd00::/8`/`fe80::/10`, treating everything else as public | `is_private_ip` normalizes IPv4-mapped (`::ffff:0:0/96`) and IPv4-compatible (`::a.b.c.d`) v6 forms back to v4 via `Ipv6Addr::to_ipv4_mapped()` and applies the v4 classifier (`is_private_ipv4`). Tested via `test_is_private_ip_v4_mapped_v6_*` cases for loopback, RFC1918, AWS metadata (169.254.169.254), CGNAT, unspecified, public, and IPv4-compatible. Mitigation reaches both the v6 connect-time `PrivateIpFilteringResolver` and the URL/precheck path. | **FIXED** (#1569) | | TM-NET-023 | HTTP-handler SSRF via fail-open precheck and rebind window | `HttpClient::check_private_ip` returned `Ok(())` on URL-parse failures and on URLs with no host, letting malformed targets reach the connect path with no IP filter. Custom `HttpHandler` implementations are doubly exposed because they don't get reqwest's connect-time `PrivateIpFilteringResolver`, even a successful precheck leaves a rebind window between validation and the moment the handler opens its own socket. | (1) `check_private_ip` now fails closed on malformed URLs and on URLs with no host. The direct-IP branch and the successful-DNS branch remain fail-closed against private addresses (including the v4-mapped IPv6 forms covered by TM-NET-022). Tested via `test_check_private_ip_fails_closed_on_invalid_url`, `test_check_private_ip_fails_closed_on_no_host`, `test_check_private_ip_blocks_literal_private_ip`, `test_check_private_ip_blocks_metadata_via_v4_mapped_v6`. (2) The `HttpTransport` trait doc explicitly assigns SSRF responsibility to network-dialing custom transports and points them at `bashkit::network::is_private_ip` for the same classifier the default reqwest path uses; `HttpTransportRequest.pinned_addrs` additionally forwards the precheck's resolve-then-check result so transports (and host egress boundaries behind them) can pin the validated addresses and close the rebind window (see [HTTP Transport](http-transport.md)). DNS-lookup errors at the precheck still pass through (fail-open by design, the rebind / connect-time threat is the handler's responsibility, and failing closed there breaks legitimate `before_http` hook flows that intentionally target unresolved hostnames). | **MITIGATED** (#1570) | | TM-NET-028 | Repeated curl data parts bypass the request-body cap | Many individually valid `-d`/`--data-*` values, file reads, encoding expansion, and inserted `&` separators combine into an oversized allocation/request | One ordered aggregate uses checked appends; every separator and encoded byte counts toward the 10 MB cap; file metadata is compared with remaining capacity before VFS reads. Exact-boundary and one-byte-over tests cover the invariant. | **MITIGATED** | +| TM-NET-029 | Private IP behind a v4-embedding or reserved v6/v4 form | Allowlisted host returns AAAA `64:ff9b::a9fe:a9fe` (NAT64), `2002:7f00:1::` (6to4), or A in `0.0.0.0/8`, `198.18.0.0/15`, multicast, `240.0.0.0/4` | `is_private_ip` classifies NAT64/6to4 by the embedded v4 address and blocks all non-global special-purpose ranges (Teredo, local-use NAT64, site-local, multicast) at pre-resolve and connect time | **MITIGATED** | **Current Risk**: LOW - Multiple mitigations in place