Skip to content

Commit 2901c0d

Browse files
committed
fix(sandbox): narrow trusted-gateway exemption to link-local IPs only
Two security fixes addressing PR review feedback: 1. Replace the compound is_always_blocked_ip && !is_link_local_ip guard in detect_trusted_host_gateway() with a plain !is_link_local_ip check. Private RFC 1918 addresses (e.g. Docker bridge 172.17.0.1, K8s node 192.168.x.x) are not always-blocked, so they slipped through the old guard and received the SSRF exemption, bypassing resolve_and_reject_internal(). The exemption is only valid for the rootless Podman/pasta link-local case. 2. Normalize IPv4-mapped IPv6 in is_cloud_metadata_ip() so that ::ffff:169.254.169.254 is correctly identified as cloud metadata. is_link_local_ip() already recognises this form as link-local; without the normalization it would pass the metadata guard and receive the trusted-gateway exemption, violating the 'metadata is never exempted' invariant. Apply the same !is_link_local_ip tightening to resolve_and_check_trusted_gateway() as defense-in-depth, and update/add tests to cover both guards including IPv4-mapped metadata, private IP rejection, and the Docker bridge scenario.
1 parent 37f2098 commit 2901c0d

1 file changed

Lines changed: 94 additions & 30 deletions

File tree

