mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(core): block IPv6 transition SSRF targets (#2801)
## Summary - classify IPv4-compatible, IPv4-mapped, and SIIT IPv4-translated IPv6 addresses using the existing IPv4 SSRF policy - decode IPv4 destinations under the RFC 6052 well-known NAT64 prefix - conservatively block local-use NAT64, Teredo, and 6to4 ranges - add boundary coverage for every newly handled transition prefix ## Why The workflow webhook SSRF guard previously recognized IPv4-mapped IPv6 addresses but not other standardized IPv6 forms that can embed or route to IPv4 destinations. Private, loopback, or link-local IPv4 targets represented through those forms could therefore pass address classification. This also covers the legacy SIIT IPv4-translated prefix (`::ffff:0:0:0/96`), which Rust's `Ipv6Addr::to_ipv4()` does not recognize but an SIIT-enabled network may route to the IPv4 value in the final 32 bits. Network-specific NAT64 prefixes remain a deployment concern and should be restricted through egress policy; they cannot be inferred generically from an IPv6 address. ## Test plan - `cargo fmt --all -- --check` - `cargo test -p buzz-core network` (35 passed) - `cargo clippy -p buzz-core --all-targets -- -D warnings` - `git diff --check` --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -3,6 +3,22 @@
|
||||
//! Provides shared helpers used across crates for SSRF protection and
|
||||
//! IP address classification.
|
||||
|
||||
// RFC 6052 well-known NAT64 prefix (64:ff9b::/96).
|
||||
const NAT64_WELL_KNOWN_PREFIX: [u8; 12] = [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
|
||||
// Legacy SIIT IPv4-translated prefix (::ffff:0:0:0/96).
|
||||
const IPV4_TRANSLATED_PREFIX: [u8; 12] = [0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0];
|
||||
|
||||
/// Extract an IPv4 address stored in the final four octets under a `/96` prefix.
|
||||
///
|
||||
/// Using network-order octets directly avoids error-prone segment shifting.
|
||||
fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option<std::net::Ipv4Addr> {
|
||||
let octets = v6.octets();
|
||||
octets
|
||||
.starts_with(prefix)
|
||||
.then(|| std::net::Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]))
|
||||
}
|
||||
|
||||
/// Returns `true` if the IP address is in a private, reserved, or
|
||||
/// loopback range. Used for SSRF protection — webhook targets must
|
||||
/// not resolve to these addresses.
|
||||
@@ -21,7 +37,12 @@
|
||||
/// - IPv6 link-local fe80::/10
|
||||
/// - IPv6 multicast ff00::/8
|
||||
/// - IPv6 documentation 2001:db8::/32 (RFC 3849) — should never appear in production
|
||||
/// - IPv4-mapped IPv6 ::ffff:0:0/96 (checked recursively against IPv4 rules)
|
||||
/// - IPv4-compatible and mapped IPv6 (checked recursively against IPv4 rules)
|
||||
/// - IPv4-translated ::ffff:0:0:0/96 (embedded IPv4 checked recursively)
|
||||
/// - NAT64 well-known 64:ff9b::/96 (embedded IPv4 checked recursively)
|
||||
/// - NAT64 local-use 64:ff9b:1::/48 (RFC 8215)
|
||||
/// - Teredo 2001::/32 (RFC 4380)
|
||||
/// - 6to4 2002::/16 (RFC 3056)
|
||||
pub fn is_private_ip(ip: &std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
@@ -38,17 +59,37 @@ pub fn is_private_ip(ip: &std::net::IpAddr) -> bool {
|
||||
|| (octets[0] == 198 && (octets[1] & 0xFE) == 18)
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
// Check IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) against IPv4 rules.
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
// Check IPv4-compatible and mapped addresses against IPv4 rules.
|
||||
if let Some(v4) = v6.to_ipv4() {
|
||||
return is_private_ip(&std::net::IpAddr::V4(v4));
|
||||
}
|
||||
|
||||
let segments = v6.segments();
|
||||
|
||||
// NAT64 well-known prefix (RFC 6052). Preserve access to public IPv4
|
||||
// destinations while rejecting embedded private/reserved addresses.
|
||||
if let Some(v4) = embedded_ipv4(v6, &NAT64_WELL_KNOWN_PREFIX) {
|
||||
return is_private_ip(&std::net::IpAddr::V4(v4));
|
||||
}
|
||||
|
||||
// Legacy SIIT IPv4-translated addresses can route to the IPv4 value
|
||||
// in their final four octets but are not recognized by `to_ipv4()`.
|
||||
if let Some(v4) = embedded_ipv4(v6, &IPV4_TRANSLATED_PREFIX) {
|
||||
return is_private_ip(&std::net::IpAddr::V4(v4));
|
||||
}
|
||||
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.segments()[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA
|
||||
|| v6.segments()[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local
|
||||
|| v6.segments()[0] & 0xff00 == 0xff00 // ff00::/8 multicast
|
||||
|| segments[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA
|
||||
|| segments[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local
|
||||
|| segments[0] & 0xff00 == 0xff00 // ff00::/8 multicast
|
||||
|| (segments[0] == 0x0064
|
||||
&& segments[1] == 0xff9b
|
||||
&& segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64
|
||||
|| (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo
|
||||
|| segments[0] == 0x2002 // 2002::/16 6to4
|
||||
// RFC 3849 — documentation range, should never appear in production
|
||||
|| (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8)
|
||||
|| (segments[0] == 0x2001 && segments[1] == 0x0db8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +174,127 @@ mod tests {
|
||||
fn test_ipv4_mapped_v6_public() {
|
||||
assert!(!is_private_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv4_compatible_v6_private() {
|
||||
assert!(is_private_ip(&"::10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(&"::127.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"::169.254.169.254".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"::8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_nat64_well_known_prefix() {
|
||||
let first = "64:ff9b::".parse().unwrap();
|
||||
let last = "64:ff9b::ffff:ffff".parse().unwrap();
|
||||
assert_eq!(
|
||||
embedded_ipv4(&first, &NAT64_WELL_KNOWN_PREFIX),
|
||||
Some("0.0.0.0".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
embedded_ipv4(&last, &NAT64_WELL_KNOWN_PREFIX),
|
||||
Some("255.255.255.255".parse().unwrap())
|
||||
);
|
||||
let embedded = "64:ff9b::172.16.1.2".parse().unwrap();
|
||||
assert_eq!(
|
||||
embedded_ipv4(&embedded, &NAT64_WELL_KNOWN_PREFIX),
|
||||
Some("172.16.1.2".parse().unwrap())
|
||||
);
|
||||
assert!(is_private_ip(
|
||||
&"64:ff9b::10.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_private_ip(
|
||||
&"64:ff9b::127.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_private_ip(
|
||||
&"64:ff9b::169.254.169.254".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"64:ff9b::8.8.8.8".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"64:ff9a:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"64:ff9b::1:0:0".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_ipv4_translated_prefix() {
|
||||
let first = "0:0:0:0:ffff:0:0:0".parse().unwrap();
|
||||
let last = "0:0:0:0:ffff:0:ffff:ffff".parse().unwrap();
|
||||
assert_eq!(
|
||||
embedded_ipv4(&first, &IPV4_TRANSLATED_PREFIX),
|
||||
Some("0.0.0.0".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
embedded_ipv4(&last, &IPV4_TRANSLATED_PREFIX),
|
||||
Some("255.255.255.255".parse().unwrap())
|
||||
);
|
||||
assert!(is_private_ip(
|
||||
&"::ffff:0:10.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_private_ip(
|
||||
&"::ffff:0:127.0.0.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(is_private_ip(
|
||||
&"::ffff:0:169.254.169.254".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"::ffff:0:8.8.8.8".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"0:0:0:0:fffe:ffff:ffff:ffff".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"0:0:0:0:ffff:1:0:0".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_nat64_local_use_prefix_boundaries() {
|
||||
assert!(is_private_ip(&"64:ff9b:1::".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"64:ff9b:1:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"64:ff9b::ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"64:ff9b:2::".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_teredo_prefix_boundaries() {
|
||||
assert!(is_private_ip(&"2001::".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"2001:0:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"2000:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"2001:1::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn test_6to4_prefix_boundaries() {
|
||||
assert!(is_private_ip(&"2002::".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(
|
||||
&"2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(
|
||||
&"2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
|
||||
.parse::<IpAddr>()
|
||||
.unwrap()
|
||||
));
|
||||
assert!(!is_private_ip(&"2003::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
|
||||
// CGNAT (RFC 6598) — 100.64.0.0/10
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user