diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 13cbce0d73..487a05dee4 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -4225,7 +4225,7 @@ async fn resolve_and_check_trusted_gateway( "port {port} is a blocked control-plane port, connection rejected" ))); } - let addrs = resolve_socket_addrs(host, port, entrypoint_pid) + let addrs = resolve_socket_addrs(host, port, entrypoint_pid, AddressBinding::IpConstrained) .await .map_err(DestinationCheckError::Resolution)?; if addrs.is_empty() { @@ -4345,16 +4345,31 @@ async fn resolve_from_sandbox_hosts( None } -async fn resolve_socket_addrs( +/// Controls whether untrusted sandbox hosts entries may participate in resolution. +/// +/// `IpConstrained` is only valid when the caller verifies every returned IP +/// against an independent pin or CIDR before dialing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AddressBinding { + NameAuthorized, + IpConstrained, +} + +impl AddressBinding { + fn allows_sandbox_hosts(self) -> bool { + matches!(self, Self::IpConstrained) + } +} + +async fn resolve_nonliteral_socket_addrs( host: &str, port: u16, - entrypoint_pid: u32, + address_binding: AddressBinding, + sandbox_hosts: Option>, ) -> std::result::Result, String> { - if let Some(addrs) = resolve_ip_literal(host, port) { - return Ok(addrs); - } - - if let Some(addrs) = resolve_from_sandbox_hosts(host, port, entrypoint_pid).await { + if address_binding.allows_sandbox_hosts() + && let Some(addrs) = sandbox_hosts + { return Ok(addrs); } @@ -4376,6 +4391,25 @@ async fn resolve_socket_addrs( Ok(addrs) } +async fn resolve_socket_addrs( + host: &str, + port: u16, + entrypoint_pid: u32, + address_binding: AddressBinding, +) -> std::result::Result, String> { + if let Some(addrs) = resolve_ip_literal(host, port) { + return Ok(addrs); + } + + let sandbox_hosts = if address_binding.allows_sandbox_hosts() { + resolve_from_sandbox_hosts(host, port, entrypoint_pid).await + } else { + None + }; + + resolve_nonliteral_socket_addrs(host, port, address_binding, sandbox_hosts).await +} + fn reject_internal_resolved_addrs( host: &str, addrs: &[SocketAddr], @@ -4563,17 +4597,16 @@ async fn dial_transparent_upstream( )) } -/// Resolve a host:port using sandbox `/etc/hosts` first (when available), then -/// reject if any resolved address is internal. +/// Resolve a host:port using the supervisor resolver, then reject if any +/// resolved address is internal. /// /// Returns the resolved `SocketAddr` list on success. Returns an error string /// if any resolved IP is in an internal range or if DNS resolution fails. async fn resolve_and_reject_internal( host: &str, port: u16, - entrypoint_pid: u32, ) -> std::result::Result, DestinationCheckError> { - let addrs = resolve_socket_addrs(host, port, entrypoint_pid) + let addrs = resolve_socket_addrs(host, port, 0, AddressBinding::NameAuthorized) .await .map_err(DestinationCheckError::Resolution)?; reject_internal_resolved_addrs(host, &addrs).map_err(DestinationCheckError::Denied)?; @@ -4594,7 +4627,7 @@ async fn resolve_and_check_allowed_ips( allowed_ips: &[ipnet::IpNet], entrypoint_pid: u32, ) -> std::result::Result, DestinationCheckError> { - let addrs = resolve_socket_addrs(host, port, entrypoint_pid) + let addrs = resolve_socket_addrs(host, port, entrypoint_pid, AddressBinding::IpConstrained) .await .map_err(DestinationCheckError::Resolution)?; validate_allowed_ips_for_resolved_addrs(host, port, &addrs, allowed_ips) @@ -4606,13 +4639,15 @@ async fn resolve_and_check_allowed_ips( /// /// Exact declared hostnames are the operator's trust signal, so RFC1918 and /// other private ranges are allowed without a duplicated `allowed_ips` entry. -/// Loopback, link-local, unspecified, and control-plane ports remain blocked. +/// The supervisor resolver is authoritative because accepting the sandbox's +/// `/etc/hosts` here would let the workload choose the address authorized by +/// the hostname. Loopback, link-local, unspecified, and control-plane ports +/// remain blocked. async fn resolve_and_check_declared_endpoint( host: &str, port: u16, - entrypoint_pid: u32, ) -> std::result::Result, DestinationCheckError> { - let addrs = resolve_socket_addrs(host, port, entrypoint_pid) + let addrs = resolve_socket_addrs(host, port, 0, AddressBinding::NameAuthorized) .await .map_err(DestinationCheckError::Resolution)?; validate_declared_endpoint_resolved_addrs(host, port, &addrs) @@ -11818,6 +11853,40 @@ network_policies: // -- resolve_and_reject_internal -- + #[tokio::test] + async fn test_address_binding_controls_sandbox_hosts() { + let attacker_selected = resolve_from_hosts_file_contents( + "93.184.216.34 api.example.test\n", + "api.example.test", + 80, + ); + + let name_authorized = resolve_nonliteral_socket_addrs( + "this-host-does-not-exist.invalid", + 80, + AddressBinding::NameAuthorized, + Some(attacker_selected.clone()), + ) + .await; + assert!( + name_authorized.is_err(), + "name-authorized resolution must ignore sandbox hosts" + ); + + let ip_constrained = resolve_nonliteral_socket_addrs( + "api.example.test", + 80, + AddressBinding::IpConstrained, + Some(attacker_selected.clone()), + ) + .await + .expect("IP-constrained resolution should accept sandbox hosts candidates"); + assert_eq!( + ip_constrained, attacker_selected, + "IP-constrained callers validate the selected addresses independently" + ); + } + #[test] fn test_parse_hosts_file_for_host_handles_comments_invalid_rows_and_case() { let contents = r#" @@ -11856,7 +11925,7 @@ network_policies: } #[test] - fn test_resolve_from_hosts_file_contents_public_ip_passes_default_ssrf_check() { + fn test_resolve_from_hosts_file_contents_public_ip_is_not_internal() { let addrs = resolve_from_hosts_file_contents("93.184.216.34 example.local\n", "example.local", 80); assert!(reject_internal_resolved_addrs("example.local", &addrs).is_ok()); @@ -11883,7 +11952,7 @@ network_policies: } #[test] - fn test_declared_endpoint_private_hosts_file_resolution_allowed() { + fn test_declared_endpoint_validator_allows_private_address() { let addrs = resolve_from_hosts_file_contents( "192.168.1.105 searxng.local\n", "searxng.local", @@ -12395,7 +12464,7 @@ network_policies: #[tokio::test] async fn test_rejects_localhost_resolution() { - let result = resolve_and_reject_internal("localhost", 80, 0).await; + let result = resolve_and_reject_internal("localhost", 80).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( @@ -12406,7 +12475,7 @@ network_policies: #[tokio::test] async fn test_rejects_loopback_ip_literal() { - let result = resolve_and_reject_internal("127.0.0.1", 443, 0).await; + let result = resolve_and_reject_internal("127.0.0.1", 443).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( @@ -12417,7 +12486,7 @@ network_policies: #[tokio::test] async fn test_rejects_metadata_ip() { - let result = resolve_and_reject_internal("169.254.169.254", 80, 0).await; + let result = resolve_and_reject_internal("169.254.169.254", 80).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( @@ -12428,7 +12497,7 @@ network_policies: #[tokio::test] async fn test_dns_failure_returns_error() { - let result = resolve_and_reject_internal("this-host-does-not-exist.invalid", 80, 0).await; + let result = resolve_and_reject_internal("this-host-does-not-exist.invalid", 80).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( @@ -14117,7 +14186,7 @@ network_policies: async fn test_forward_public_ip_allowed_without_allowed_ips() { // Public IPs (e.g. dns.google -> 8.8.8.8) should pass through // resolve_and_reject_internal without needing allowed_ips. - let result = resolve_and_reject_internal("dns.google", 80, 0).await; + let result = resolve_and_reject_internal("dns.google", 80).await; assert!( result.is_ok(), "Public IP should be allowed without allowed_ips: {result:?}" @@ -14137,7 +14206,7 @@ network_policies: #[tokio::test] async fn test_forward_private_ip_rejected_without_allowed_ips() { // Private IP literals should be rejected by resolve_and_reject_internal. - let result = resolve_and_reject_internal("10.0.0.1", 80, 0).await; + let result = resolve_and_reject_internal("10.0.0.1", 80).await; assert!( result.is_err(), "Private IP should be rejected without allowed_ips" @@ -14208,7 +14277,7 @@ network_policies: #[tokio::test] async fn test_resolve_reject_internal_fails_closed_on_nul_hostname() { - let result = resolve_and_reject_internal("evil.com\0.safe.com", 443, 0).await; + let result = resolve_and_reject_internal("evil.com\0.safe.com", 443).await; assert!( result.is_err(), "NUL-containing hostname should fail DNS resolution (fail closed)" diff --git a/crates/openshell-supervisor-network/src/proxy/destination.rs b/crates/openshell-supervisor-network/src/proxy/destination.rs index 9b53fe1548..ae1c5f9179 100644 --- a/crates/openshell-supervisor-network/src/proxy/destination.rs +++ b/crates/openshell-supervisor-network/src/proxy/destination.rs @@ -362,20 +362,16 @@ pub(crate) async fn validate_destination( DestinationDenial::from_check(error, DestinationDenialKind::AllowedIps) })? } - AddressAuthorization::ExactDeclaredHost => { - resolve_and_check_declared_endpoint(host, port, sandbox_entrypoint_pid) - .await - .map_err(|error| { - DestinationDenial::from_check(error, DestinationDenialKind::DeclaredEndpoint) - })? - } - AddressAuthorization::DefaultPublicOnly => { - resolve_and_reject_internal(host, port, sandbox_entrypoint_pid) - .await - .map_err(|error| { - DestinationDenial::from_check(error, DestinationDenialKind::InternalAddress) - })? - } + AddressAuthorization::ExactDeclaredHost => resolve_and_check_declared_endpoint(host, port) + .await + .map_err(|error| { + DestinationDenial::from_check(error, DestinationDenialKind::DeclaredEndpoint) + })?, + AddressAuthorization::DefaultPublicOnly => resolve_and_reject_internal(host, port) + .await + .map_err(|error| { + DestinationDenial::from_check(error, DestinationDenialKind::InternalAddress) + })?, AddressAuthorization::PinnedResolved(addresses) => addresses .iter() .copied() diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index ab5ccafece..9b47a7ea31 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -219,12 +219,8 @@ impl NoProxy { /// outside that range. /// /// The automatic loopback bypass is derived from resolved addresses, never - /// from the host string: `resolved` may come from the sandbox's own - /// `/etc/hosts` (see `resolve_socket_addrs`), which the workload controls, - /// so treating the name `localhost` as proof of loopback would let a - /// spoofed mapping dial a policy-allowed public address while escaping the - /// operator proxy. A spoofed `localhost` falls through to the entries below - /// and is proxied unless an explicit `NO_PROXY` entry matches. + /// from the host string. This keeps the bypass valid even for callers whose + /// independently IP-constrained resolution may honor sandbox `/etc/hosts`. fn direct_addrs( &self, host: &str, @@ -1782,11 +1778,10 @@ mod tests { assert!(!bypasses(&cfg, "example.com")); } - /// A sandbox controls its own `/etc/hosts`, and `resolve_socket_addrs` - /// consults it before DNS, so `localhost` can resolve to any address the - /// network policy happens to allow. The name must not authorize a direct - /// dial that escapes the operator proxy — only genuinely loopback - /// resolved addresses may. + /// Only genuinely loopback resolved addresses may authorize the automatic + /// direct-dial bypass. Callers with independently IP-constrained resolution + /// can still honor sandbox `/etc/hosts`, so this check must remain based on + /// the resolved address rather than the hostname. #[test] fn spoofed_localhost_resolution_does_not_bypass_proxy() { let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy:8080")]); diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 53d2f469f7..10a17c0cea 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -133,9 +133,9 @@ After OPA policy allows a connection, the proxy resolves DNS and rejects undecla | Aspect | Detail | |---|---| | Default | The proxy blocks private IPs for undeclared, wildcard, and hostless endpoints, and for endpoints added through the policy advisor. Exact hostnames declared in user policy may resolve to private RFC 1918 addresses. Loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), and unspecified (`0.0.0.0`) addresses are always blocked and cannot be overridden with `allowed_ips`. | -| What you can change | Declare a known internal service as an exact `host` endpoint, or add `allowed_ips` (CIDR notation) to an endpoint to permit specific private IP ranges for wildcard or hostless policies. On Linux, the proxy consults the sandbox's `/etc/hosts` before DNS, so Kubernetes `hostAliases` can make LAN-only hostnames resolvable. Policies with `allowed_ips` entries that overlap loopback, link-local, or unspecified addresses fail to load with a clear validation error. | +| What you can change | Declare a known internal service as an exact `host` endpoint, or add `allowed_ips` (CIDR notation) to an endpoint to permit specific private IP ranges for wildcard or hostless policies. The supervisor resolver is authoritative for hostname-only authorization. Linux explicit-proxy paths with access to the workload process namespace may use sandbox `/etc/hosts` entries, including Kubernetes `hostAliases`, for ordinary endpoints only when the endpoint has `allowed_ips`; every resolved address is then checked against the allowlist. Driver-managed host-gateway aliases use a separate trusted IP pin. Policies with `allowed_ips` entries that overlap loopback, link-local, or unspecified addresses fail to load with a clear validation error. | | Risk if relaxed | Without SSRF protection, a misconfigured policy could allow the agent to reach cloud metadata services (`169.254.169.254`), internal databases, or other infrastructure endpoints through DNS rebinding. | -| Recommendation | Prefer exact hostname endpoints for stable internal services. Use `allowed_ips` when you need hostless or wildcard authorization, and scope the CIDR as narrowly as possible (for example, `10.0.5.20/32` for a single host). Loopback, link-local, and unspecified addresses are always blocked regardless of `allowed_ips`. `hostAliases` change resolution, not authorization: `host: searxng.local` with `/etc/hosts` mapping to `192.168.1.105` is trusted only when that exact hostname is declared by user policy. The policy advisor does not propose rules for always-blocked destinations and still requires a separate `allowed_ips` edit for private-IP endpoints. | +| Recommendation | Prefer exact hostname endpoints for stable internal services. Use `allowed_ips` when you need hostless or wildcard authorization, and scope the CIDR as narrowly as possible (for example, `10.0.5.20/32` for a single host). Loopback, link-local, and unspecified addresses are always blocked regardless of `allowed_ips`. On a deployment that supports sandbox host aliases, declare both `host: searxng.local` and `allowed_ips: [192.168.1.105/32]` before relying on a Kubernetes `hostAliases` mapping; the alias changes resolution while the allowlist supplies the trusted address constraint. The policy advisor does not propose rules for always-blocked destinations and still requires a separate `allowed_ips` edit for private-IP endpoints. | ### Operator Approval diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index db349e0068..67245d1ba5 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -357,6 +357,8 @@ Use `allowed_ips` to pin the addresses an endpoint may reach. When it is set, ev - **Host + allowlist**: `host` + `allowed_ips` — domain must resolve to an IP in the allowlist - **Hostless allowlist**: `allowed_ips` only (no `host`) — any domain on the port is allowed if it resolves to an IP in the allowlist +The supervisor resolver is authoritative for hostname-only authorization. When a Linux explicit-proxy deployment exposes the workload process namespace to the supervisor, a sandbox `/etc/hosts` entry such as a Kubernetes `hostAliases` mapping is honored for an ordinary endpoint only if it sets `allowed_ips` to the exact IP or a narrow CIDR. OpenShell's driver-managed host-gateway aliases use a separate trusted IP pin. + Loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), unspecified, and cloud metadata addresses are **always blocked** as upstream destinations regardless of `allowed_ips`. The Google Cloud metadata emulator reserves `127.0.0.1:8174` in Linux sandboxes. OpenShell handles SDK discovery locally through the supervisor; do not add an `allowed_ips` exception or grant access to the host cloud metadata service. See the [Google provider documentation](https://docs.nvidia.com/openshell/latest/how-it-works/providers/google.md).