Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**

Expand Down
107 changes: 92 additions & 15 deletions crates/bashkit/src/network/allowlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading