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:
Alex Rosenzweig
2026-07-24 22:47:58 -04:00
committed by GitHub
co-authored by Amp
parent 50655ac097
commit c26bf5945d
+169 -7
View File
@@ -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]