Skip to content

Commit 6afe945

Browse files
authored
fix(sandbox): block unspecified IPs in SSRF checks (#598)
1 parent 256f7fc commit 6afe945

File tree

1 file changed

+47
-10
lines changed

1 file changed

+47
-10
lines changed

crates/openshell-sandbox/src/proxy.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,18 +1210,20 @@ fn query_tls_mode(
12101210
}
12111211
}
12121212

1213-
/// Check if an IP address is internal (loopback, private RFC1918, or link-local).
1213+
/// Check if an IP address is internal (loopback, private RFC1918, link-local, or unspecified).
12141214
///
12151215
/// This is a defense-in-depth check to prevent SSRF via the CONNECT proxy.
12161216
/// It covers:
1217-
/// - IPv4 loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16)
1218-
/// - IPv6 loopback (`::1`), link-local (`fe80::/10`), ULA (`fc00::/7`)
1217+
/// - IPv4 loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (`0.0.0.0`)
1218+
/// - IPv6 loopback (`::1`), link-local (`fe80::/10`), ULA (`fc00::/7`), unspecified (`::`)
12191219
/// - IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) are unwrapped and checked as IPv4
12201220
fn is_internal_ip(ip: IpAddr) -> bool {
12211221
match ip {
1222-
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
1222+
IpAddr::V4(v4) => {
1223+
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
1224+
}
12231225
IpAddr::V6(v6) => {
1224-
if v6.is_loopback() {
1226+
if v6.is_loopback() || v6.is_unspecified() {
12251227
return true;
12261228
}
12271229
// fe80::/10 — IPv6 link-local
@@ -1234,7 +1236,10 @@ fn is_internal_ip(ip: IpAddr) -> bool {
12341236
}
12351237
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
12361238
if let Some(v4) = v6.to_ipv4_mapped() {
1237-
return v4.is_loopback() || v4.is_private() || v4.is_link_local();
1239+
return v4.is_loopback()
1240+
|| v4.is_private()
1241+
|| v4.is_link_local()
1242+
|| v4.is_unspecified();
12381243
}
12391244
false
12401245
}
@@ -1287,14 +1292,14 @@ async fn resolve_and_reject_internal(
12871292

12881293
/// Check if an IP address is always blocked regardless of policy.
12891294
///
1290-
/// Loopback and link-local addresses are never allowed even when an endpoint
1295+
/// Loopback, link-local, and unspecified addresses are never allowed even when an endpoint
12911296
/// has `allowed_ips` configured. This prevents proxy bypass (loopback) and
12921297
/// cloud metadata SSRF (link-local 169.254.x.x).
12931298
fn is_always_blocked_ip(ip: IpAddr) -> bool {
12941299
match ip {
1295-
IpAddr::V4(v4) => v4.is_loopback() || v4.is_link_local(),
1300+
IpAddr::V4(v4) => v4.is_loopback() || v4.is_link_local() || v4.is_unspecified(),
12961301
IpAddr::V6(v6) => {
1297-
if v6.is_loopback() {
1302+
if v6.is_loopback() || v6.is_unspecified() {
12981303
return true;
12991304
}
13001305
// fe80::/10 — IPv6 link-local
@@ -1303,7 +1308,7 @@ fn is_always_blocked_ip(ip: IpAddr) -> bool {
13031308
}
13041309
// Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
13051310
if let Some(v4) = v6.to_ipv4_mapped() {
1306-
return v4.is_loopback() || v4.is_link_local();
1311+
return v4.is_loopback() || v4.is_link_local() || v4.is_unspecified();
13071312
}
13081313
false
13091314
}
@@ -2023,6 +2028,11 @@ mod tests {
20232028
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1))));
20242029
}
20252030

2031+
#[test]
2032+
fn test_rejects_ipv4_unspecified() {
2033+
assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
2034+
}
2035+
20262036
#[test]
20272037
fn test_allows_ipv4_public() {
20282038
assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
@@ -2043,6 +2053,11 @@ mod tests {
20432053
assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
20442054
}
20452055

2056+
#[test]
2057+
fn test_rejects_ipv6_unspecified() {
2058+
assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
2059+
}
2060+
20462061
#[test]
20472062
fn test_rejects_ipv6_link_local() {
20482063
// fe80::1
@@ -2325,6 +2340,16 @@ mod tests {
23252340
))));
23262341
}
23272342

2343+
#[test]
2344+
fn test_always_blocked_ipv4_unspecified() {
2345+
assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
2346+
}
2347+
2348+
#[test]
2349+
fn test_always_blocked_ipv6_unspecified() {
2350+
assert!(is_always_blocked_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
2351+
}
2352+
23282353
#[test]
23292354
fn test_always_blocked_ipv4_mapped_v6_loopback() {
23302355
let v6 = Ipv4Addr::LOCALHOST.to_ipv6_mapped();
@@ -2430,6 +2455,18 @@ mod tests {
24302455
);
24312456
}
24322457

2458+
#[tokio::test]
2459+
async fn test_resolve_check_allowed_ips_blocks_unspecified() {
2460+
let nets = parse_allowed_ips(&["0.0.0.0/0".to_string()]).unwrap();
2461+
let result = resolve_and_check_allowed_ips("0.0.0.0", 80, &nets).await;
2462+
assert!(result.is_err());
2463+
let err = result.unwrap_err();
2464+
assert!(
2465+
err.contains("always-blocked"),
2466+
"expected 'always-blocked' in error: {err}"
2467+
);
2468+
}
2469+
24332470
#[tokio::test]
24342471
async fn test_resolve_check_allowed_ips_rejects_outside_allowlist() {
24352472
// 8.8.8.8 resolves to a public IP which is NOT in 10.0.0.0/8

0 commit comments

Comments
 (0)