diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 30d1bf4783..1a68a9fe4c 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -78,6 +78,16 @@ handled by the inference interception path: External inference endpoints that do not use `inference.local` are treated like ordinary network traffic and must be allowed by policy. +In proxy-required networks, the supervisor honors `HTTPS_PROXY`/`HTTP_PROXY`/ +`NO_PROXY` from its own environment: after policy and SSRF checks pass, it +chains the upstream dial through the corporate forward proxy with HTTP CONNECT +instead of connecting directly. `NO_PROXY` destinations, loopback, and +host-gateway aliases always dial directly. Only `http://` proxy URLs are +supported. Local DNS resolution and SSRF validation still run before the +proxied dial; the CONNECT target sent to the corporate proxy is the requested +hostname. The workload child's proxy variables are unaffected — they are +always rewritten to point at the local policy proxy. + ## Credentials Provider credentials are stored at the gateway and fetched by the supervisor at diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index c6a619f3d1..91141565ed 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -345,6 +345,14 @@ Podman resources after out-of-band container removal or label drift. | `OPENSHELL_PODMAN_TLS_CA` | `--podman-tls-ca` | unset | Host path to the CA certificate mounted for sandbox mTLS. | | `OPENSHELL_PODMAN_TLS_CERT` | `--podman-tls-cert` | unset | Host path to the client certificate mounted for sandbox mTLS. | | `OPENSHELL_PODMAN_TLS_KEY` | `--podman-tls-key` | unset | Host path to the client private key mounted for sandbox mTLS. | +| `OPENSHELL_SANDBOX_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL injected into sandbox containers as `HTTPS_PROXY`. The in-container supervisor chains policy-approved upstream dials through it with HTTP CONNECT. Only `http://` proxy URLs are supported. | +| `OPENSHELL_SANDBOX_HTTP_PROXY` | `--sandbox-http-proxy` | unset | Corporate forward proxy URL injected as `HTTP_PROXY` for plain HTTP requests. | +| `OPENSHELL_SANDBOX_NO_PROXY` | `--sandbox-no-proxy` | unset | Comma-separated `NO_PROXY` list (hostnames, domain suffixes, IPs, CIDRs) dialed directly instead of through the corporate proxy. | + +Through the gateway, the same settings are the `https_proxy`, `http_proxy`, and +`no_proxy` keys under `[openshell.drivers.podman]`; see +`docs/reference/gateway-config.mdx`. Per-sandbox environment values take +precedence over these operator defaults. ## Rootless-Specific Adaptations diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index def8e5f3dc..95970b0791 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -134,6 +134,21 @@ pub struct PodmanComputeConfig { /// Set to `0` to disable health checks entirely. /// Defaults to [`DEFAULT_HEALTH_CHECK_INTERVAL_SECS`] (10 seconds). pub health_check_interval_secs: u64, + /// Corporate forward proxy URL injected into sandbox containers as + /// `HTTPS_PROXY`/`https_proxy` (e.g. `http://proxy.corp.com:8080`). + /// + /// The in-container supervisor chains policy-approved TLS tunnels + /// through this proxy with HTTP CONNECT instead of dialing upstream + /// destinations directly. Only `http://` proxy URLs are supported. + /// Per-sandbox environment values take precedence. + pub https_proxy: Option, + /// Corporate forward proxy URL injected as `HTTP_PROXY`/`http_proxy`, + /// used for plain HTTP requests. See `https_proxy`. + pub http_proxy: Option, + /// Comma-separated `NO_PROXY` list injected alongside the proxy URLs + /// (e.g. `*.svc.cluster.local,10.0.0.0/8`). Destinations matching an + /// entry are dialed directly instead of through the corporate proxy. + pub no_proxy: Option, } pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10; @@ -189,6 +204,35 @@ impl PodmanComputeConfig { Ok(()) } + /// Validate optional corporate proxy configuration. + /// + /// The in-container supervisor only supports `http://` forward proxies, + /// so reject other schemes at config time instead of silently ignoring + /// them inside every sandbox. + pub fn validate_proxy_config(&self) -> Result<(), crate::client::PodmanApiError> { + for (field, value) in [ + ("https_proxy", &self.https_proxy), + ("http_proxy", &self.http_proxy), + ] { + let Some(url) = value else { continue }; + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "{field} must not be empty when set" + ))); + } + if let Some((scheme, _)) = trimmed.split_once("://") + && !scheme.eq_ignore_ascii_case("http") + { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "{field} uses unsupported scheme '{scheme}': only http:// forward \ + proxies are supported by the sandbox supervisor" + ))); + } + } + Ok(()) + } + /// Validate optional host gateway override. pub fn validate_host_gateway_ip(&self) -> Result<(), crate::client::PodmanApiError> { let trimmed = self.host_gateway_ip.trim(); @@ -262,6 +306,9 @@ impl Default for PodmanComputeConfig { sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, enable_bind_mounts: false, health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS, + https_proxy: None, + http_proxy: None, + no_proxy: None, } } } @@ -288,6 +335,10 @@ impl std::fmt::Debug for PodmanComputeConfig { "health_check_interval_secs", &self.health_check_interval_secs, ) + // Proxy URLs may embed credentials in userinfo; log presence only. + .field("https_proxy", &self.https_proxy.is_some()) + .field("http_proxy", &self.http_proxy.is_some()) + .field("no_proxy", &self.no_proxy) .finish() } } @@ -397,6 +448,49 @@ mod tests { }); } + // ── Proxy config validation ─────────────────────────────────────── + + #[test] + fn validate_proxy_config_accepts_unset_and_http() { + assert!( + PodmanComputeConfig::default() + .validate_proxy_config() + .is_ok() + ); + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + http_proxy: Some("proxy.corp.com:3128".to_string()), + no_proxy: Some("*.svc.cluster.local".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); + } + + #[test] + fn validate_proxy_config_rejects_non_http_schemes() { + for url in ["https://proxy:443", "socks5://proxy:1080"] { + let cfg = PodmanComputeConfig { + https_proxy: Some(url.to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!( + err.to_string().contains("unsupported scheme"), + "{url}: {err}" + ); + } + } + + #[test] + fn validate_proxy_config_rejects_empty_value() { + let cfg = PodmanComputeConfig { + http_proxy: Some(" ".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("http_proxy"), "{err}"); + } + // ── TLS config validation ───────────────────────────────────────── #[test] diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index e4a0b79189..18f6080741 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -385,6 +385,25 @@ fn build_env( env.insert(openshell_core::sandbox_env::USER_ENVIRONMENT.into(), json); } + // 1.5. Operator-configured corporate egress proxy. The in-container + // supervisor reads these from its own environment and chains approved + // upstream dials through the corporate proxy; the workload child's proxy + // variables are rewritten separately to point at the local policy proxy. + // Inserted as defaults so per-sandbox user env wins. Both cases are set + // because proxy-aware tooling disagrees on which one it reads. + for (name, value) in [ + ("HTTPS_PROXY", &config.https_proxy), + ("https_proxy", &config.https_proxy), + ("HTTP_PROXY", &config.http_proxy), + ("http_proxy", &config.http_proxy), + ("NO_PROXY", &config.no_proxy), + ("no_proxy", &config.no_proxy), + ] { + if let Some(value) = value { + env.entry(name.into()).or_insert_with(|| value.clone()); + } + } + // 2. Required driver vars (highest priority -- always overwrite). env.insert( openshell_core::sandbox_env::SANDBOX.into(), @@ -1675,6 +1694,85 @@ mod tests { ); } + #[test] + fn container_spec_injects_operator_proxy_env() { + let sandbox = test_sandbox("test-id", "test-name"); + let mut config = test_config(); + config.https_proxy = Some("http://proxy.corp.com:8080".to_string()); + config.http_proxy = Some("http://proxy.corp.com:3128".to_string()); + config.no_proxy = Some("*.svc.cluster.local,10.0.0.0/8".to_string()); + + let spec = build_container_spec(&sandbox, &config); + let env_map = spec["env"].as_object().expect("env should be an object"); + + for key in ["HTTPS_PROXY", "https_proxy"] { + assert_eq!( + env_map.get(key).and_then(|v| v.as_str()), + Some("http://proxy.corp.com:8080"), + "{key} should carry the operator proxy URL" + ); + } + for key in ["HTTP_PROXY", "http_proxy"] { + assert_eq!( + env_map.get(key).and_then(|v| v.as_str()), + Some("http://proxy.corp.com:3128"), + "{key} should carry the operator proxy URL" + ); + } + for key in ["NO_PROXY", "no_proxy"] { + assert_eq!( + env_map.get(key).and_then(|v| v.as_str()), + Some("*.svc.cluster.local,10.0.0.0/8"), + "{key} should carry the operator NO_PROXY list" + ); + } + } + + #[test] + fn container_spec_omits_proxy_env_when_unconfigured() { + let sandbox = test_sandbox("test-id", "test-name"); + let spec = build_container_spec(&sandbox, &test_config()); + let env_map = spec["env"].as_object().expect("env should be an object"); + + for key in ["HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY"] { + assert!( + !env_map.contains_key(key), + "{key} should be absent without operator proxy config" + ); + } + } + + #[test] + fn container_spec_user_env_overrides_operator_proxy() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + environment: std::collections::HashMap::from([( + "HTTPS_PROXY".to_string(), + "http://sandbox-specific:9999".to_string(), + )]), + template: Some(DriverSandboxTemplate::default()), + ..Default::default() + }); + let mut config = test_config(); + config.https_proxy = Some("http://proxy.corp.com:8080".to_string()); + + let spec = build_container_spec(&sandbox, &config); + let env_map = spec["env"].as_object().expect("env should be an object"); + + assert_eq!( + env_map.get("HTTPS_PROXY").and_then(|v| v.as_str()), + Some("http://sandbox-specific:9999"), + "per-sandbox HTTPS_PROXY should win over operator config" + ); + assert_eq!( + env_map.get("https_proxy").and_then(|v| v.as_str()), + Some("http://proxy.corp.com:8080"), + "unset lowercase variant still falls back to operator config" + ); + } + #[test] fn container_spec_required_labels_cannot_be_overridden() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index cf639fa265..4207da08bd 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -185,6 +185,7 @@ impl PodmanComputeDriver { config.validate_tls_config()?; config.validate_runtime_limits()?; config.validate_host_gateway_ip()?; + config.validate_proxy_config()?; let client = PodmanClient::new(config.socket_path.clone()); diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index c57aff4277..116ad1553c 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -103,6 +103,19 @@ struct Args { /// Host path to the client private key for sandbox mTLS. #[arg(long, env = "OPENSHELL_PODMAN_TLS_KEY")] podman_tls_key: Option, + + /// Corporate forward proxy URL injected into sandbox containers as + /// `HTTPS_PROXY` for the supervisor's upstream dials (http:// only). + #[arg(long, env = "OPENSHELL_SANDBOX_HTTPS_PROXY")] + sandbox_https_proxy: Option, + + /// Corporate forward proxy URL injected as `HTTP_PROXY` (http:// only). + #[arg(long, env = "OPENSHELL_SANDBOX_HTTP_PROXY")] + sandbox_http_proxy: Option, + + /// Comma-separated `NO_PROXY` list injected alongside the proxy URLs. + #[arg(long, env = "OPENSHELL_SANDBOX_NO_PROXY")] + sandbox_no_proxy: Option, } #[tokio::main] @@ -137,6 +150,9 @@ async fn main() -> Result<()> { guest_tls_cert: args.podman_tls_cert, guest_tls_key: args.podman_tls_key, sandbox_pids_limit: args.sandbox_pids_limit, + https_proxy: args.sandbox_https_proxy, + http_proxy: args.sandbox_http_proxy, + no_proxy: args.sandbox_no_proxy, ..PodmanComputeConfig::default() }) .await diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index c6c1af5aed..ac0cb120a2 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -19,3 +19,4 @@ pub mod run; pub mod sigv4; mod spiffe_endpoint; mod token_grant; +pub mod upstream_proxy; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 38cab79c79..30e925bb70 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7,6 +7,7 @@ use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; use crate::policy_local::{POLICY_LOCAL_HOST, PolicyLocalContext}; +use crate::upstream_proxy::{self, UpstreamProxyConfig, UpstreamScheme}; use miette::{IntoDiagnostic, Result}; use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::denial::DenialEvent; @@ -240,6 +241,24 @@ impl ProxyHandle { ); } + // Corporate egress proxy configured on the supervisor's own + // environment (HTTPS_PROXY/HTTP_PROXY/NO_PROXY). Read once at startup; + // the workload cannot influence the supervisor's environment. + let upstream_proxy: Arc> = + Arc::new(UpstreamProxyConfig::from_env()); + if let Some(cfg) = upstream_proxy.as_ref() { + let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(openshell_ocsf::StateId::Enabled, "enabled") + .message(format!( + "Upstream corporate proxy enabled: {}", + cfg.summary() + )) + .build(); + ocsf_emit!(event); + } + let join = tokio::spawn(async move { // Wait for the OPA engine's symlink resolution reload to complete // before accepting connections. This prevents requests from @@ -275,6 +294,7 @@ impl ProxyHandle { let inf = inference_ctx.clone(); let policy_local = policy_local_ctx.clone(); let gw = trusted_host_gateway.clone(); + let up_proxy = upstream_proxy.clone(); let resolver = provider_credentials .as_ref() .and_then(ProviderCredentialState::resolver); @@ -296,6 +316,7 @@ impl ProxyHandle { inf, policy_local, gw, + up_proxy, resolver, dynamic_credentials, dtx, @@ -548,6 +569,7 @@ async fn handle_tcp_connection( inference_ctx: Option>, policy_local_ctx: Option>, trusted_host_gateway: Arc>, + upstream_proxy: Arc>, secret_resolver: Option>, dynamic_credentials: Option< Arc< @@ -602,6 +624,7 @@ async fn handle_tcp_connection( entrypoint_pid, policy_local_ctx, trusted_host_gateway, + upstream_proxy, secret_resolver, dynamic_credentials, denial_tx.as_ref(), @@ -1076,9 +1099,15 @@ async fn handle_tcp_connection( return Ok(()); } - let mut upstream = TcpStream::connect(validated_addrs.as_slice()) - .await - .into_diagnostic()?; + let mut upstream = dial_upstream( + &upstream_proxy, + UpstreamScheme::Https, + &host_lc, + port, + &validated_addrs, + ) + .await + .into_diagnostic()?; debug!( "handle_tcp_connection dns_resolve_and_tcp_connect: {}ms host={host_lc}", @@ -2768,6 +2797,33 @@ fn validate_declared_endpoint_resolved_addrs( Ok(()) } +/// Dial a validated upstream destination. +/// +/// Connects directly to the SSRF-checked resolved addresses, or chains +/// through the corporate proxy (HTTP CONNECT) when one is configured for +/// this destination via the supervisor's `HTTPS_PROXY`/`HTTP_PROXY` and not +/// excluded by `NO_PROXY`. Policy evaluation and SSRF validation must have +/// already succeeded; only the final TCP dial changes. +/// +/// The CONNECT target sent to the corporate proxy is the client-requested +/// hostname, so hostname-filtering proxies and split-horizon DNS at the +/// proxy keep working. +async fn dial_upstream( + upstream_proxy: &Option, + scheme: UpstreamScheme, + host_lc: &str, + port: u16, + addrs: &[SocketAddr], +) -> std::io::Result { + if let Some(endpoint) = upstream_proxy + .as_ref() + .and_then(|cfg| cfg.proxy_for(scheme, host_lc)) + { + return upstream_proxy::connect_via(endpoint, host_lc, port).await; + } + TcpStream::connect(addrs).await +} + /// Resolve a host:port using sandbox `/etc/hosts` first (when available), then /// reject if any resolved address is internal. /// @@ -3322,6 +3378,7 @@ async fn handle_forward_proxy( entrypoint_pid: Arc, policy_local_ctx: Option>, trusted_host_gateway: Arc>, + upstream_proxy: Arc>, secret_resolver: Option>, dynamic_credentials: Option< Arc< @@ -4336,8 +4393,21 @@ async fn handle_forward_proxy( return Ok(()); } - // 6. Connect upstream - let mut upstream = match TcpStream::connect(addrs.as_slice()).await { + // 6. Connect upstream. Host-gateway aliases always dial directly — the + // corporate proxy cannot reach the driver-injected host gateway. + let dial_result = if is_host_gateway_alias(&host_lc) { + TcpStream::connect(addrs.as_slice()).await + } else { + dial_upstream( + &upstream_proxy, + UpstreamScheme::Http, + &host_lc, + port, + &addrs, + ) + .await + }; + let mut upstream = match dial_result { Ok(s) => s, Err(e) => { let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) @@ -8452,6 +8522,7 @@ network_policies: None, // inference_ctx None, // policy_local_ctx Arc::new(None), // trusted_host_gateway + Arc::new(None), // upstream_proxy None, // secret_resolver None, // dynamic_credentials Some(denial_tx), // denial_tx — positive allow/deny signal diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs new file mode 100644 index 0000000000..e6023c7d84 --- /dev/null +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -0,0 +1,667 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Upstream corporate proxy chaining for the sandbox egress proxy. +//! +//! In proxy-required enterprise networks (issue #1792) the supervisor cannot +//! dial policy-approved destinations directly: all outbound traffic must go +//! through a corporate forward proxy. This module reads the standard +//! `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` variables from the +//! supervisor's **own** environment (the workload child's proxy variables are +//! rewritten separately to point at the local policy proxy) and chains +//! approved connections through the corporate proxy with HTTP CONNECT. +//! +//! Scope and invariants: +//! - Only `http://` proxy URLs are supported. `https://` and SOCKS proxies +//! are ignored with a warning. +//! - Policy evaluation, DNS resolution, and SSRF checks run exactly as in the +//! direct-dial path; the corporate proxy only replaces the final TCP dial. +//! - `NO_PROXY` decides which destinations bypass the corporate proxy and +//! keep dialing directly (cluster-internal services, host gateway, etc.). +//! Loopback destinations always bypass the proxy. + +use std::io::{Error as IoError, ErrorKind}; +use std::net::IpAddr; +use std::time::Duration; + +use base64::Engine as _; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tracing::{debug, warn}; + +/// Upper bound on the corporate proxy's CONNECT response header block. +const MAX_CONNECT_RESPONSE_BYTES: usize = 8 * 1024; + +/// End-to-end budget for dialing the corporate proxy and completing the +/// CONNECT handshake. +const CONNECT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Which proxy variable applies to a destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpstreamScheme { + /// TLS-bound tunnels (client issued CONNECT): `HTTPS_PROXY`. + Https, + /// Plain HTTP forward-proxy requests: `HTTP_PROXY`. + Http, +} + +/// A parsed corporate proxy endpoint. +#[derive(Clone)] +pub struct ProxyEndpoint { + host: String, + port: u16, + /// Pre-computed `Basic ` header value from URL userinfo. + /// Never logged. + proxy_authorization: Option, +} + +impl ProxyEndpoint { + /// `host:port` label for logs. Excludes credentials. + #[must_use] + pub fn display_addr(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} + +impl std::fmt::Debug for ProxyEndpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProxyEndpoint") + .field("host", &self.host) + .field("port", &self.port) + .field("proxy_authorization", &self.proxy_authorization.is_some()) + .finish() + } +} + +/// One parsed `NO_PROXY` entry. +#[derive(Debug, Clone)] +enum NoProxyEntry { + /// `*` — bypass the proxy for every destination. + Wildcard, + /// Domain suffix match: `corp.com` matches `corp.com` and `x.corp.com`. + Domain(String), + /// Exact IP literal match. + Ip(IpAddr), + /// CIDR match against IP-literal destination hosts. + Cidr(ipnet::IpNet), +} + +/// Parsed `NO_PROXY` list. +#[derive(Debug, Clone, Default)] +struct NoProxy { + entries: Vec, +} + +impl NoProxy { + fn parse(raw: &str) -> Self { + let mut entries = Vec::new(); + for item in raw.split(',') { + let item = item.trim(); + if item.is_empty() { + continue; + } + if item == "*" { + entries.push(NoProxyEntry::Wildcard); + continue; + } + if let Ok(net) = item.parse::() { + entries.push(NoProxyEntry::Cidr(net)); + continue; + } + if let Ok(ip) = item.trim_matches(['[', ']']).parse::() { + entries.push(NoProxyEntry::Ip(ip)); + continue; + } + // Domain entry. Strip a `:port` qualifier (ports are ignored), + // then any leading `*.` or `.` so `.corp.com`, `*.corp.com`, and + // `corp.com` all behave identically. + let name = item.rsplit_once(':').map_or(item, |(name, port)| { + if port.chars().all(|c| c.is_ascii_digit()) { + name + } else { + item + } + }); + let name = name + .strip_prefix("*.") + .or_else(|| name.strip_prefix('.')) + .unwrap_or(name) + .to_ascii_lowercase(); + if !name.is_empty() { + entries.push(NoProxyEntry::Domain(name)); + } + } + Self { entries } + } + + /// Whether `host` (lowercase hostname or IP literal) must bypass the + /// corporate proxy. Loopback destinations always match. + fn matches(&self, host: &str) -> bool { + let host_ip = host.trim_matches(['[', ']']).parse::().ok(); + if host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback()) { + return true; + } + self.entries.iter().any(|entry| match entry { + NoProxyEntry::Wildcard => true, + NoProxyEntry::Domain(suffix) => { + host == suffix + || host + .strip_suffix(suffix) + .is_some_and(|prefix| prefix.ends_with('.')) + } + NoProxyEntry::Ip(ip) => host_ip == Some(*ip), + NoProxyEntry::Cidr(net) => host_ip.is_some_and(|ip| net.contains(&ip)), + }) + } +} + +/// Corporate proxy configuration read from the supervisor's environment. +#[derive(Debug, Clone)] +pub struct UpstreamProxyConfig { + https: Option, + http: Option, + no_proxy: NoProxy, +} + +impl UpstreamProxyConfig { + /// Read `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` (lowercase + /// variants take precedence) from the process environment. Returns `None` + /// when no usable proxy is configured. + #[must_use] + pub fn from_env() -> Option { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + fn from_lookup(lookup: impl Fn(&str) -> Option) -> Option { + let var = |upper: &str, lower: &str| { + lookup(lower) + .or_else(|| lookup(upper)) + .filter(|v| !v.trim().is_empty()) + }; + let all = var("ALL_PROXY", "all_proxy"); + let https = var("HTTPS_PROXY", "https_proxy") + .or_else(|| all.clone()) + .and_then(|url| parse_proxy_url(&url, "HTTPS_PROXY")); + let http = var("HTTP_PROXY", "http_proxy") + .or(all) + .and_then(|url| parse_proxy_url(&url, "HTTP_PROXY")); + if https.is_none() && http.is_none() { + return None; + } + let no_proxy = NoProxy::parse(&var("NO_PROXY", "no_proxy").unwrap_or_default()); + Some(Self { + https, + http, + no_proxy, + }) + } + + /// The corporate proxy to use for `host`, or `None` when the destination + /// must be dialed directly. + #[must_use] + pub fn proxy_for(&self, scheme: UpstreamScheme, host: &str) -> Option<&ProxyEndpoint> { + if self.no_proxy.matches(host) { + return None; + } + match scheme { + UpstreamScheme::Https => self.https.as_ref(), + UpstreamScheme::Http => self.http.as_ref(), + } + } + + /// Credential-free summary for startup logging. + #[must_use] + pub fn summary(&self) -> String { + let fmt = |ep: Option<&ProxyEndpoint>| { + ep.map_or_else(|| "-".to_string(), ProxyEndpoint::display_addr) + }; + format!( + "https_proxy={} http_proxy={} no_proxy_entries={}", + fmt(self.https.as_ref()), + fmt(self.http.as_ref()), + self.no_proxy.entries.len() + ) + } +} + +/// Parse an `http://[user:pass@]host[:port]` proxy URL. Unsupported schemes +/// (TLS or SOCKS proxies) are rejected with a warning. +fn parse_proxy_url(raw: &str, var_name: &str) -> Option { + let raw = raw.trim(); + let rest = match raw.split_once("://") { + Some((scheme, rest)) => { + if !scheme.eq_ignore_ascii_case("http") { + warn!( + "{var_name} uses unsupported proxy scheme '{scheme}' \ + (only http:// proxies are supported); ignoring" + ); + return None; + } + rest + } + None => raw, + }; + // Drop any path component. + let rest = rest.split(['/', '?', '#']).next().unwrap_or(rest); + let (userinfo, authority) = match rest.rsplit_once('@') { + Some((userinfo, authority)) => (Some(userinfo), authority), + None => (None, rest), + }; + let Some((host, port)) = split_host_port(authority) else { + warn!("{var_name} has an invalid proxy address '{authority}'; ignoring"); + return None; + }; + let proxy_authorization = userinfo.map(|userinfo| { + let (user, pass) = userinfo.split_once(':').unwrap_or((userinfo, "")); + let credentials = format!("{}:{}", percent_decode(user), percent_decode(pass)); + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(credentials) + ) + }); + Some(ProxyEndpoint { + host, + port, + proxy_authorization, + }) +} + +/// Split `host[:port]` (with optional `[v6]` brackets), defaulting to port 80. +fn split_host_port(authority: &str) -> Option<(String, u16)> { + if authority.is_empty() { + return None; + } + if let Some(v6_end) = authority.find(']') { + if !authority.starts_with('[') { + return None; + } + let host = authority[1..v6_end].to_string(); + let port = match authority[v6_end + 1..].strip_prefix(':') { + Some(port) => port.parse().ok()?, + None if authority[v6_end + 1..].is_empty() => 80, + None => return None, + }; + return Some((host, port)); + } + match authority.rsplit_once(':') { + Some((host, port)) if !host.contains(':') => Some((host.to_string(), port.parse().ok()?)), + Some(_) => None, // bare IPv6 without brackets is ambiguous + None => Some((authority.to_string(), 80)), + } +} + +/// Minimal percent-decoder for URL userinfo. +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let (Some(hi), Some(lo)) = ( + (bytes[i + 1] as char).to_digit(16), + (bytes[i + 2] as char).to_digit(16), + ) + { + #[allow(clippy::cast_possible_truncation)] + out.push((hi * 16 + lo) as u8); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Open a tunnel to `host:port` through the corporate proxy with HTTP CONNECT. +/// +/// Returns the connected stream once the proxy answers 200; after that the +/// stream is a transparent byte pipe to the destination. +/// +/// The destination hostname (not a locally resolved IP) is sent in the +/// CONNECT target so hostname-filtering proxies keep working; local DNS +/// resolution and SSRF validation must already have happened at the call +/// site. +/// +/// # Errors +/// +/// Returns an error when the proxy is unreachable, the handshake times out, +/// or the proxy answers with a non-200 status. +pub async fn connect_via( + endpoint: &ProxyEndpoint, + host: &str, + port: u16, +) -> std::io::Result { + tokio::time::timeout( + CONNECT_HANDSHAKE_TIMEOUT, + connect_via_inner(endpoint, host, port), + ) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!( + "upstream proxy {} CONNECT handshake timed out", + endpoint.display_addr() + ), + ) + })? +} + +async fn connect_via_inner( + endpoint: &ProxyEndpoint, + host: &str, + port: u16, +) -> std::io::Result { + let mut stream = TcpStream::connect((endpoint.host.as_str(), endpoint.port)).await?; + + let target = if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + }; + let mut request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n"); + if let Some(auth) = &endpoint.proxy_authorization { + request.push_str("Proxy-Authorization: "); + request.push_str(auth); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + stream.write_all(request.as_bytes()).await?; + + // Read the proxy's response header block. + let mut buf = vec![0u8; MAX_CONNECT_RESPONSE_BYTES]; + let mut used = 0; + loop { + if used == buf.len() { + return Err(IoError::other(format!( + "upstream proxy {} CONNECT response headers exceed {MAX_CONNECT_RESPONSE_BYTES} bytes", + endpoint.display_addr() + ))); + } + let n = stream.read(&mut buf[used..]).await?; + if n == 0 { + return Err(IoError::new( + ErrorKind::UnexpectedEof, + format!( + "upstream proxy {} closed the connection during CONNECT", + endpoint.display_addr() + ), + )); + } + used += n; + if buf[..used].windows(4).any(|win| win == b"\r\n\r\n") { + break; + } + } + + let response = String::from_utf8_lossy(&buf[..used]); + let status_line = response.lines().next().unwrap_or_default(); + let status_code = status_line + .split_whitespace() + .nth(1) + .and_then(|code| code.parse::().ok()); + match status_code { + Some(200) => { + debug!( + proxy = %endpoint.display_addr(), + target = %target, + "upstream proxy CONNECT tunnel established" + ); + Ok(stream) + } + Some(code) => Err(IoError::other(format!( + "upstream proxy {} refused CONNECT to {target}: HTTP {code}", + endpoint.display_addr() + ))), + None => Err(IoError::new( + ErrorKind::InvalidData, + format!( + "upstream proxy {} sent a malformed CONNECT response", + endpoint.display_addr() + ), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_from(pairs: &[(&str, &str)]) -> Option { + UpstreamProxyConfig::from_lookup(|name| { + pairs + .iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| (*v).to_string()) + }) + } + + #[test] + fn no_env_yields_none() { + assert!(config_from(&[]).is_none()); + } + + #[test] + fn empty_values_yield_none() { + assert!(config_from(&[("HTTPS_PROXY", " "), ("HTTP_PROXY", "")]).is_none()); + } + + #[test] + fn https_proxy_parsed_with_port() { + let cfg = config_from(&[("HTTPS_PROXY", "http://proxy.corp.com:8080")]).unwrap(); + let ep = cfg + .proxy_for(UpstreamScheme::Https, "api.stripe.com") + .unwrap(); + assert_eq!(ep.display_addr(), "proxy.corp.com:8080"); + assert!(ep.proxy_authorization.is_none()); + assert!( + cfg.proxy_for(UpstreamScheme::Http, "api.stripe.com") + .is_none() + ); + } + + #[test] + fn lowercase_takes_precedence() { + let cfg = config_from(&[ + ("HTTPS_PROXY", "http://upper:1111"), + ("https_proxy", "http://lower:2222"), + ]) + .unwrap(); + let ep = cfg.proxy_for(UpstreamScheme::Https, "example.com").unwrap(); + assert_eq!(ep.display_addr(), "lower:2222"); + } + + #[test] + fn all_proxy_fills_both_schemes() { + let cfg = config_from(&[("ALL_PROXY", "http://proxy:3128")]).unwrap(); + assert!( + cfg.proxy_for(UpstreamScheme::Https, "example.com") + .is_some() + ); + assert!(cfg.proxy_for(UpstreamScheme::Http, "example.com").is_some()); + } + + #[test] + fn scheme_defaults_to_http_and_port_defaults_to_80() { + let cfg = config_from(&[("HTTP_PROXY", "proxy.corp.com")]).unwrap(); + let ep = cfg.proxy_for(UpstreamScheme::Http, "example.com").unwrap(); + assert_eq!(ep.display_addr(), "proxy.corp.com:80"); + } + + #[test] + fn tls_and_socks_proxies_rejected() { + assert!(config_from(&[("HTTPS_PROXY", "https://proxy:443")]).is_none()); + assert!(config_from(&[("HTTPS_PROXY", "socks5://proxy:1080")]).is_none()); + } + + #[test] + fn userinfo_becomes_basic_auth() { + let cfg = config_from(&[("HTTPS_PROXY", "http://user:p%40ss@proxy:8080")]).unwrap(); + let ep = cfg.proxy_for(UpstreamScheme::Https, "example.com").unwrap(); + let auth = ep.proxy_authorization.as_deref().unwrap(); + let expected = base64::engine::general_purpose::STANDARD.encode("user:p@ss"); + assert_eq!(auth, format!("Basic {expected}")); + } + + #[test] + fn debug_output_hides_credentials() { + let cfg = config_from(&[("HTTPS_PROXY", "http://user:secret@proxy:8080")]).unwrap(); + let debug = format!("{cfg:?}"); + assert!(!debug.contains("secret")); + assert!(!cfg.summary().contains("secret")); + } + + #[test] + fn ipv6_proxy_address_parses() { + let cfg = config_from(&[("HTTPS_PROXY", "http://[fd00::1]:8080")]).unwrap(); + let ep = cfg.proxy_for(UpstreamScheme::Https, "example.com").unwrap(); + assert_eq!(ep.display_addr(), "fd00::1:8080"); + } + + // -- NO_PROXY matching -- + + fn no_proxy_cfg(no_proxy: &str) -> UpstreamProxyConfig { + config_from(&[("HTTPS_PROXY", "http://proxy:8080"), ("NO_PROXY", no_proxy)]).unwrap() + } + + fn bypasses(cfg: &UpstreamProxyConfig, host: &str) -> bool { + cfg.proxy_for(UpstreamScheme::Https, host).is_none() + } + + #[test] + fn no_proxy_domain_suffix_matches_host_and_subdomains() { + let cfg = no_proxy_cfg("corp.com,other.example"); + assert!(bypasses(&cfg, "corp.com")); + assert!(bypasses(&cfg, "api.corp.com")); + assert!(!bypasses(&cfg, "notcorp.com")); + assert!(!bypasses(&cfg, "corp.com.evil.io")); + } + + #[test] + fn no_proxy_leading_dot_and_wildcard_prefix_are_equivalent() { + for entry in [".svc.cluster.local", "*.svc.cluster.local"] { + let cfg = no_proxy_cfg(entry); + assert!( + bypasses(&cfg, "kubernetes.default.svc.cluster.local"), + "{entry}" + ); + assert!(!bypasses(&cfg, "example.com"), "{entry}"); + } + } + + #[test] + fn no_proxy_cidr_matches_ip_literals() { + let cfg = no_proxy_cfg("10.96.0.0/12"); + assert!(bypasses(&cfg, "10.96.0.1")); + assert!(bypasses(&cfg, "10.100.20.30")); + assert!(!bypasses(&cfg, "10.200.0.9")); + assert!(!bypasses(&cfg, "93.184.216.34")); + } + + #[test] + fn no_proxy_exact_ip_matches() { + let cfg = no_proxy_cfg("192.168.1.5"); + assert!(bypasses(&cfg, "192.168.1.5")); + assert!(!bypasses(&cfg, "192.168.1.6")); + } + + #[test] + fn no_proxy_wildcard_bypasses_everything() { + let cfg = no_proxy_cfg("*"); + assert!(bypasses(&cfg, "example.com")); + } + + #[test] + fn no_proxy_ignores_port_qualifiers() { + let cfg = no_proxy_cfg("internal.corp:8443"); + assert!(bypasses(&cfg, "internal.corp")); + assert!(bypasses(&cfg, "svc.internal.corp")); + } + + #[test] + fn loopback_and_localhost_always_bypass() { + let cfg = no_proxy_cfg(""); + assert!(bypasses(&cfg, "localhost")); + assert!(bypasses(&cfg, "127.0.0.1")); + assert!(bypasses(&cfg, "::1")); + assert!(!bypasses(&cfg, "example.com")); + } + + // -- CONNECT handshake -- + + async fn fake_proxy( + response: &'static str, + ) -> (std::net::SocketAddr, tokio::task::JoinHandle) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 4096]; + let mut used = 0; + loop { + let n = socket.read(&mut buf[used..]).await.unwrap(); + used += n; + if n == 0 || buf[..used].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + socket.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8_lossy(&buf[..used]).into_owned() + }); + (addr, handle) + } + + fn endpoint_for(addr: std::net::SocketAddr, auth: Option<&str>) -> ProxyEndpoint { + ProxyEndpoint { + host: addr.ip().to_string(), + port: addr.port(), + proxy_authorization: auth.map(str::to_string), + } + } + + #[tokio::test] + async fn connect_via_success_sends_connect_and_auth() { + let (addr, handle) = fake_proxy("HTTP/1.1 200 Connection established\r\n\r\n").await; + let endpoint = endpoint_for(addr, Some("Basic dXNlcjpwYXNz")); + let stream = connect_via(&endpoint, "api.example.com", 443) + .await + .unwrap(); + drop(stream); + let request = handle.await.unwrap(); + assert!(request.starts_with("CONNECT api.example.com:443 HTTP/1.1\r\n")); + assert!(request.contains("Host: api.example.com:443\r\n")); + assert!(request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n")); + } + + #[tokio::test] + async fn connect_via_rejects_non_200() { + let (addr, _handle) = + fake_proxy("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n").await; + let endpoint = endpoint_for(addr, None); + let err = connect_via(&endpoint, "api.example.com", 443) + .await + .unwrap_err(); + assert!(err.to_string().contains("407"), "{err}"); + } + + #[tokio::test] + async fn connect_via_rejects_malformed_response() { + let (addr, _handle) = fake_proxy("garbage without status\r\n\r\n").await; + let endpoint = endpoint_for(addr, None); + let err = connect_via(&endpoint, "api.example.com", 443) + .await + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidData); + } + + #[tokio::test] + async fn connect_via_ipv6_target_is_bracketed() { + let (addr, handle) = fake_proxy("HTTP/1.1 200 OK\r\n\r\n").await; + let endpoint = endpoint_for(addr, None); + let _ = connect_via(&endpoint, "2001:db8::1", 443).await.unwrap(); + let request = handle.await.unwrap(); + assert!(request.starts_with("CONNECT [2001:db8::1]:443 HTTP/1.1\r\n")); + } +} diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 247774a6c0..d761817f18 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -301,6 +301,15 @@ sandbox_pids_limit = 2048 # but increase process churn (each check spawns a conmon subprocess). # Set to 0 to disable health checks entirely. Default: 10. health_check_interval_secs = 10 +# Corporate forward proxy for sandbox egress. When set, the in-container +# supervisor chains policy-approved upstream connections through this proxy +# with HTTP CONNECT instead of dialing destinations directly. Only http:// +# proxy URLs are supported. NO_PROXY entries (hostnames, domain suffixes, +# IPs, CIDRs) are dialed directly. Per-sandbox HTTPS_PROXY/HTTP_PROXY/NO_PROXY +# environment values take precedence over these defaults. +# https_proxy = "http://proxy.corp.com:8080" +# http_proxy = "http://proxy.corp.com:8080" +# no_proxy = "*.svc.cluster.local,10.0.0.0/8" ``` ### MicroVM diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 11fbf9a059..53edff81d7 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -352,6 +352,15 @@ EOF if [[ -n "${GRPC_ENDPOINT}" ]]; then printf 'grpc_endpoint = "%s"\n' "${GRPC_ENDPOINT}" >>"${CONFIG_PATH}" fi + if [[ -n "${OPENSHELL_SANDBOX_HTTPS_PROXY:-}" ]]; then + printf 'https_proxy = "%s"\n' "${OPENSHELL_SANDBOX_HTTPS_PROXY}" >>"${CONFIG_PATH}" + fi + if [[ -n "${OPENSHELL_SANDBOX_HTTP_PROXY:-}" ]]; then + printf 'http_proxy = "%s"\n' "${OPENSHELL_SANDBOX_HTTP_PROXY}" >>"${CONFIG_PATH}" + fi + if [[ -n "${OPENSHELL_SANDBOX_NO_PROXY:-}" ]]; then + printf 'no_proxy = "%s"\n' "${OPENSHELL_SANDBOX_NO_PROXY}" >>"${CONFIG_PATH}" + fi ;; esac