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
21 changes: 21 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2714,6 +2714,27 @@ mod tests {
"Instance not found"
));
}

// --- H6: CGNAT SSRF blocklist ---

#[pg_test]
fn test_ssrf_blocks_cgnat_range() {
use std::net::{IpAddr, Ipv4Addr};
// 100.64.0.0/10 must be blocked
assert!(
crate::ssrf::check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))).is_some(),
"100.64.0.1 (CGNAT) should be blocked"
);
assert!(
crate::ssrf::check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 127, 255, 254))).is_some(),
"100.127.255.254 (CGNAT) should be blocked"
);
// Outside CGNAT range should be allowed
assert!(
crate::ssrf::check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1))).is_none(),
"100.128.0.1 (NOT CGNAT) should be allowed"
);
}
}

/// Required by `cargo pgrx test`
Expand Down
36 changes: 32 additions & 4 deletions src/ssrf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ fn check_blocked_ipv4(ip: Ipv4Addr) -> Option<&'static str> {
match octets {
[0, ..] => Some("reserved (0.0.0.0/8)"),
[10, ..] => Some("private (10.0.0.0/8)"),
[100, b, ..] if (64..=127).contains(&b) => Some("shared/CGNAT (100.64.0.0/10)"),
[127, ..] => Some("loopback (127.0.0.0/8)"),
[169, 254, ..] => Some("link-local (169.254.0.0/16)"),
[172, b, ..] if (16..=31).contains(&b) => Some("private (172.16.0.0/12)"),
Expand Down Expand Up @@ -236,7 +237,10 @@ pub fn validate_url_allowlist(url: &str) -> Result<(), String> {
/// Extract the hostname (without port or brackets) from a URL.
///
/// Returns `None` for malformed URLs or URLs without a `://` scheme separator.
#[cfg(not(feature = "http-allow-all"))]
#[cfg(any(
feature = "http-allow-azure-domains",
feature = "http-allow-test-domains"
))]
fn extract_host(url: &str) -> Option<String> {
// Strip scheme
let after_scheme = url.find("://").map(|i| &url[i + 3..])?;
Expand Down Expand Up @@ -420,6 +424,21 @@ mod tests {
assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(0, 255, 255, 255))).is_some());
}

#[cfg(not(feature = "http-allow-all"))]
#[test]
fn blocks_cgnat_rfc6598() {
// 100.64.0.0/10 — Carrier-Grade NAT (RFC 6598)
// Used by cloud providers for internal routing / metadata
assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 0))).is_some());
assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))).is_some());
assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 100, 100, 100))).is_some());
assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 127, 255, 255))).is_some());
// Edge: 100.63.x.x is NOT CGNAT
assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 63, 255, 255))).is_none());
// Edge: 100.128.x.x is NOT CGNAT
assert!(check_blocked_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 0))).is_none());
}

// --- IPv4 allowed (public) ---

#[test]
Expand Down Expand Up @@ -539,7 +558,10 @@ mod tests {

// --- extract_host helper ---

#[cfg(not(feature = "http-allow-all"))]
#[cfg(any(
feature = "http-allow-azure-domains",
feature = "http-allow-test-domains"
))]
#[test]
fn extract_host_basic() {
assert_eq!(
Expand All @@ -555,7 +577,10 @@ mod tests {
assert_eq!(extract_host("http://user:pass@host/p"), Some("host".into()));
}

#[cfg(not(feature = "http-allow-all"))]
#[cfg(any(
feature = "http-allow-azure-domains",
feature = "http-allow-test-domains"
))]
#[test]
fn extract_host_query_and_fragment() {
// Query-only URL (no path slash after authority)
Expand Down Expand Up @@ -585,7 +610,10 @@ mod tests {
);
}

#[cfg(not(feature = "http-allow-all"))]
#[cfg(any(
feature = "http-allow-azure-domains",
feature = "http-allow-test-domains"
))]
#[test]
fn extract_host_none_cases() {
assert_eq!(extract_host("no-scheme"), None);
Expand Down
Loading