crates/openshell-sandbox/src/proxy.rs

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1915,8 +1915,17 @@ fn is_host_gateway_alias(host: &str) -> bool {
19151915

19161916
/// Returns `true` if `ip` is a known cloud instance metadata endpoint that
19171917
/// must never be exempted from SSRF blocking.
1918+
///
1919+
/// IPv4-mapped IPv6 addresses (e.g. `::ffff:169.254.169.254`) are normalized
1920+
/// to their embedded IPv4 representation before comparison, so the invariant
1921+
/// holds regardless of how the address is represented.
19181922
fn is_cloud_metadata_ip(ip: IpAddr) -> bool {
1919-
CLOUD_METADATA_IPS.contains(&ip)
1923+
match ip {
1924+
IpAddr::V4(_) => CLOUD_METADATA_IPS.contains(&ip),
1925+
IpAddr::V6(v6) => v6
1926+
.to_ipv4_mapped()
1927+
.is_some_and(|v4| CLOUD_METADATA_IPS.contains(&IpAddr::V4(v4))),
1928+
}
19201929
}
19211930

19221931
/// Read the proxy's own `/etc/hosts` at startup and return the IP mapped to
@@ -1958,13 +1967,15 @@ fn detect_trusted_host_gateway() -> Option<IpAddr> {
19581967
return None;
19591968
}
19601969
// The exemption exists solely for link-local IPs used by rootless Podman
1961-
// with pasta. Loopback (127.x), unspecified (0.0.0.0), and other
1962-
// always-blocked non-link-local addresses are never legitimate host
1963-
// gateway IPs and must not receive the exemption.
1964-
if is_always_blocked_ip(ip) && !is_link_local_ip(ip) {
1970+
// with pasta. Private RFC 1918 addresses (e.g. Docker bridge 172.17.0.1,
1971+
// Kubernetes node 192.168.x.x), loopback, unspecified, and all other
1972+
// non-link-local addresses are never legitimate candidates for the
1973+
// link-local SSRF exemption — they must fall through to the normal
1974+
// allowed_ips / resolve_and_reject_internal() enforcement path.
1975+
if !is_link_local_ip(ip) {
19651976
warn!(
19661977
%ip,
1967-
"host.openshell.internal maps to an always-blocked non-link-local IP; \
1978+
"host.openshell.internal maps to a non-link-local IP; \
19681979
trusted-gateway SSRF exemption disabled"
19691980
);
19701981
return None;
@@ -2022,13 +2033,12 @@ async fn resolve_and_check_trusted_gateway(
20222033
));
20232034
}
20242035
// Defense-in-depth: even if the resolved IP matches trusted_gw, reject
2025-
// always-blocked non-link-local addresses (loopback, unspecified). This
2026-
// catches the case where detect_trusted_host_gateway somehow admitted a
2027-
// bad IP, or where trusted_gw was set to a loopback/unspecified address
2028-
// through a code path we haven't anticipated.
2029-
if is_always_blocked_ip(addr.ip()) && !is_link_local_ip(addr.ip()) {
2036+
// any non-link-local address. detect_trusted_host_gateway() already
2037+
// enforces this at startup, but we re-check here to guard against any
2038+
// unanticipated code path that might admit a private or loopback IP.
2039+
if !is_link_local_ip(addr.ip()) {
20302040
return Err(format!(
2031-
"{host} resolves to always-blocked non-link-local address {}, \
2041+
"{host} resolves to non-link-local address {}, \
20322042
connection rejected",
20332043
addr.ip()
20342044
));
@@ -3910,6 +3920,29 @@ mod tests {
39103920
assert!(!is_cloud_metadata_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
39113921
}
39123922

3923+
#[test]
3924+
fn test_is_cloud_metadata_ip_blocks_ipv4_mapped_metadata() {
3925+
// ::ffff:169.254.169.254 is the IPv4-mapped IPv6 representation of the
3926+
// AWS/GCP/Azure IMDS endpoint. is_link_local_ip() recognizes it as
3927+
// link-local, so is_cloud_metadata_ip() must also catch it — otherwise
3928+
// the trusted-gateway exemption would be granted to the metadata service.
3929+
let mapped = Ipv4Addr::new(169, 254, 169, 254).to_ipv6_mapped();
3930+
assert!(
3931+
is_cloud_metadata_ip(IpAddr::V6(mapped)),
3932+
"::ffff:169.254.169.254 must be recognized as cloud metadata"
3933+
);
3934+
}
3935+
3936+
#[test]
3937+
fn test_is_cloud_metadata_ip_allows_other_ipv4_mapped_link_local() {
3938+
// Other IPv4-mapped link-local addresses are NOT metadata.
3939+
let mapped = Ipv4Addr::new(169, 254, 1, 2).to_ipv6_mapped();
3940+
assert!(
3941+
!is_cloud_metadata_ip(IpAddr::V6(mapped)),
3942+
"::ffff:169.254.1.2 should not be flagged as cloud metadata"
3943+
);
3944+
}
3945+
39133946
// -- detect_trusted_host_gateway --
39143947

39153948
#[test]
@@ -3941,44 +3974,59 @@ mod tests {
39413974

39423975
#[test]
39433976
fn test_detect_trusted_host_gateway_rejects_loopback() {
3944-
// Loopback is always-blocked and not link-local — must not be trusted.
3977+
// Loopback is not link-local — must not receive the SSRF exemption.
39453978
let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
39463979
assert!(!is_cloud_metadata_ip(ip));
3947-
assert!(is_always_blocked_ip(ip));
39483980
assert!(!is_link_local_ip(ip));
3949-
// The guard: always-blocked && !link-local → reject.
3950-
assert!(is_always_blocked_ip(ip) && !is_link_local_ip(ip));
3981+
// The guard: !link-local → reject.
3982+
assert!(!is_link_local_ip(ip));
39513983
}
39523984

39533985
#[test]
39543986
fn test_detect_trusted_host_gateway_rejects_unspecified() {
3955-
// Unspecified (0.0.0.0) must not be trusted.
3987+
// Unspecified (0.0.0.0) is not link-local — must not be trusted.
39563988
let ip = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
39573989
assert!(!is_cloud_metadata_ip(ip));
3958-
assert!(is_always_blocked_ip(ip));
39593990
assert!(!is_link_local_ip(ip));
3960-
assert!(is_always_blocked_ip(ip) && !is_link_local_ip(ip));
3991+
assert!(!is_link_local_ip(ip));
39613992
}
39623993

39633994
#[test]
39643995
fn test_detect_trusted_host_gateway_rejects_loopback_v6() {
39653996
let ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
39663997
assert!(!is_cloud_metadata_ip(ip));
3967-
assert!(is_always_blocked_ip(ip));
39683998
assert!(!is_link_local_ip(ip));
3969-
assert!(is_always_blocked_ip(ip) && !is_link_local_ip(ip));
3999+
}
4000+
4001+
#[test]
4002+
fn test_detect_trusted_host_gateway_rejects_private_ip() {
4003+
// Docker bridge (172.17.0.1) and K8s host gateway (192.168.x.x) are
4004+
// RFC 1918 private addresses — not link-local. Before this fix they
4005+
// slipped through the old always-blocked guard and received the SSRF
4006+
// exemption. The new guard (!is_link_local_ip) rejects them, so
4007+
// connections to these hosts fall through to resolve_and_reject_internal().
4008+
for ip in [
4009+
IpAddr::V4(Ipv4Addr::new(172, 17, 0, 1)),
4010+
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
4011+
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
4012+
] {
4013+
assert!(!is_cloud_metadata_ip(ip), "{ip} should not be metadata");
4014+
assert!(!is_link_local_ip(ip), "{ip} should not be link-local");
4015+
// Guard fires — exemption disabled.
4016+
assert!(!is_link_local_ip(ip), "{ip}: guard must reject");
4017+
}
39704018
}
39714019

39724020
#[test]
39734021
fn test_detect_trusted_host_gateway_allows_link_local_non_metadata() {
3974-
// 169.254.1.2 is always-blocked (link-local) but IS link-local,
3975-
// so the guard should pass and allow the exemption.
4022+
// 169.254.1.2 (rootless Podman pasta gateway) IS link-local and is
4023+
// not a cloud metadata IP — it is the only address class the exemption
4024+
// is designed for.
39764025
let ip = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2));
39774026
assert!(!is_cloud_metadata_ip(ip));
3978-
assert!(is_always_blocked_ip(ip));
39794027
assert!(is_link_local_ip(ip));
3980-
// The guard does NOT fire — this IP is eligible for the exemption.
3981-
assert!(!(is_always_blocked_ip(ip) && !is_link_local_ip(ip)));
4028+
// Guard does NOT fire — this IP is eligible for the exemption.
4029+
assert!(is_link_local_ip(ip));
39824030
}
39834031

39844032
// -- parse_hosts_file_for_host: multi-entry / duplicate scenarios --
@@ -4189,8 +4237,8 @@ mod tests {
41894237
assert!(result.is_err(), "loopback must be rejected");
41904238
let err = result.unwrap_err();
41914239
assert!(
4192-
err.contains("always-blocked non-link-local"),
4193-
"expected always-blocked rejection, got: {err}"
4240+
err.contains("non-link-local"),
4241+
"expected non-link-local rejection, got: {err}"
41944242
);
41954243
}
41964244

@@ -4203,8 +4251,8 @@ mod tests {
42034251
assert!(result.is_err(), "unspecified must be rejected");
42044252
let err = result.unwrap_err();
42054253
assert!(
4206-
err.contains("always-blocked non-link-local"),
4207-
"expected always-blocked rejection, got: {err}"
4254+
err.contains("non-link-local"),
4255+
"expected non-link-local rejection, got: {err}"
42084256
);
42094257
}
42104258

@@ -4238,6 +4286,22 @@ mod tests {
42384286
);
42394287
}
42404288

4289+
#[tokio::test]
4290+
async fn test_trusted_gateway_rejects_private_ip_as_trusted_gw() {
4291+
// Defense-in-depth: a private RFC 1918 IP (e.g. Docker bridge 172.17.0.1)
4292+
// must be rejected even if it somehow matched trusted_gw.
4293+
// detect_trusted_host_gateway() already blocks these via !is_link_local_ip(),
4294+
// but resolve_and_check_trusted_gateway() must enforce the same invariant.
4295+
let docker_bridge = IpAddr::V4(Ipv4Addr::new(172, 17, 0, 1));
4296+
let result = resolve_and_check_trusted_gateway("172.17.0.1", 8080, docker_bridge, 0).await;
4297+
assert!(result.is_err(), "private RFC 1918 IP must be rejected");
4298+
let err = result.unwrap_err();
4299+
assert!(
4300+
err.contains("non-link-local"),
4301+
"expected non-link-local rejection for private IP, got: {err}"
4302+
);
4303+
}
4304+
42414305
#[tokio::test]
42424306
async fn test_rejects_localhost_resolution() {
42434307
let result = resolve_and_reject_internal("localhost", 80, 0).await;

0 commit comments

Comments
 (0)