From cc0405c4c2290cb2f4604e33d264666913ca4f65 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 9 Jul 2026 12:18:49 +0000 Subject: [PATCH 01/28] feat(sandbox,gateway): route sandbox egress through corporate HTTP proxy - Chain sandbox egress through a corporate HTTP proxy so outbound traffic from within the sandbox respects the host proxy settings - Forward sandbox proxy environment variables to the generated Podman config so the proxy is applied consistently to Podman-managed workloads Signed-off-by: Philippe Martin --- architecture/sandbox.md | 10 + crates/openshell-driver-podman/README.md | 8 + crates/openshell-driver-podman/src/config.rs | 94 +++ .../openshell-driver-podman/src/container.rs | 98 +++ crates/openshell-driver-podman/src/driver.rs | 1 + crates/openshell-driver-podman/src/main.rs | 16 + .../openshell-supervisor-network/src/lib.rs | 1 + .../openshell-supervisor-network/src/proxy.rs | 81 ++- .../src/upstream_proxy.rs | 667 ++++++++++++++++++ docs/reference/gateway-config.mdx | 9 + tasks/scripts/gateway.sh | 9 + 11 files changed, 989 insertions(+), 5 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/upstream_proxy.rs diff --git a/architecture/sandbox.md b/architecture/sandbox.md index d2cb44d7b8..8715e2996f 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -103,6 +103,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 9ee26c0735..f9c238469e 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(), @@ -1646,6 +1665,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 a86d58dee0..25a2eff5b4 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 ab2313b890..a100dae03c 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, @@ -627,6 +648,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< @@ -695,6 +717,7 @@ async fn handle_tcp_connection( entrypoint_pid, policy_local_ctx, trusted_host_gateway, + upstream_proxy, secret_resolver, dynamic_credentials, denial_tx.as_ref(), @@ -1169,9 +1192,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}", @@ -2897,6 +2926,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. /// @@ -3536,6 +3592,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< @@ -4569,8 +4626,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()) @@ -9323,6 +9393,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 3dde7643d5..22a4161cc2 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -389,6 +389,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 From 3c43b520e9a4e729b61048cba3e567ef149c8301 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 16 Jul 2026 12:32:46 +0200 Subject: [PATCH 02/28] fix(sandbox,podman): make corporate proxy routing operator-owned The operator-configured corporate egress proxy was injected under the conventional HTTPS_PROXY/HTTP_PROXY/NO_PROXY names as defaults beneath sandbox spec/template environment, so a sandbox creator could redirect egress at an arbitrary proxy or disable proxying with NO_PROXY=*. Route the boundary through reserved, supervisor-only variables (OPENSHELL_UPSTREAM_HTTPS_PROXY/HTTP_PROXY/NO_PROXY) written in the Podman driver's required-variable tier. Any sandbox-supplied value under a reserved name is stripped before the operator value is applied, so the supervisor never observes a reserved proxy variable the operator did not set. The supervisor now reads only the reserved names and ignores the conventional proxy variables the sandbox controls. Add the reserved proxy variables to the supervisor-only child-environment denylist so the corporate proxy URL and any embedded credentials are not inherited by the sandbox workload, which reaches egress through the local policy proxy and never needs them. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 23 +-- crates/openshell-core/src/sandbox_env.rs | 24 ++++ crates/openshell-driver-podman/src/config.rs | 21 +-- .../openshell-driver-podman/src/container.rs | 134 ++++++++++++------ .../openshell-supervisor-network/src/proxy.rs | 13 +- .../src/upstream_proxy.rs | 119 +++++++++------- .../src/process.rs | 20 +++ docs/reference/gateway-config.mdx | 5 +- 8 files changed, 235 insertions(+), 124 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 8715e2996f..cb4c6bb6d0 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -103,15 +103,20 @@ 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. +In proxy-required networks, the supervisor chains the upstream dial through a +corporate forward proxy with HTTP CONNECT instead of connecting directly, once +policy and SSRF checks pass. The proxy configuration is an operator-owned +boundary read from reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` / +`OPENSHELL_UPSTREAM_HTTP_PROXY` / `OPENSHELL_UPSTREAM_NO_PROXY` variables that +compute drivers write in their required-variable tier; sandbox and template +environment cannot override them. The conventional `HTTPS_PROXY`/`HTTP_PROXY`/ +`NO_PROXY` variables a sandbox controls are ignored on this path. Reserved +`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 diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index f066580636..e86e50380d 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -114,3 +114,27 @@ pub const SANDBOX_UID: &str = "OPENSHELL_SANDBOX_UID"; /// Used alongside UID for PVC init container `chown` operations and when the /// supervisor drops privileges to a group other than the UID's primary group. pub const SANDBOX_GID: &str = "OPENSHELL_SANDBOX_GID"; + +/// Corporate forward-proxy URL the supervisor chains TLS (CONNECT) egress +/// through, e.g. `http://proxy.corp.com:8080`. +/// +/// This is an operator-owned egress boundary. Compute drivers set it from +/// gateway configuration in the supervisor's required-variable tier so that +/// sandbox spec/template environment cannot override it. The supervisor reads +/// *only* this reserved name — never the conventional `HTTPS_PROXY` / +/// `ALL_PROXY` variables, which the sandbox creator controls and which are +/// reserved for pointing the workload child at the local policy proxy. +pub const UPSTREAM_HTTPS_PROXY: &str = "OPENSHELL_UPSTREAM_HTTPS_PROXY"; + +/// Corporate forward-proxy URL the supervisor uses for plain-HTTP egress. +/// +/// Operator-owned counterpart to [`UPSTREAM_HTTPS_PROXY`]; see that constant +/// for the trust-boundary rationale. +pub const UPSTREAM_HTTP_PROXY: &str = "OPENSHELL_UPSTREAM_HTTP_PROXY"; + +/// Comma-separated `NO_PROXY`-style list of destinations the supervisor dials +/// directly instead of chaining through the corporate proxy. +/// +/// Operator-owned counterpart to [`UPSTREAM_HTTPS_PROXY`]; see that constant +/// for the trust-boundary rationale. +pub const UPSTREAM_NO_PROXY: &str = "OPENSHELL_UPSTREAM_NO_PROXY"; diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 95970b0791..c03dc2707e 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -134,20 +134,25 @@ 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`). + /// Corporate forward proxy URL injected into sandbox containers as the + /// reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` supervisor variable + /// (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. + /// This is an operator-owned egress boundary: it is written in the + /// required-variable tier so sandbox/template environment cannot override + /// it, and the conventional `HTTPS_PROXY` variables are not used. pub https_proxy: Option, - /// Corporate forward proxy URL injected as `HTTP_PROXY`/`http_proxy`, - /// used for plain HTTP requests. See `https_proxy`. + /// Corporate forward proxy URL injected as the reserved + /// `OPENSHELL_UPSTREAM_HTTP_PROXY` variable, 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. + /// Comma-separated `NO_PROXY` list injected as the reserved + /// `OPENSHELL_UPSTREAM_NO_PROXY` variable (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, } diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index f9c238469e..5989f69d3d 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -385,26 +385,40 @@ 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. + // 2. Required driver vars (highest priority -- always overwrite). + + // Operator-configured corporate egress proxy. This is a security boundary, + // so it is written in the required-variable tier under reserved + // supervisor-only names. The conventional `HTTPS_PROXY`/`NO_PROXY` variants + // are deliberately NOT used here: those belong to the sandbox creator and + // are separately rewritten to point the workload child at the local policy + // proxy, so letting them steer the supervisor would let a sandbox pick an + // arbitrary proxy or disable proxying with `NO_PROXY=*`. + // + // Any sandbox/template-supplied value under a reserved name is first + // stripped, then replaced with the operator value (if configured), so the + // supervisor can never observe a reserved proxy variable the operator did + // not set. 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), + ( + openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, + &config.https_proxy, + ), + ( + openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY, + &config.http_proxy, + ), + ( + openshell_core::sandbox_env::UPSTREAM_NO_PROXY, + &config.no_proxy, + ), ] { + env.remove(name); if let Some(value) = value { - env.entry(name.into()).or_insert_with(|| value.clone()); + env.insert(name.into(), value.clone()); } } - // 2. Required driver vars (highest priority -- always overwrite). env.insert( openshell_core::sandbox_env::SANDBOX.into(), sandbox.name.clone(), @@ -1667,6 +1681,10 @@ mod tests { #[test] fn container_spec_injects_operator_proxy_env() { + use openshell_core::sandbox_env::{ + UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, + }; + let sandbox = test_sandbox("test-id", "test-name"); let mut config = test_config(); config.https_proxy = Some("http://proxy.corp.com:8080".to_string()); @@ -1676,36 +1694,43 @@ mod tests { 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" + assert_eq!( + env_map.get(UPSTREAM_HTTPS_PROXY).and_then(|v| v.as_str()), + Some("http://proxy.corp.com:8080"), + "reserved upstream HTTPS var should carry the operator proxy URL" + ); + assert_eq!( + env_map.get(UPSTREAM_HTTP_PROXY).and_then(|v| v.as_str()), + Some("http://proxy.corp.com:3128"), + "reserved upstream HTTP var should carry the operator proxy URL" + ); + assert_eq!( + env_map.get(UPSTREAM_NO_PROXY).and_then(|v| v.as_str()), + Some("*.svc.cluster.local,10.0.0.0/8"), + "reserved upstream NO_PROXY var should carry the operator list" + ); + + // The conventional proxy variables must NOT be set from operator + // config: they belong to the sandbox creator, not the supervisor. + for key in ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] { + assert!( + !env_map.contains_key(key), + "{key} must not be populated from operator proxy config" ); } } #[test] fn container_spec_omits_proxy_env_when_unconfigured() { + use openshell_core::sandbox_env::{ + UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, + }; + 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"] { + for key in [UPSTREAM_HTTPS_PROXY, UPSTREAM_HTTP_PROXY, UPSTREAM_NO_PROXY] { assert!( !env_map.contains_key(key), "{key} should be absent without operator proxy config" @@ -1714,16 +1739,32 @@ mod tests { } #[test] - fn container_spec_user_env_overrides_operator_proxy() { + fn container_spec_user_env_cannot_override_operator_proxy() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_core::sandbox_env::{UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY}; + // A sandbox creator tries to redirect egress at an attacker proxy and + // disable proxying with `NO_PROXY=*` through both spec and template env. 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()), + environment: std::collections::HashMap::from([ + ( + "HTTPS_PROXY".to_string(), + "http://attacker:9999".to_string(), + ), + ( + UPSTREAM_HTTPS_PROXY.to_string(), + "http://attacker:9999".to_string(), + ), + (UPSTREAM_NO_PROXY.to_string(), "*".to_string()), + ]), + template: Some(DriverSandboxTemplate { + environment: std::collections::HashMap::from([( + UPSTREAM_NO_PROXY.to_string(), + "*".to_string(), + )]), + ..Default::default() + }), ..Default::default() }); let mut config = test_config(); @@ -1733,14 +1774,13 @@ mod tests { 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()), + env_map.get(UPSTREAM_HTTPS_PROXY).and_then(|v| v.as_str()), Some("http://proxy.corp.com:8080"), - "unset lowercase variant still falls back to operator config" + "operator proxy must win over any sandbox-supplied reserved value" + ); + assert!( + !env_map.contains_key(UPSTREAM_NO_PROXY), + "sandbox must not be able to inject a reserved NO_PROXY bypass" ); } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index a100dae03c..54c2dbe770 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -242,8 +242,10 @@ 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. + // environment via the operator-owned reserved OPENSHELL_UPSTREAM_* + // variables. Read once at startup; the workload cannot influence the + // supervisor's environment, and the conventional HTTPS_PROXY/NO_PROXY + // variables it does control are ignored on this path. let upstream_proxy: Arc> = Arc::new(UpstreamProxyConfig::from_env()); if let Some(cfg) = upstream_proxy.as_ref() { @@ -2930,9 +2932,10 @@ fn validate_declared_endpoint_resolved_addrs( /// /// 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. +/// this destination via the supervisor's reserved upstream proxy variables +/// and not excluded by the reserved `NO_PROXY` list. 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 diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index e6023c7d84..1f0ffb50d8 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -5,20 +5,28 @@ //! //! 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. +//! through a corporate forward proxy. This module reads the operator-owned +//! reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` / `OPENSHELL_UPSTREAM_HTTP_PROXY` +//! / `OPENSHELL_UPSTREAM_NO_PROXY` variables from the supervisor's **own** +//! environment and chains approved connections through the corporate proxy +//! with HTTP CONNECT. +//! +//! The conventional `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` +//! variables are intentionally ignored: those are controlled by the sandbox +//! creator and are rewritten separately to point the workload child at the +//! local policy proxy, so honoring them would let a sandbox pick an arbitrary +//! upstream proxy or disable proxying with `NO_PROXY=*`. The compute driver +//! writes the reserved names in its required-variable tier, which sandbox and +//! template environment cannot override. //! //! 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. +//! - The reserved `NO_PROXY` list 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; @@ -164,31 +172,38 @@ pub struct UpstreamProxyConfig { } 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. + /// Read the operator-owned corporate proxy configuration from the + /// supervisor's reserved environment variables + /// ([`UPSTREAM_HTTPS_PROXY`](openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY), + /// [`UPSTREAM_HTTP_PROXY`](openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY), + /// [`UPSTREAM_NO_PROXY`](openshell_core::sandbox_env::UPSTREAM_NO_PROXY)). + /// Returns `None` when no usable proxy is configured. + /// + /// The conventional `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` + /// variables are intentionally ignored here: they are set by the sandbox + /// creator (and rewritten to point workload children at the local policy + /// proxy), so honoring them would let a sandbox choose an arbitrary + /// upstream proxy or disable proxying entirely. The compute driver writes + /// the reserved names in its required-variable tier, where sandbox and + /// template environment cannot override them. #[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()) + use openshell_core::sandbox_env::{ + UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, }; - 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")); + let var = |name: &str| lookup(name).filter(|v| !v.trim().is_empty()); + let https = + var(UPSTREAM_HTTPS_PROXY).and_then(|url| parse_proxy_url(&url, UPSTREAM_HTTPS_PROXY)); + let http = + var(UPSTREAM_HTTP_PROXY).and_then(|url| parse_proxy_url(&url, UPSTREAM_HTTP_PROXY)); if https.is_none() && http.is_none() { return None; } - let no_proxy = NoProxy::parse(&var("NO_PROXY", "no_proxy").unwrap_or_default()); + let no_proxy = NoProxy::parse(&var(UPSTREAM_NO_PROXY).unwrap_or_default()); Some(Self { https, http, @@ -428,6 +443,10 @@ async fn connect_via_inner( #[cfg(test)] mod tests { use super::*; + use openshell_core::sandbox_env::{ + UPSTREAM_HTTP_PROXY as HTTP_PROXY, UPSTREAM_HTTPS_PROXY as HTTPS_PROXY, + UPSTREAM_NO_PROXY as NO_PROXY, + }; fn config_from(pairs: &[(&str, &str)]) -> Option { UpstreamProxyConfig::from_lookup(|name| { @@ -443,14 +462,29 @@ mod tests { assert!(config_from(&[]).is_none()); } + #[test] + fn conventional_proxy_vars_are_ignored() { + // The sandbox creator controls these names; they must not steer the + // supervisor's operator-owned egress boundary. + assert!( + config_from(&[ + ("HTTPS_PROXY", "http://attacker:9999"), + ("HTTP_PROXY", "http://attacker:9999"), + ("ALL_PROXY", "http://attacker:9999"), + ("https_proxy", "http://attacker:9999"), + ]) + .is_none() + ); + } + #[test] fn empty_values_yield_none() { - assert!(config_from(&[("HTTPS_PROXY", " "), ("HTTP_PROXY", "")]).is_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 cfg = config_from(&[(HTTPS_PROXY, "http://proxy.corp.com:8080")]).unwrap(); let ep = cfg .proxy_for(UpstreamScheme::Https, "api.stripe.com") .unwrap(); @@ -462,43 +496,22 @@ mod tests { ); } - #[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 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()); + 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 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"); @@ -507,7 +520,7 @@ mod tests { #[test] fn debug_output_hides_credentials() { - let cfg = config_from(&[("HTTPS_PROXY", "http://user:secret@proxy:8080")]).unwrap(); + 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")); @@ -515,7 +528,7 @@ mod tests { #[test] fn ipv6_proxy_address_parses() { - let cfg = config_from(&[("HTTPS_PROXY", "http://[fd00::1]:8080")]).unwrap(); + 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"); } @@ -523,7 +536,7 @@ mod tests { // -- NO_PROXY matching -- fn no_proxy_cfg(no_proxy: &str) -> UpstreamProxyConfig { - config_from(&[("HTTPS_PROXY", "http://proxy:8080"), ("NO_PROXY", no_proxy)]).unwrap() + config_from(&[(HTTPS_PROXY, "http://proxy:8080"), (NO_PROXY, no_proxy)]).unwrap() } fn bypasses(cfg: &UpstreamProxyConfig, host: &str) -> bool { diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 72effa98f8..9aa1a8c620 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -78,6 +78,13 @@ const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[ openshell_core::sandbox_env::TLS_CERT, openshell_core::sandbox_env::TLS_KEY, openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, + // Corporate proxy routing is a supervisor-only egress boundary and the URLs + // may embed proxy credentials (`http://user:pass@proxy`). The workload + // reaches egress through the local policy proxy, never the corporate proxy + // directly, so these must not be inherited by sandbox child processes. + openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, + openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY, + openshell_core::sandbox_env::UPSTREAM_NO_PROXY, ]; pub fn is_supervisor_only_env_var(key: &str) -> bool { @@ -2255,6 +2262,19 @@ mod tests { ); } assert!(stdout.contains("OPENSHELL_ENDPOINT=https://gateway.example.test")); + + // The reserved corporate-proxy variables can carry proxy credentials + // and must be treated as supervisor-only so they are stripped above. + for key in [ + openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, + openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY, + openshell_core::sandbox_env::UPSTREAM_NO_PROXY, + ] { + assert!( + is_supervisor_only_env_var(key), + "{key} must be supervisor-only so it is not leaked to the workload" + ); + } } #[test] diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 22a4161cc2..75c2e1c20b 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -393,8 +393,9 @@ health_check_interval_secs = 10 # 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. +# IPs, CIDRs) are dialed directly. This is an operator-owned egress boundary: +# sandbox and template environment cannot override it, and the conventional +# HTTPS_PROXY/HTTP_PROXY/NO_PROXY variables a sandbox sets do not affect it. # https_proxy = "http://proxy.corp.com:8080" # http_proxy = "http://proxy.corp.com:8080" # no_proxy = "*.svc.cluster.local,10.0.0.0/8" From fa0082c689f93a5c529ab8f5ba6074a61621cdca Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 16 Jul 2026 14:02:53 +0200 Subject: [PATCH 03/28] feat(sandbox,podman): deliver corporate proxy credentials via secret file Proxy credentials were embedded inline in the proxy URL, so they were stored in gateway.toml and exposed in container metadata via 'podman inspect'. Reject inline 'user:pass@' credentials in https_proxy/http_proxy at startup (parsed with the url crate rather than hand-splitting), and add a proxy_auth_file option pointing at a 'user:pass' file. The driver stages that file as a per-sandbox root-only Podman secret, mounts it at a fixed path, and exports only the path in the reserved OPENSHELL_UPSTREAM_PROXY_AUTH_FILE variable, so the credential never appears in config, environment, or container metadata. Reading the file fails closed on a missing, empty, or control-character-bearing value. The supervisor reads the credential from the mounted file and builds the Proxy-Authorization: Basic header, rejecting control characters, and no longer derives credentials from URL userinfo. The auth-file path is added to the child-environment strip list, and the generated gateway.toml is written owner-only (mode 0600). Signed-off-by: Philippe Martin --- Cargo.lock | 1 + architecture/sandbox.md | 9 ++ crates/openshell-core/src/driver_utils.rs | 7 + crates/openshell-core/src/sandbox_env.rs | 9 ++ crates/openshell-driver-podman/Cargo.toml | 1 + crates/openshell-driver-podman/README.md | 21 ++- crates/openshell-driver-podman/src/config.rs | 113 ++++++++++++- .../openshell-driver-podman/src/container.rs | 108 +++++++++++-- crates/openshell-driver-podman/src/driver.rs | 103 +++++++++--- crates/openshell-driver-podman/src/main.rs | 15 +- .../src/upstream_proxy.rs | 153 +++++++++++++----- .../src/process.rs | 2 + docs/reference/gateway-config.mdx | 13 +- tasks/scripts/gateway.sh | 6 + 14 files changed, 468 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b412467610..01d84feb25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3974,6 +3974,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index cb4c6bb6d0..c90c960637 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -118,6 +118,15 @@ 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. +Proxy credentials are never embedded in the URL: an inline `user:pass@` is +rejected at startup because it would be stored in `gateway.toml` and exposed in +container metadata. Operators supply credentials via `proxy_auth_file`; the +driver stages them as a root-only secret mounted at a fixed path and exports +only that path in `OPENSHELL_UPSTREAM_PROXY_AUTH_FILE`. The supervisor reads the +file and builds the `Proxy-Authorization: Basic` header, rejecting credentials +containing control characters. The reserved proxy variables — including the +auth-file path — are stripped from workload child processes. + ## Credentials Provider credentials are stored at the gateway and fetched by the supervisor at diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 9e4411b2a1..f87a23d8ae 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -72,6 +72,13 @@ pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key"; /// Container-side mount path for the per-sandbox JWT token. pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; +/// Container-side mount path for the corporate upstream-proxy credentials. +/// +/// The file holds the `user:pass` userinfo used to build the +/// `Proxy-Authorization` header. It is delivered through a root-only secret +/// mount so the credential never appears in container environment/metadata. +pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy"; + /// Return the XDG state path for a driver's sandbox JWT token file. /// /// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index e86e50380d..4b696b05fe 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -138,3 +138,12 @@ pub const UPSTREAM_HTTP_PROXY: &str = "OPENSHELL_UPSTREAM_HTTP_PROXY"; /// Operator-owned counterpart to [`UPSTREAM_HTTPS_PROXY`]; see that constant /// for the trust-boundary rationale. pub const UPSTREAM_NO_PROXY: &str = "OPENSHELL_UPSTREAM_NO_PROXY"; + +/// Filesystem path (inside the sandbox) to the corporate proxy credentials. +/// +/// The file holds `user:pass` userinfo the supervisor turns into a +/// `Proxy-Authorization: Basic` header. Credentials are delivered through this +/// root-only file rather than embedded in the proxy URL, so they never appear +/// in container environment or metadata. Compute drivers write this in the +/// required-variable tier; the value is a path, not a secret. +pub const UPSTREAM_PROXY_AUTH_FILE: &str = "OPENSHELL_UPSTREAM_PROXY_AUTH_FILE"; diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index ed798c0ab2..5f108fd9cc 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -34,6 +34,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } thiserror = { workspace = true } miette = { workspace = true } +url = { workspace = true } [dev-dependencies] prost-types = { workspace = true } diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 91141565ed..ebc79e1ecd 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -345,14 +345,21 @@ 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_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://` URLs are supported. | +| `OPENSHELL_SANDBOX_HTTP_PROXY` | `--sandbox-http-proxy` | unset | Corporate forward proxy URL 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. +| `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. | + +Through the gateway, the same settings are the `https_proxy`, `http_proxy`, +`no_proxy`, and `proxy_auth_file` keys under `[openshell.drivers.podman]`; see +`docs/reference/gateway-config.mdx`. + +This is an operator-owned egress boundary: the supervisor reads it from reserved +`OPENSHELL_UPSTREAM_*` variables written at highest priority, so sandbox and +template environment cannot override it, and the conventional +`HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox controls do not steer +it. Credentials must be supplied through `proxy_auth_file`; an inline +`user:pass@` in the URL is rejected at startup. ## Rootless-Specific Adaptations diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index c03dc2707e..2be2a79afd 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -6,6 +6,19 @@ use std::net::IpAddr; use std::path::PathBuf; use std::str::FromStr; +/// Parse a proxy URL, defaulting a missing scheme to `http://`. +/// +/// A bare `host:port` (no `://`) is normalized to `http://host:port` so it +/// parses the same way the in-sandbox supervisor treats a scheme-less proxy +/// value. Returns the parsed [`url::Url`] for scheme/userinfo/host inspection. +fn parse_proxy_url(raw: &str) -> Result { + if raw.contains("://") { + url::Url::parse(raw) + } else { + url::Url::parse(&format!("http://{raw}")) + } +} + /// Default Podman bridge network name. pub const DEFAULT_NETWORK_NAME: &str = "openshell"; pub const MACOS_PODMAN_MACHINE_HOST_GATEWAY_IP: &str = "192.168.127.254"; @@ -154,6 +167,15 @@ pub struct PodmanComputeConfig { /// `*.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, + /// Path (on the gateway host) to a file containing the corporate proxy + /// credentials as `user:pass`. + /// + /// Credentials must be supplied through this file, never embedded in the + /// proxy URL: an inline `user:pass@` in `https_proxy`/`http_proxy` is + /// rejected at startup because it would leak into `gateway.toml` and + /// container metadata. The gateway reads this file at sandbox-create time + /// and delivers it to the supervisor through a root-only secret mount. + pub proxy_auth_file: Option, } pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10; @@ -211,9 +233,12 @@ impl PodmanComputeConfig { /// 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. + /// The in-container supervisor only supports `http://` forward proxies, so + /// reject other schemes at config time instead of silently ignoring them + /// inside every sandbox. Credentials must be supplied through + /// `proxy_auth_file`; an inline `user:pass@` in the URL is rejected because + /// it would otherwise be stored in `gateway.toml` and exposed in container + /// metadata. pub fn validate_proxy_config(&self) -> Result<(), crate::client::PodmanApiError> { for (field, value) in [ ("https_proxy", &self.https_proxy), @@ -226,15 +251,46 @@ impl PodmanComputeConfig { "{field} must not be empty when set" ))); } - if let Some((scheme, _)) = trimmed.split_once("://") - && !scheme.eq_ignore_ascii_case("http") - { + + let parsed = parse_proxy_url(trimmed).map_err(|e| { + crate::client::PodmanApiError::InvalidInput(format!( + "{field} is not a valid proxy URL: {e}" + )) + })?; + + if !parsed.scheme().eq_ignore_ascii_case("http") { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "{field} uses unsupported scheme '{}': only http:// forward \ + proxies are supported by the sandbox supervisor", + parsed.scheme() + ))); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(crate::client::PodmanApiError::InvalidInput(format!( + "{field} must not embed credentials in the URL; supply them via \ + proxy_auth_file so they are not stored in config or container metadata" + ))); + } + if parsed.host_str().is_none_or(str::is_empty) { return Err(crate::client::PodmanApiError::InvalidInput(format!( - "{field} uses unsupported scheme '{scheme}': only http:// forward \ - proxies are supported by the sandbox supervisor" + "{field} is missing a proxy host" ))); } } + + if let Some(path) = self.proxy_auth_file.as_deref() { + if path.trim().is_empty() { + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_auth_file must not be empty when set".to_string(), + )); + } + if self.https_proxy.is_none() && self.http_proxy.is_none() { + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_auth_file is set but no https_proxy/http_proxy is configured" + .to_string(), + )); + } + } Ok(()) } @@ -314,6 +370,7 @@ impl Default for PodmanComputeConfig { https_proxy: None, http_proxy: None, no_proxy: None, + proxy_auth_file: None, } } } @@ -344,6 +401,7 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("https_proxy", &self.https_proxy.is_some()) .field("http_proxy", &self.http_proxy.is_some()) .field("no_proxy", &self.no_proxy) + .field("proxy_auth_file", &self.proxy_auth_file.is_some()) .finish() } } @@ -496,6 +554,45 @@ mod tests { assert!(err.to_string().contains("http_proxy"), "{err}"); } + #[test] + fn validate_proxy_config_rejects_inline_credentials() { + for url in [ + "http://user:pass@proxy.corp.com:8080", + "http://user@proxy.corp.com:8080", + "user:pass@proxy.corp.com:8080", + ] { + let cfg = PodmanComputeConfig { + https_proxy: Some(url.to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!( + err.to_string().contains("proxy_auth_file"), + "{url} should be rejected and point at proxy_auth_file: {err}" + ); + } + } + + #[test] + fn validate_proxy_config_accepts_auth_file_with_proxy() { + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + proxy_auth_file: Some("/etc/openshell/secrets/proxy-auth".to_string()), + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok()); + } + + #[test] + fn validate_proxy_config_rejects_auth_file_without_proxy() { + let cfg = PodmanComputeConfig { + proxy_auth_file: Some("/etc/openshell/secrets/proxy-auth".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("proxy_auth_file"), "{err}"); + } + // ── TLS config validation ───────────────────────────────────────── #[test] diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 5989f69d3d..1216cb5c5b 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -55,12 +55,15 @@ const VOLUME_PREFIX: &str = "openshell-sandbox-"; /// Secret name prefix for per-sandbox gateway JWTs. const TOKEN_SECRET_PREFIX: &str = "openshell-token-"; +const PROXY_AUTH_SECRET_PREFIX: &str = "openshell-proxy-auth-"; /// Container-side mount paths for client TLS materials and the sandbox token. const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH; const TLS_CERT_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CERT_MOUNT_PATH; const TLS_KEY_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_KEY_MOUNT_PATH; const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; +const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = + openshell_core::driver_utils::UPSTREAM_PROXY_AUTH_MOUNT_PATH; /// Directory inside sandbox containers where the supervisor binary is mounted. const SUPERVISOR_MOUNT_DIR: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_DIR; @@ -162,6 +165,12 @@ pub fn token_secret_name(sandbox_id: &str) -> String { format!("{TOKEN_SECRET_PREFIX}{sandbox_id}") } +/// Build the per-sandbox Podman secret name for the corporate proxy credentials. +#[must_use] +pub fn proxy_auth_secret_name(sandbox_id: &str) -> String { + format!("{PROXY_AUTH_SECRET_PREFIX}{sandbox_id}") +} + /// Truncate a container ID to 12 characters (standard short form). #[must_use] pub fn short_id(id: &str) -> String { @@ -399,6 +408,14 @@ fn build_env( // stripped, then replaced with the operator value (if configured), so the // supervisor can never observe a reserved proxy variable the operator did // not set. + // + // Proxy credentials are never passed through the environment: when + // `proxy_auth_file` is configured the supervisor reads them from a + // root-only secret mount, and only the mount *path* is exported. + let proxy_auth_mount = config + .proxy_auth_file + .as_ref() + .map(|_| UPSTREAM_PROXY_AUTH_MOUNT_PATH.to_string()); for (name, value) in [ ( openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, @@ -412,6 +429,10 @@ fn build_env( openshell_core::sandbox_env::UPSTREAM_NO_PROXY, &config.no_proxy, ), + ( + openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, + &proxy_auth_mount, + ), ] { env.remove(name); if let Some(value) = value { @@ -1026,15 +1047,32 @@ pub fn build_container_spec_with_token_and_gpu_devices( }, resource_limits, secret_env: BTreeMap::new(), - secrets: token_secret_name.map_or_else(Vec::new, |source| { - vec![SecretMount { - source: source.to_string(), - target: SANDBOX_TOKEN_MOUNT_PATH.into(), - uid: 0, - gid: 0, - mode: 0o400, - }] - }), + secrets: { + let mut secrets = Vec::new(); + if let Some(source) = token_secret_name { + secrets.push(SecretMount { + source: source.to_string(), + target: SANDBOX_TOKEN_MOUNT_PATH.into(), + uid: 0, + gid: 0, + mode: 0o400, + }); + } + // Corporate proxy credentials, when configured, are mounted as a + // root-only secret. The driver creates a matching Podman secret + // (see `create_sandbox_proxy_auth_secret`) named deterministically + // from the sandbox id, so no name needs threading through here. + if config.proxy_auth_file.is_some() { + secrets.push(SecretMount { + source: proxy_auth_secret_name(&sandbox.id), + target: UPSTREAM_PROXY_AUTH_MOUNT_PATH.into(), + uid: 0, + gid: 0, + mode: 0o400, + }); + } + secrets + }, stop_timeout: config.stop_timeout_secs, // Inject stable host aliases into /etc/hosts so sandbox containers can // reach services on the host. `host.openshell.internal` is the driver- @@ -2518,6 +2556,58 @@ mod tests { ); } + #[test] + fn container_spec_proxy_auth_file_mounts_secret_and_sets_path_only() { + let sandbox = test_sandbox("proxy-id", "proxy-name"); + let mut config = test_config(); + config.https_proxy = Some("http://proxy.corp.com:8080".to_string()); + config.proxy_auth_file = Some("/etc/openshell/secrets/proxy-auth".to_string()); + + let spec = build_container_spec(&sandbox, &config); + let env_map = spec["env"].as_object().expect("env should be an object"); + + // The supervisor gets only the mount *path*, never the credential. + assert_eq!( + env_map + .get(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE) + .and_then(|v| v.as_str()), + Some(UPSTREAM_PROXY_AUTH_MOUNT_PATH) + ); + // The URL carried in the environment must remain credential-free. + assert_eq!( + env_map + .get(openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY) + .and_then(|v| v.as_str()), + Some("http://proxy.corp.com:8080") + ); + + let secrets = spec["secrets"] + .as_array() + .expect("secrets should be an array"); + assert!( + secrets.iter().any(|secret| { + secret["source"].as_str() == Some(proxy_auth_secret_name(&sandbox.id).as_str()) + && secret["target"].as_str() == Some(UPSTREAM_PROXY_AUTH_MOUNT_PATH) + && secret["mode"].as_u64() == Some(0o400) + }), + "proxy credentials must be delivered through a root-only secret mount" + ); + } + + #[test] + fn container_spec_omits_proxy_auth_mount_when_unconfigured() { + let sandbox = test_sandbox("proxy-id", "proxy-name"); + 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!( + !env_map.contains_key(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE), + "auth-file path must be absent when no proxy_auth_file is configured" + ); + } + #[test] fn container_spec_omits_tls_without_config() { let sandbox = test_sandbox("notls-id", "notls-name"); diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 25a2eff5b4..49db01c15d 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -114,6 +114,56 @@ async fn cleanup_sandbox_token_secret(client: &PodmanClient, secret_name: &str) } } +/// Read the operator's proxy credentials file and stage it as a per-sandbox +/// Podman secret, so the credentials reach the supervisor through a root-only +/// mount rather than the container environment. +/// +/// Fails closed: when `proxy_auth_file` is configured but cannot be read or is +/// empty/malformed, the sandbox is not created. +async fn create_sandbox_proxy_auth_secret( + client: &PodmanClient, + config: &PodmanComputeConfig, + sandbox: &DriverSandbox, +) -> Result, ComputeDriverError> { + let Some(path) = config.proxy_auth_file.as_deref() else { + return Ok(None); + }; + + let raw = tokio::fs::read_to_string(path).await.map_err(|e| { + ComputeDriverError::Message(format!("failed to read proxy_auth_file '{path}': {e}")) + })?; + let credential = raw.trim(); + if credential.is_empty() { + return Err(ComputeDriverError::InvalidArgument(format!( + "proxy_auth_file '{path}' is empty" + ))); + } + // A credential containing CR/LF/NUL would allow HTTP header injection once + // the supervisor places it in the Proxy-Authorization header. + if credential.contains(['\r', '\n', '\0']) { + return Err(ComputeDriverError::InvalidArgument(format!( + "proxy_auth_file '{path}' must not contain control characters" + ))); + } + + let secret_name = container::proxy_auth_secret_name(&sandbox.id); + client + .create_secret(&secret_name, format!("{credential}\n").as_bytes()) + .await + .map_err(ComputeDriverError::from)?; + Ok(Some(secret_name)) +} + +async fn cleanup_sandbox_proxy_auth_secret(client: &PodmanClient, secret_name: &str) { + if let Err(err) = client.remove_secret(secret_name).await { + warn!( + secret = %secret_name, + error = %err, + "Failed to remove Podman sandbox proxy-auth secret" + ); + } +} + fn local_podman_cdi_gpu_inventory_from(dev_root: &Path) -> CdiGpuInventory { let mut device_ids = std::fs::read_dir(dev_root) .ok() @@ -517,6 +567,29 @@ impl PodmanComputeDriver { return Err(e); } }; + let proxy_auth_secret_name = + match create_sandbox_proxy_auth_secret(&self.client, &self.config, sandbox).await { + Ok(name) => name, + Err(e) => { + let _ = self.client.remove_volume(&vol_name).await; + if let Some(secret) = token_secret_name.as_deref() { + cleanup_sandbox_token_secret(&self.client, secret).await; + } + return Err(e); + } + }; + + // Clean up the volume and both per-sandbox secrets on any failure past + // this point. + let cleanup_created = || async { + let _ = self.client.remove_volume(&vol_name).await; + if let Some(secret) = token_secret_name.as_deref() { + cleanup_sandbox_token_secret(&self.client, secret).await; + } + if let Some(secret) = proxy_auth_secret_name.as_deref() { + cleanup_sandbox_proxy_auth_secret(&self.client, secret).await; + } + }; // 3. Create container. let gpu_devices = match self.resolve_gpu_cdi_devices( @@ -526,10 +599,7 @@ impl PodmanComputeDriver { ) { Ok(devices) => devices, Err(e) => { - let _ = self.client.remove_volume(&vol_name).await; - if let Some(secret) = token_secret_name.as_deref() { - cleanup_sandbox_token_secret(&self.client, secret).await; - } + cleanup_created().await; return Err(e); } }; @@ -541,10 +611,7 @@ impl PodmanComputeDriver { ) { Ok(spec) => spec, Err(e) => { - let _ = self.client.remove_volume(&vol_name).await; - if let Some(secret) = token_secret_name.as_deref() { - cleanup_sandbox_token_secret(&self.client, secret).await; - } + cleanup_created().await; return Err(e); } }; @@ -555,17 +622,11 @@ impl PodmanComputeDriver { // sandbox's ID, not the conflicting container's ID (which // has the same name but a different ID), so it would be // orphaned otherwise. - let _ = self.client.remove_volume(&vol_name).await; - if let Some(secret) = token_secret_name.as_deref() { - cleanup_sandbox_token_secret(&self.client, secret).await; - } + cleanup_created().await; return Err(ComputeDriverError::AlreadyExists); } Err(e) => { - let _ = self.client.remove_volume(&vol_name).await; - if let Some(secret) = token_secret_name.as_deref() { - cleanup_sandbox_token_secret(&self.client, secret).await; - } + cleanup_created().await; return Err(ComputeDriverError::from(e)); } } @@ -578,10 +639,7 @@ impl PodmanComputeDriver { "Failed to start container; cleaning up" ); let _ = self.client.remove_container(&name).await; - let _ = self.client.remove_volume(&vol_name).await; - if let Some(secret) = token_secret_name.as_deref() { - cleanup_sandbox_token_secret(&self.client, secret).await; - } + cleanup_created().await; return Err(ComputeDriverError::from(e)); } @@ -680,6 +738,11 @@ impl PodmanComputeDriver { ); } cleanup_sandbox_token_secret(&self.client, &container::token_secret_name(sandbox_id)).await; + cleanup_sandbox_proxy_auth_secret( + &self.client, + &container::proxy_auth_secret_name(sandbox_id), + ) + .await; Ok(container_existed) } diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index 116ad1553c..e7d8f374d4 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -104,18 +104,26 @@ struct Args { #[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). + /// Corporate forward proxy URL for the supervisor's upstream TLS dials + /// (http:// only). Credentials must not be embedded in the URL; use + /// `--sandbox-proxy-auth-file` instead. #[arg(long, env = "OPENSHELL_SANDBOX_HTTPS_PROXY")] sandbox_https_proxy: Option, - /// Corporate forward proxy URL injected as `HTTP_PROXY` (http:// only). + /// Corporate forward proxy URL for plain HTTP (http:// only). Credentials + /// must not be embedded in the URL; use `--sandbox-proxy-auth-file`. #[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, + + /// Path to a file containing the corporate proxy credentials as + /// `user:pass`. Delivered to the supervisor through a root-only secret + /// mount so the credentials never appear in config or container metadata. + #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_AUTH_FILE")] + sandbox_proxy_auth_file: Option, } #[tokio::main] @@ -153,6 +161,7 @@ async fn main() -> Result<()> { https_proxy: args.sandbox_https_proxy, http_proxy: args.sandbox_http_proxy, no_proxy: args.sandbox_no_proxy, + proxy_auth_file: args.sandbox_proxy_auth_file, ..PodmanComputeConfig::default() }) .await diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 1f0ffb50d8..aa841f5f3c 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -58,7 +58,7 @@ pub enum UpstreamScheme { pub struct ProxyEndpoint { host: String, port: u16, - /// Pre-computed `Basic ` header value from URL userinfo. + /// Pre-computed `Basic ` header value from the proxy auth file. /// Never logged. proxy_authorization: Option, } @@ -188,7 +188,26 @@ impl UpstreamProxyConfig { /// template environment cannot override them. #[must_use] pub fn from_env() -> Option { - Self::from_lookup(|name| std::env::var(name).ok()) + let mut config = Self::from_lookup(|name| std::env::var(name).ok())?; + + // Load proxy credentials from the reserved auth file, if configured, + // and apply them to every endpoint. The file is delivered through a + // root-only secret mount so the credentials never appear in the + // environment or container metadata. + if let Some(path) = std::env::var(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE) + .ok() + .filter(|p| !p.trim().is_empty()) + { + match std::fs::read_to_string(&path) { + Ok(credential) => config.apply_proxy_auth(basic_auth_header(&credential)), + Err(err) => warn!( + "failed to read upstream proxy auth file '{path}': {err}; \ + proceeding without proxy credentials" + ), + } + } + + Some(config) } fn from_lookup(lookup: impl Fn(&str) -> Option) -> Option { @@ -211,6 +230,19 @@ impl UpstreamProxyConfig { }) } + /// Attach a pre-built `Proxy-Authorization` header value to every + /// configured endpoint. A `None` value clears any existing credentials. + fn apply_proxy_auth(&mut self, proxy_authorization: Option) { + for endpoint in [self.https.as_mut(), self.http.as_mut()] + .into_iter() + .flatten() + { + endpoint + .proxy_authorization + .clone_from(&proxy_authorization); + } + } + /// The corporate proxy to use for `host`, or `None` when the destination /// must be dialed directly. #[must_use] @@ -239,8 +271,13 @@ impl UpstreamProxyConfig { } } -/// Parse an `http://[user:pass@]host[:port]` proxy URL. Unsupported schemes -/// (TLS or SOCKS proxies) are rejected with a warning. +/// Parse an `http://host[:port]` proxy URL. Unsupported schemes (TLS or SOCKS +/// proxies) are rejected with a warning. +/// +/// Credentials are never taken from the URL: they are delivered out of band +/// through [`UPSTREAM_PROXY_AUTH_FILE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE) +/// so they never appear in config or container metadata. Any `user:pass@` +/// userinfo present in the URL is ignored with a warning. fn parse_proxy_url(raw: &str, var_name: &str) -> Option { let raw = raw.trim(); let rest = match raw.split_once("://") { @@ -258,26 +295,24 @@ fn parse_proxy_url(raw: &str, var_name: &str) -> Option { }; // 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 authority = match rest.rsplit_once('@') { + Some((_userinfo, authority)) => { + warn!( + "{var_name} contains inline credentials, which are ignored; \ + supply proxy credentials via the proxy auth file" + ); + authority + } + 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, + proxy_authorization: None, }) } @@ -305,28 +340,22 @@ fn split_host_port(authority: &str) -> Option<(String, u16)> { } } -/// 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; - } +/// Build a `Proxy-Authorization: Basic ` header value from a raw +/// `user:pass` credential. +/// +/// Returns `None` for an empty credential or one containing control characters +/// (CR, LF, NUL) that could inject additional HTTP headers. The credential is +/// used verbatim: it is delivered through a trusted operator file, not a URL, +/// so there is no percent-encoding to decode. +fn basic_auth_header(credential: &str) -> Option { + let credential = credential.trim(); + if credential.is_empty() || credential.contains(|c: char| c.is_control()) { + return None; } - String::from_utf8_lossy(&out).into_owned() + Some(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(credential) + )) } /// Open a tunnel to `host:port` through the corporate proxy with HTTP CONNECT. @@ -510,17 +539,55 @@ mod tests { } #[test] - fn userinfo_becomes_basic_auth() { - let cfg = config_from(&[(HTTPS_PROXY, "http://user:p%40ss@proxy:8080")]).unwrap(); + fn url_userinfo_is_ignored_not_used_as_credentials() { + // Inline credentials in the URL must never become the proxy auth; + // credentials come only from the auth file. + let cfg = config_from(&[(HTTPS_PROXY, "http://user:secret@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}")); + assert_eq!(ep.display_addr(), "proxy:8080"); + assert!( + ep.proxy_authorization.is_none(), + "URL userinfo must not be used as proxy credentials" + ); + } + + #[test] + fn basic_auth_header_encodes_and_rejects_control_chars() { + assert_eq!( + basic_auth_header("user:p@ss").as_deref(), + Some( + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("user:p@ss") + ) + .as_str() + ) + ); + assert!(basic_auth_header(" ").is_none()); + assert!(basic_auth_header("user:pa\r\nss").is_none()); + assert!(basic_auth_header("user:pa\nInjected: header").is_none()); + } + + #[test] + fn apply_proxy_auth_sets_header_on_all_endpoints() { + let mut cfg = config_from(&[ + (HTTPS_PROXY, "http://proxy:8080"), + (HTTP_PROXY, "http://proxy:3128"), + ]) + .unwrap(); + cfg.apply_proxy_auth(basic_auth_header("user:secret")); + for scheme in [UpstreamScheme::Https, UpstreamScheme::Http] { + let ep = cfg.proxy_for(scheme, "example.com").unwrap(); + let auth = ep.proxy_authorization.as_deref().unwrap(); + let expected = base64::engine::general_purpose::STANDARD.encode("user:secret"); + 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 mut cfg = config_from(&[(HTTPS_PROXY, "http://proxy:8080")]).unwrap(); + cfg.apply_proxy_auth(basic_auth_header("user:secret")); let debug = format!("{cfg:?}"); assert!(!debug.contains("secret")); assert!(!cfg.summary().contains("secret")); diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 9aa1a8c620..b5f08f3f1f 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -85,6 +85,7 @@ const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[ openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY, openshell_core::sandbox_env::UPSTREAM_NO_PROXY, + openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, ]; pub fn is_supervisor_only_env_var(key: &str) -> bool { @@ -2269,6 +2270,7 @@ mod tests { openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY, openshell_core::sandbox_env::UPSTREAM_NO_PROXY, + openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, ] { assert!( is_supervisor_only_env_var(key), diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 75c2e1c20b..6d123e8bfb 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -396,9 +396,16 @@ health_check_interval_secs = 10 # IPs, CIDRs) are dialed directly. This is an operator-owned egress boundary: # sandbox and template environment cannot override it, and the conventional # HTTPS_PROXY/HTTP_PROXY/NO_PROXY variables a sandbox sets do not affect it. -# https_proxy = "http://proxy.corp.com:8080" -# http_proxy = "http://proxy.corp.com:8080" -# no_proxy = "*.svc.cluster.local,10.0.0.0/8" +# +# Credentials must NOT be embedded in the URL (an inline user:pass@ is +# rejected at startup, since it would be stored here and exposed in container +# metadata). Instead point proxy_auth_file at a file containing "user:pass"; +# the gateway delivers it to the supervisor through a root-only secret mount. +# Keep gateway.toml and the auth file owner-readable only (mode 0600). +# https_proxy = "http://proxy.corp.com:8080" +# http_proxy = "http://proxy.corp.com:8080" +# no_proxy = "*.svc.cluster.local,10.0.0.0/8" +# proxy_auth_file = "/etc/openshell/secrets/proxy-auth" ``` ### MicroVM diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 53edff81d7..92e67bddd9 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -310,6 +310,9 @@ echo "Generating local gateway credentials..." mkdir -p "${STATE_DIR}" CONFIG_PATH="${STATE_DIR}/gateway.toml" +# The config may reference credential-bearing material (e.g. proxy_auth_file); +# keep it owner-only regardless of the ambient umask. +install -m 600 /dev/null "${CONFIG_PATH}" cat >"${CONFIG_PATH}" <>"${CONFIG_PATH}" fi + if [[ -n "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE:-}" ]]; then + printf 'proxy_auth_file = "%s"\n' "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE}" >>"${CONFIG_PATH}" + fi ;; esac From a82cf264e4ffdc36f0160152bb0c12eb6fdae19a Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 16 Jul 2026 14:57:38 +0200 Subject: [PATCH 04/28] fix(sandbox,podman): fail closed on invalid upstream proxy configuration The reserved OPENSHELL_UPSTREAM_* variables are an operator-owned egress boundary, but the supervisor treated present-but-invalid values as unset: an unsupported or malformed proxy URL was ignored with a warning, an unreadable auth file proceeded without credentials, and a malformed credential silently became unauthenticated. Any of these could quietly downgrade the corporate proxy boundary to direct dialing or unauthenticated proxy access. Make every configured-but-invalid proxy or auth setting fatal to supervisor proxy startup, emit an OCSF ConfigStateChange failure event before refusing, and share URL validation semantics between the Podman driver and the supervisor through a single validator in openshell-core (parse_upstream_proxy_url), so a value accepted at sandbox-create time can never be rejected in-container or vice versa. Inline user:pass@ URL credentials are now fatal in the supervisor too (previously warn-and-strip), matching the driver. Unset or empty variables still mean no proxy; only present-but-invalid values fail. Error paths never include credential content. Addresses the fail-closed review item on #2245. Signed-off-by: Philippe Martin --- Cargo.lock | 1 - architecture/sandbox.md | 22 +- crates/openshell-core/src/driver_utils.rs | 152 ++++++++ crates/openshell-driver-podman/Cargo.toml | 1 - crates/openshell-driver-podman/src/config.rs | 72 ++-- .../openshell-supervisor-network/src/proxy.rs | 21 +- .../src/upstream_proxy.rs | 361 ++++++++++-------- docs/reference/gateway-config.mdx | 5 + 8 files changed, 420 insertions(+), 215 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01d84feb25..b412467610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3974,7 +3974,6 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", - "url", ] [[package]] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index c90c960637..3aa92256ce 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -118,14 +118,22 @@ 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. +The configuration is fail-closed: a reserved variable that is present but +invalid — an unsupported or malformed proxy URL, an unreadable auth file, or a +malformed credential — is fatal to supervisor startup instead of being treated +as unset, so a misconfiguration can never silently degrade to direct dialing +or unauthenticated proxy access. The driver validates the same rules at +sandbox-create time through a validator shared with the supervisor +(`openshell_core::driver_utils::parse_upstream_proxy_url`). + Proxy credentials are never embedded in the URL: an inline `user:pass@` is -rejected at startup because it would be stored in `gateway.toml` and exposed in -container metadata. Operators supply credentials via `proxy_auth_file`; the -driver stages them as a root-only secret mounted at a fixed path and exports -only that path in `OPENSHELL_UPSTREAM_PROXY_AUTH_FILE`. The supervisor reads the -file and builds the `Proxy-Authorization: Basic` header, rejecting credentials -containing control characters. The reserved proxy variables — including the -auth-file path — are stripped from workload child processes. +rejected because it would be stored in `gateway.toml` and exposed in container +metadata. Operators supply credentials via `proxy_auth_file`; the driver +stages them as a root-only secret mounted at a fixed path and exports only +that path in `OPENSHELL_UPSTREAM_PROXY_AUTH_FILE`. The supervisor reads the +file and builds the `Proxy-Authorization: Basic` header; an empty credential +or one containing control characters is fatal. The reserved proxy variables — +including the auth-file path — are stripped from workload child processes. ## Credentials diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index f87a23d8ae..4a33cae610 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -79,6 +79,97 @@ pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; /// mount so the credential never appears in container environment/metadata. pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy"; +/// A validated corporate upstream-proxy address. +/// +/// Produced by [`parse_upstream_proxy_url`], which is the single source of +/// truth for what counts as a valid upstream proxy URL. Compute drivers use +/// it to reject bad operator config at sandbox-create time, and the +/// in-container supervisor applies the same rules to the reserved +/// `OPENSHELL_UPSTREAM_*` variables so a value one side accepts is never +/// rejected (or silently ignored) by the other. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamProxyAddr { + /// Proxy hostname, IPv4, or IPv6 address (IPv6 without brackets). + pub host: String, + /// Proxy TCP port (defaults to 80 when the URL has none). + pub port: u16, +} + +/// Why an upstream proxy URL was rejected by [`parse_upstream_proxy_url`]. +/// +/// Kept as a typed error so each consumer (driver config validation, +/// supervisor startup) can phrase the message for its own surface while +/// enforcing identical semantics. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum UpstreamProxyUrlError { + /// The value is empty or whitespace-only. + #[error("proxy URL is empty")] + Empty, + /// The value does not parse as a URL. + #[error("not a valid proxy URL: {0}")] + Invalid(url::ParseError), + /// The URL uses a scheme other than `http` (TLS and SOCKS proxies are + /// not supported by the sandbox supervisor). + #[error( + "unsupported proxy scheme '{0}': only http:// forward proxies are \ + supported by the sandbox supervisor" + )] + UnsupportedScheme(String), + /// The URL embeds `user:pass@` credentials, which would leak into config + /// and container metadata. Credentials must come from the proxy auth file. + #[error("proxy URL must not embed credentials; supply them via the proxy auth file")] + InlineCredentials, + /// The URL has no host component. + #[error("proxy URL is missing a proxy host")] + MissingHost, +} + +/// Parse and validate a corporate upstream-proxy URL. +/// +/// A bare `host:port` (no `://`) is normalized to `http://host:port`. Only +/// `http://` proxies are accepted, inline userinfo is rejected, and the port +/// defaults to 80. +/// +/// # Errors +/// +/// Returns an [`UpstreamProxyUrlError`] describing the first rule the value +/// violates. +pub fn parse_upstream_proxy_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(UpstreamProxyUrlError::Empty); + } + let parsed = if trimmed.contains("://") { + url::Url::parse(trimmed) + } else { + url::Url::parse(&format!("http://{trimmed}")) + } + .map_err(UpstreamProxyUrlError::Invalid)?; + + if !parsed.scheme().eq_ignore_ascii_case("http") { + return Err(UpstreamProxyUrlError::UnsupportedScheme( + parsed.scheme().to_string(), + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(UpstreamProxyUrlError::InlineCredentials); + } + let host = match parsed.host() { + // `Host::Ipv6` renders without brackets, which is what socket + // connect APIs expect. + Some(url::Host::Ipv6(ip)) => ip.to_string(), + Some(host) => host.to_string(), + None => return Err(UpstreamProxyUrlError::MissingHost), + }; + if host.is_empty() { + return Err(UpstreamProxyUrlError::MissingHost); + } + Ok(UpstreamProxyAddr { + host, + port: parsed.port().unwrap_or(80), + }) +} + /// Return the XDG state path for a driver's sandbox JWT token file. /// /// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. @@ -167,3 +258,64 @@ pub fn supervisor_image_tag(image: &str) -> Option<&str> { pub fn supervisor_image_should_refresh(image: &str) -> bool { matches!(supervisor_image_tag(image), Some("dev" | "latest")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upstream_proxy_url_accepts_http_with_port() { + let addr = parse_upstream_proxy_url("http://proxy.corp.com:8080").unwrap(); + assert_eq!(addr.host, "proxy.corp.com"); + assert_eq!(addr.port, 8080); + } + + #[test] + fn upstream_proxy_url_defaults_scheme_and_port() { + let addr = parse_upstream_proxy_url("proxy.corp.com").unwrap(); + assert_eq!(addr.host, "proxy.corp.com"); + assert_eq!(addr.port, 80); + let addr = parse_upstream_proxy_url("proxy.corp.com:3128").unwrap(); + assert_eq!(addr.port, 3128); + } + + #[test] + fn upstream_proxy_url_ipv6_host_is_bracket_free() { + let addr = parse_upstream_proxy_url("http://[fd00::1]:8080").unwrap(); + assert_eq!(addr.host, "fd00::1"); + assert_eq!(addr.port, 8080); + } + + #[test] + fn upstream_proxy_url_rejects_tls_and_socks_schemes() { + for url in ["https://proxy:443", "socks5://proxy:1080"] { + assert!(matches!( + parse_upstream_proxy_url(url), + Err(UpstreamProxyUrlError::UnsupportedScheme(_)) + )); + } + } + + #[test] + fn upstream_proxy_url_rejects_inline_credentials() { + for url in ["http://user:pass@proxy:8080", "http://user@proxy:8080"] { + assert_eq!( + parse_upstream_proxy_url(url), + Err(UpstreamProxyUrlError::InlineCredentials) + ); + } + } + + #[test] + fn upstream_proxy_url_rejects_empty_and_invalid() { + assert_eq!( + parse_upstream_proxy_url(" "), + Err(UpstreamProxyUrlError::Empty) + ); + assert!(matches!( + parse_upstream_proxy_url("http://proxy:notaport"), + Err(UpstreamProxyUrlError::Invalid(_)) + )); + assert!(parse_upstream_proxy_url("http://").is_err()); + } +} diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index 5f108fd9cc..ed798c0ab2 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -34,7 +34,6 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } thiserror = { workspace = true } miette = { workspace = true } -url = { workspace = true } [dev-dependencies] prost-types = { workspace = true } diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 2be2a79afd..9df4f06b76 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -6,19 +6,6 @@ use std::net::IpAddr; use std::path::PathBuf; use std::str::FromStr; -/// Parse a proxy URL, defaulting a missing scheme to `http://`. -/// -/// A bare `host:port` (no `://`) is normalized to `http://host:port` so it -/// parses the same way the in-sandbox supervisor treats a scheme-less proxy -/// value. Returns the parsed [`url::Url`] for scheme/userinfo/host inspection. -fn parse_proxy_url(raw: &str) -> Result { - if raw.contains("://") { - url::Url::parse(raw) - } else { - url::Url::parse(&format!("http://{raw}")) - } -} - /// Default Podman bridge network name. pub const DEFAULT_NETWORK_NAME: &str = "openshell"; pub const MACOS_PODMAN_MACHINE_HOST_GATEWAY_IP: &str = "192.168.127.254"; @@ -233,49 +220,34 @@ impl PodmanComputeConfig { /// 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. Credentials must be supplied through - /// `proxy_auth_file`; an inline `user:pass@` in the URL is rejected because - /// it would otherwise be stored in `gateway.toml` and exposed in container - /// metadata. + /// Shares validation semantics with the in-container supervisor through + /// [`openshell_core::driver_utils::parse_upstream_proxy_url`], so a value + /// accepted here can never be rejected by the supervisor at sandbox + /// startup (or vice versa). The supervisor only supports `http://` + /// forward proxies, so other schemes are rejected at config time instead + /// of failing inside every sandbox. Credentials must be supplied through + /// `proxy_auth_file`; an inline `user:pass@` in the URL is rejected + /// because it would otherwise be stored in `gateway.toml` and exposed in + /// container metadata. pub fn validate_proxy_config(&self) -> Result<(), crate::client::PodmanApiError> { + use openshell_core::driver_utils::{UpstreamProxyUrlError, parse_upstream_proxy_url}; 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" - ))); - } - - let parsed = parse_proxy_url(trimmed).map_err(|e| { - crate::client::PodmanApiError::InvalidInput(format!( - "{field} is not a valid proxy URL: {e}" - )) + parse_upstream_proxy_url(url).map_err(|err| { + crate::client::PodmanApiError::InvalidInput(match err { + UpstreamProxyUrlError::Empty => { + format!("{field} must not be empty when set") + } + UpstreamProxyUrlError::InlineCredentials => format!( + "{field} must not embed credentials in the URL; supply them via \ + proxy_auth_file so they are not stored in config or container metadata" + ), + err => format!("{field} {err}"), + }) })?; - - if !parsed.scheme().eq_ignore_ascii_case("http") { - return Err(crate::client::PodmanApiError::InvalidInput(format!( - "{field} uses unsupported scheme '{}': only http:// forward \ - proxies are supported by the sandbox supervisor", - parsed.scheme() - ))); - } - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(crate::client::PodmanApiError::InvalidInput(format!( - "{field} must not embed credentials in the URL; supply them via \ - proxy_auth_file so they are not stored in config or container metadata" - ))); - } - if parsed.host_str().is_none_or(str::is_empty) { - return Err(crate::client::PodmanApiError::InvalidInput(format!( - "{field} is missing a proxy host" - ))); - } } if let Some(path) = self.proxy_auth_file.as_deref() { @@ -538,7 +510,7 @@ mod tests { }; let err = cfg.validate_proxy_config().unwrap_err(); assert!( - err.to_string().contains("unsupported scheme"), + err.to_string().contains("unsupported proxy scheme"), "{url}: {err}" ); } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 54c2dbe770..a5d351c1c4 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -246,8 +246,27 @@ impl ProxyHandle { // variables. Read once at startup; the workload cannot influence the // supervisor's environment, and the conventional HTTPS_PROXY/NO_PROXY // variables it does control are ignored on this path. + // + // This is an operator-owned security boundary, so a present-but-invalid + // value (bad proxy URL, unreadable auth file, malformed credential) is + // fatal to proxy startup: failing closed prevents a misconfiguration + // from silently degrading to direct dialing or unauthenticated proxy + // access. let upstream_proxy: Arc> = - Arc::new(UpstreamProxyConfig::from_env()); + Arc::new(UpstreamProxyConfig::from_env().map_err(|err| { + let event = + openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(openshell_ocsf::StateId::Disabled, "invalid") + .message(format!( + "Upstream corporate proxy configuration invalid; \ + refusing to start: {err}" + )) + .build(); + ocsf_emit!(event); + miette::miette!("invalid upstream corporate proxy configuration: {err}") + })?); if let Some(cfg) = upstream_proxy.as_ref() { let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(SeverityId::Informational) diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index aa841f5f3c..aeb92e8f1a 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -20,8 +20,14 @@ //! template environment cannot override. //! //! Scope and invariants: -//! - Only `http://` proxy URLs are supported. `https://` and SOCKS proxies -//! are ignored with a warning. +//! - Only `http://` proxy URLs are supported. Configuration is fail-closed: +//! any present-but-invalid reserved value — an unsupported (`https://`, +//! SOCKS) or malformed proxy URL, an unreadable auth file, or a malformed +//! credential — is a fatal startup error rather than being silently +//! ignored, so a typo can never quietly downgrade the operator's egress +//! boundary to direct dialing or unauthenticated proxy access. Validation +//! semantics are shared with the compute driver via +//! [`openshell_core::driver_utils::parse_upstream_proxy_url`]. //! - Policy evaluation, DNS resolution, and SSRF checks run exactly as in the //! direct-dial path; the corporate proxy only replaces the final TCP dial. //! - The reserved `NO_PROXY` list decides which destinations bypass the @@ -35,7 +41,7 @@ use std::time::Duration; use base64::Engine as _; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tracing::{debug, warn}; +use tracing::debug; /// Upper bound on the corporate proxy's CONNECT response header block. const MAX_CONNECT_RESPONSE_BYTES: usize = 8 * 1024; @@ -176,8 +182,10 @@ impl UpstreamProxyConfig { /// supervisor's reserved environment variables /// ([`UPSTREAM_HTTPS_PROXY`](openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY), /// [`UPSTREAM_HTTP_PROXY`](openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY), - /// [`UPSTREAM_NO_PROXY`](openshell_core::sandbox_env::UPSTREAM_NO_PROXY)). - /// Returns `None` when no usable proxy is configured. + /// [`UPSTREAM_NO_PROXY`](openshell_core::sandbox_env::UPSTREAM_NO_PROXY), + /// [`UPSTREAM_PROXY_AUTH_FILE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE)). + /// Returns `Ok(None)` when no proxy is configured (unset or empty + /// variables). /// /// The conventional `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` /// variables are intentionally ignored here: they are set by the sandbox @@ -186,60 +194,72 @@ impl UpstreamProxyConfig { /// upstream proxy or disable proxying entirely. The compute driver writes /// the reserved names in its required-variable tier, where sandbox and /// template environment cannot override them. - #[must_use] - pub fn from_env() -> Option { - let mut config = Self::from_lookup(|name| std::env::var(name).ok())?; - - // Load proxy credentials from the reserved auth file, if configured, - // and apply them to every endpoint. The file is delivered through a - // root-only secret mount so the credentials never appear in the - // environment or container metadata. - if let Some(path) = std::env::var(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE) - .ok() - .filter(|p| !p.trim().is_empty()) - { - match std::fs::read_to_string(&path) { - Ok(credential) => config.apply_proxy_auth(basic_auth_header(&credential)), - Err(err) => warn!( - "failed to read upstream proxy auth file '{path}': {err}; \ - proceeding without proxy credentials" - ), - } - } - - Some(config) - } - - fn from_lookup(lookup: impl Fn(&str) -> Option) -> Option { + /// + /// # Errors + /// + /// These reserved variables are an operator-owned security boundary, so + /// any present-but-invalid value is fatal instead of being treated as + /// unset: an invalid or unsupported proxy URL, an auth file that is set + /// but unreadable or holds a malformed credential, or an auth file with + /// no proxy configured. Failing closed here prevents a misconfiguration + /// from silently degrading to direct dialing or unauthenticated proxy + /// access. + pub fn from_env() -> Result, String> { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result, String> { use openshell_core::sandbox_env::{ - UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, + UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, UPSTREAM_PROXY_AUTH_FILE, }; let var = |name: &str| lookup(name).filter(|v| !v.trim().is_empty()); - let https = - var(UPSTREAM_HTTPS_PROXY).and_then(|url| parse_proxy_url(&url, UPSTREAM_HTTPS_PROXY)); - let http = - var(UPSTREAM_HTTP_PROXY).and_then(|url| parse_proxy_url(&url, UPSTREAM_HTTP_PROXY)); + let https = var(UPSTREAM_HTTPS_PROXY) + .map(|url| parse_proxy_url(&url, UPSTREAM_HTTPS_PROXY)) + .transpose()?; + let http = var(UPSTREAM_HTTP_PROXY) + .map(|url| parse_proxy_url(&url, UPSTREAM_HTTP_PROXY)) + .transpose()?; + let auth_file = var(UPSTREAM_PROXY_AUTH_FILE); if https.is_none() && http.is_none() { - return None; + if auth_file.is_some() { + return Err(format!( + "{UPSTREAM_PROXY_AUTH_FILE} is set but no upstream proxy is configured" + )); + } + return Ok(None); } let no_proxy = NoProxy::parse(&var(UPSTREAM_NO_PROXY).unwrap_or_default()); - Some(Self { + let mut config = Self { https, http, no_proxy, - }) + }; + + // Load proxy credentials from the reserved auth file, if configured, + // and apply them to every endpoint. The file is delivered through a + // root-only secret mount so the credentials never appear in the + // environment or container metadata. + if let Some(path) = auth_file { + let credential = std::fs::read_to_string(&path).map_err(|err| { + format!("failed to read upstream proxy auth file '{path}': {err}") + })?; + let header = basic_auth_header(&credential).map_err(|err| { + format!("invalid credential in upstream proxy auth file '{path}': {err}") + })?; + config.apply_proxy_auth(header); + } + + Ok(Some(config)) } /// Attach a pre-built `Proxy-Authorization` header value to every - /// configured endpoint. A `None` value clears any existing credentials. - fn apply_proxy_auth(&mut self, proxy_authorization: Option) { + /// configured endpoint. + fn apply_proxy_auth(&mut self, proxy_authorization: String) { for endpoint in [self.https.as_mut(), self.http.as_mut()] .into_iter() .flatten() { - endpoint - .proxy_authorization - .clone_from(&proxy_authorization); + endpoint.proxy_authorization = Some(proxy_authorization.clone()); } } @@ -271,88 +291,49 @@ impl UpstreamProxyConfig { } } -/// Parse an `http://host[:port]` proxy URL. Unsupported schemes (TLS or SOCKS -/// proxies) are rejected with a warning. +/// Parse an `http://host[:port]` proxy URL with the same validation rules the +/// compute driver applies at sandbox-create time +/// ([`parse_upstream_proxy_url`](openshell_core::driver_utils::parse_upstream_proxy_url)). /// /// Credentials are never taken from the URL: they are delivered out of band /// through [`UPSTREAM_PROXY_AUTH_FILE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE) -/// so they never appear in config or container metadata. Any `user:pass@` -/// userinfo present in the URL is ignored 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 authority = match rest.rsplit_once('@') { - Some((_userinfo, authority)) => { - warn!( - "{var_name} contains inline credentials, which are ignored; \ - supply proxy credentials via the proxy auth file" - ); - authority - } - None => rest, - }; - let Some((host, port)) = split_host_port(authority) else { - warn!("{var_name} has an invalid proxy address '{authority}'; ignoring"); - return None; - }; - Some(ProxyEndpoint { - host, - port, +/// so they never appear in config or container metadata. +/// +/// # Errors +/// +/// Rejects unsupported schemes (TLS or SOCKS proxies), inline `user:pass@` +/// credentials, and malformed addresses. The error names `var_name` so the +/// operator can locate the offending setting. +fn parse_proxy_url(raw: &str, var_name: &str) -> Result { + let addr = openshell_core::driver_utils::parse_upstream_proxy_url(raw) + .map_err(|err| format!("{var_name} is invalid: {err}"))?; + Ok(ProxyEndpoint { + host: addr.host, + port: addr.port, proxy_authorization: None, }) } -/// 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)), - } -} - /// Build a `Proxy-Authorization: Basic ` header value from a raw /// `user:pass` credential. /// -/// Returns `None` for an empty credential or one containing control characters -/// (CR, LF, NUL) that could inject additional HTTP headers. The credential is -/// used verbatim: it is delivered through a trusted operator file, not a URL, -/// so there is no percent-encoding to decode. -fn basic_auth_header(credential: &str) -> Option { +/// The credential is used verbatim: it is delivered through a trusted +/// operator file, not a URL, so there is no percent-encoding to decode. +/// +/// # Errors +/// +/// Rejects an empty credential and one containing control characters (CR, LF, +/// NUL) that could inject additional HTTP headers. Error messages never +/// include the credential content. +fn basic_auth_header(credential: &str) -> Result { let credential = credential.trim(); - if credential.is_empty() || credential.contains(|c: char| c.is_control()) { - return None; + if credential.is_empty() { + return Err("credential is empty".to_string()); } - Some(format!( + if credential.contains(|c: char| c.is_control()) { + return Err("credential contains control characters".to_string()); + } + Ok(format!( "Basic {}", base64::engine::general_purpose::STANDARD.encode(credential) )) @@ -474,10 +455,10 @@ mod tests { use super::*; use openshell_core::sandbox_env::{ UPSTREAM_HTTP_PROXY as HTTP_PROXY, UPSTREAM_HTTPS_PROXY as HTTPS_PROXY, - UPSTREAM_NO_PROXY as NO_PROXY, + UPSTREAM_NO_PROXY as NO_PROXY, UPSTREAM_PROXY_AUTH_FILE as PROXY_AUTH_FILE, }; - fn config_from(pairs: &[(&str, &str)]) -> Option { + fn config_from(pairs: &[(&str, &str)]) -> Result, String> { UpstreamProxyConfig::from_lookup(|name| { pairs .iter() @@ -486,9 +467,14 @@ mod tests { }) } + /// Shorthand for tests exercising a configuration that must load. + fn config_ok(pairs: &[(&str, &str)]) -> UpstreamProxyConfig { + config_from(pairs).unwrap().unwrap() + } + #[test] fn no_env_yields_none() { - assert!(config_from(&[]).is_none()); + assert!(config_from(&[]).unwrap().is_none()); } #[test] @@ -502,18 +488,23 @@ mod tests { ("ALL_PROXY", "http://attacker:9999"), ("https_proxy", "http://attacker:9999"), ]) + .unwrap() .is_none() ); } #[test] fn empty_values_yield_none() { - assert!(config_from(&[(HTTPS_PROXY, " "), (HTTP_PROXY, "")]).is_none()); + assert!( + config_from(&[(HTTPS_PROXY, " "), (HTTP_PROXY, "")]) + .unwrap() + .is_none() + ); } #[test] fn https_proxy_parsed_with_port() { - let cfg = config_from(&[(HTTPS_PROXY, "http://proxy.corp.com:8080")]).unwrap(); + let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy.corp.com:8080")]); let ep = cfg .proxy_for(UpstreamScheme::Https, "api.stripe.com") .unwrap(); @@ -527,67 +518,127 @@ mod tests { #[test] fn scheme_defaults_to_http_and_port_defaults_to_80() { - let cfg = config_from(&[(HTTP_PROXY, "proxy.corp.com")]).unwrap(); + let cfg = config_ok(&[(HTTP_PROXY, "proxy.corp.com")]); let ep = cfg.proxy_for(UpstreamScheme::Http, "example.com").unwrap(); assert_eq!(ep.display_addr(), "proxy.corp.com:80"); } + // -- Fail-closed configuration validation -- + // + // Present-but-invalid reserved values must be fatal, never silently + // treated as unset: a typo must not downgrade the operator's egress + // boundary to direct dialing or unauthenticated proxy access. + #[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()); + fn tls_and_socks_proxies_are_fatal() { + for url in ["https://proxy:443", "socks5://proxy:1080"] { + let err = config_from(&[(HTTPS_PROXY, url)]).unwrap_err(); + assert!(err.contains(HTTPS_PROXY), "{err}"); + assert!(err.contains("scheme"), "{err}"); + } } #[test] - fn url_userinfo_is_ignored_not_used_as_credentials() { + fn url_userinfo_is_fatal_not_used_as_credentials() { // Inline credentials in the URL must never become the proxy auth; - // credentials come only from the auth file. - let cfg = config_from(&[(HTTPS_PROXY, "http://user:secret@proxy:8080")]).unwrap(); - let ep = cfg.proxy_for(UpstreamScheme::Https, "example.com").unwrap(); - assert_eq!(ep.display_addr(), "proxy:8080"); + // credentials come only from the auth file. Matching the compute + // driver, a URL that embeds them is rejected outright. + let err = config_from(&[(HTTPS_PROXY, "http://user:secret@proxy:8080")]).unwrap_err(); + assert!(err.contains(HTTPS_PROXY), "{err}"); assert!( - ep.proxy_authorization.is_none(), - "URL userinfo must not be used as proxy credentials" + !err.contains("secret"), + "error must not leak the credential: {err}" ); } #[test] - fn basic_auth_header_encodes_and_rejects_control_chars() { - assert_eq!( - basic_auth_header("user:p@ss").as_deref(), - Some( - format!( - "Basic {}", - base64::engine::general_purpose::STANDARD.encode("user:p@ss") - ) - .as_str() - ) - ); - assert!(basic_auth_header(" ").is_none()); - assert!(basic_auth_header("user:pa\r\nss").is_none()); - assert!(basic_auth_header("user:pa\nInjected: header").is_none()); + fn malformed_proxy_address_is_fatal() { + let err = config_from(&[(HTTP_PROXY, "http://proxy:notaport")]).unwrap_err(); + assert!(err.contains(HTTP_PROXY), "{err}"); + // One invalid endpoint is fatal even when the other one is valid. + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (HTTP_PROXY, "socks5://proxy:1080"), + ]) + .unwrap_err(); + assert!(err.contains(HTTP_PROXY), "{err}"); + } + + #[test] + fn unreadable_auth_file_is_fatal() { + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (PROXY_AUTH_FILE, "/nonexistent/upstream-proxy-auth"), + ]) + .unwrap_err(); + assert!(err.contains("auth file"), "{err}"); + } + + #[test] + fn malformed_auth_file_credential_is_fatal() { + for credential in [" ", "user:pa\r\nss"] { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), credential).unwrap(); + let path = file.path().to_str().unwrap().to_string(); + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (PROXY_AUTH_FILE, &path), + ]) + .unwrap_err(); + assert!(err.contains("auth file"), "{err}"); + assert!( + !err.contains("pa\r\nss"), + "error must not leak the credential: {err}" + ); + } + } + + #[test] + fn auth_file_without_proxy_is_fatal() { + let err = + config_from(&[(PROXY_AUTH_FILE, "/etc/openshell/auth/upstream-proxy")]).unwrap_err(); + assert!(err.contains(PROXY_AUTH_FILE), "{err}"); } #[test] - fn apply_proxy_auth_sets_header_on_all_endpoints() { - let mut cfg = config_from(&[ + fn auth_file_credentials_are_applied_to_all_endpoints() { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "user:secret\n").unwrap(); + let path = file.path().to_str().unwrap().to_string(); + let cfg = config_ok(&[ (HTTPS_PROXY, "http://proxy:8080"), (HTTP_PROXY, "http://proxy:3128"), - ]) - .unwrap(); - cfg.apply_proxy_auth(basic_auth_header("user:secret")); + (PROXY_AUTH_FILE, &path), + ]); + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("user:secret") + ); for scheme in [UpstreamScheme::Https, UpstreamScheme::Http] { let ep = cfg.proxy_for(scheme, "example.com").unwrap(); - let auth = ep.proxy_authorization.as_deref().unwrap(); - let expected = base64::engine::general_purpose::STANDARD.encode("user:secret"); - assert_eq!(auth, format!("Basic {expected}")); + assert_eq!(ep.proxy_authorization.as_deref(), Some(expected.as_str())); } } + #[test] + fn basic_auth_header_encodes_and_rejects_control_chars() { + assert_eq!( + basic_auth_header("user:p@ss").as_deref(), + Ok(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("user:p@ss") + ) + .as_str()) + ); + assert!(basic_auth_header(" ").is_err()); + assert!(basic_auth_header("user:pa\r\nss").is_err()); + assert!(basic_auth_header("user:pa\nInjected: header").is_err()); + } + #[test] fn debug_output_hides_credentials() { - let mut cfg = config_from(&[(HTTPS_PROXY, "http://proxy:8080")]).unwrap(); - cfg.apply_proxy_auth(basic_auth_header("user:secret")); + let mut cfg = config_ok(&[(HTTPS_PROXY, "http://proxy:8080")]); + cfg.apply_proxy_auth(basic_auth_header("user:secret").unwrap()); let debug = format!("{cfg:?}"); assert!(!debug.contains("secret")); assert!(!cfg.summary().contains("secret")); @@ -595,7 +646,7 @@ mod tests { #[test] fn ipv6_proxy_address_parses() { - let cfg = config_from(&[(HTTPS_PROXY, "http://[fd00::1]:8080")]).unwrap(); + let cfg = config_ok(&[(HTTPS_PROXY, "http://[fd00::1]:8080")]); let ep = cfg.proxy_for(UpstreamScheme::Https, "example.com").unwrap(); assert_eq!(ep.display_addr(), "fd00::1:8080"); } @@ -603,7 +654,7 @@ mod tests { // -- NO_PROXY matching -- fn no_proxy_cfg(no_proxy: &str) -> UpstreamProxyConfig { - config_from(&[(HTTPS_PROXY, "http://proxy:8080"), (NO_PROXY, no_proxy)]).unwrap() + config_ok(&[(HTTPS_PROXY, "http://proxy:8080"), (NO_PROXY, no_proxy)]) } fn bypasses(cfg: &UpstreamProxyConfig, host: &str) -> bool { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 6d123e8bfb..3ba1802aa3 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -397,6 +397,11 @@ health_check_interval_secs = 10 # sandbox and template environment cannot override it, and the conventional # HTTPS_PROXY/HTTP_PROXY/NO_PROXY variables a sandbox sets do not affect it. # +# Configuration is fail-closed: an invalid proxy URL is rejected at gateway +# startup, and a set-but-invalid value reaching a sandbox (for example an +# unreadable or malformed auth file) is fatal to that sandbox's supervisor +# instead of silently falling back to direct or unauthenticated egress. +# # Credentials must NOT be embedded in the URL (an inline user:pass@ is # rejected at startup, since it would be stored here and exposed in container # metadata). Instead point proxy_auth_file at a file containing "user:pass"; From 1a9bcb018a5861ece36c87a0d1dd1383a7228955 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 16 Jul 2026 15:40:34 +0200 Subject: [PATCH 05/28] fix(sandbox,podman): finish the fail-closed upstream proxy credential contract The upstream proxy URL already had a single shared validator, but the credential did not: the Podman driver rejected only CR/LF/NUL while the supervisor rejected every control character, so a credential accepted at sandbox-create time (e.g. one containing a tab) could still be rejected in-container. Present-but-whitespace reserved OPENSHELL_UPSTREAM_* values were also silently treated as unset, quietly downgrading the operator's egress boundary to direct dialing. Add parse_upstream_proxy_credential to openshell-core as the single source of truth for the documented user:pass credential form (non-empty user, no control characters, trimmed) and use it in both the Podman driver's secret staging and the supervisor's Proxy-Authorization header construction. Error variants carry no payload so credential content can never leak into messages. Make a present-but-empty reserved variable fatal to supervisor proxy startup instead of meaning "unset"; only fully unset variables disable the proxy. The driver correspondingly rejects an empty no_proxy at config time so it can never inject a value the supervisor refuses. Addresses the remaining fail-closed credential/config review item on Signed-off-by: Philippe Martin #2245. --- architecture/sandbox.md | 19 ++-- crates/openshell-core/src/driver_utils.rs | 100 +++++++++++++++++ crates/openshell-driver-podman/src/config.rs | 23 ++++ crates/openshell-driver-podman/src/driver.rs | 24 ++-- .../src/upstream_proxy.rs | 104 +++++++++++------- docs/reference/gateway-config.mdx | 3 + 6 files changed, 211 insertions(+), 62 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 3aa92256ce..42cf8399b2 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -119,20 +119,23 @@ variables are unaffected — they are always rewritten to point at the local policy proxy. The configuration is fail-closed: a reserved variable that is present but -invalid — an unsupported or malformed proxy URL, an unreadable auth file, or a -malformed credential — is fatal to supervisor startup instead of being treated -as unset, so a misconfiguration can never silently degrade to direct dialing -or unauthenticated proxy access. The driver validates the same rules at -sandbox-create time through a validator shared with the supervisor -(`openshell_core::driver_utils::parse_upstream_proxy_url`). +invalid — a present-but-empty value, an unsupported or malformed proxy URL, an +unreadable auth file, or a malformed credential — is fatal to supervisor +startup instead of being treated as unset, so a misconfiguration can never +silently degrade to direct dialing or unauthenticated proxy access. Only a +fully unset variable means "no proxy". The driver validates the same rules at +sandbox-create time through validators shared with the supervisor +(`openshell_core::driver_utils::parse_upstream_proxy_url` and +`parse_upstream_proxy_credential`). Proxy credentials are never embedded in the URL: an inline `user:pass@` is rejected because it would be stored in `gateway.toml` and exposed in container metadata. Operators supply credentials via `proxy_auth_file`; the driver stages them as a root-only secret mounted at a fixed path and exports only that path in `OPENSHELL_UPSTREAM_PROXY_AUTH_FILE`. The supervisor reads the -file and builds the `Proxy-Authorization: Basic` header; an empty credential -or one containing control characters is fatal. The reserved proxy variables — +file and builds the `Proxy-Authorization: Basic` header; a credential that is +empty, contains control characters, or is not in `user:pass` form is fatal on +both sides. The reserved proxy variables — including the auth-file path — are stripped from workload child processes. ## Credentials diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 4a33cae610..dd322fb80c 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -170,6 +170,60 @@ pub fn parse_upstream_proxy_url(raw: &str) -> Result Result<&str, UpstreamProxyCredentialError> { + let credential = raw.trim(); + if credential.is_empty() { + return Err(UpstreamProxyCredentialError::Empty); + } + if credential.contains(|c: char| c.is_control()) { + return Err(UpstreamProxyCredentialError::ControlCharacters); + } + match credential.split_once(':') { + None => Err(UpstreamProxyCredentialError::MissingSeparator), + Some(("", _)) => Err(UpstreamProxyCredentialError::EmptyUser), + Some(_) => Ok(credential), + } +} + /// Return the XDG state path for a driver's sandbox JWT token file. /// /// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. @@ -318,4 +372,50 @@ mod tests { )); assert!(parse_upstream_proxy_url("http://").is_err()); } + + #[test] + fn upstream_proxy_credential_accepts_user_pass_and_trims() { + assert_eq!( + parse_upstream_proxy_credential("user:pass\n"), + Ok("user:pass") + ); + // The password may be empty and may contain further colons. + assert_eq!(parse_upstream_proxy_credential("user:"), Ok("user:")); + assert_eq!( + parse_upstream_proxy_credential("user:p@:ss"), + Ok("user:p@:ss") + ); + } + + #[test] + fn upstream_proxy_credential_rejects_empty() { + for raw in ["", " ", "\n"] { + assert_eq!( + parse_upstream_proxy_credential(raw), + Err(UpstreamProxyCredentialError::Empty) + ); + } + } + + #[test] + fn upstream_proxy_credential_rejects_control_characters() { + for raw in ["user:pa\r\nss", "user:pa\0ss", "user:pa\tss"] { + assert_eq!( + parse_upstream_proxy_credential(raw), + Err(UpstreamProxyCredentialError::ControlCharacters) + ); + } + } + + #[test] + fn upstream_proxy_credential_rejects_malformed_user_pass_form() { + assert_eq!( + parse_upstream_proxy_credential("userpass"), + Err(UpstreamProxyCredentialError::MissingSeparator) + ); + assert_eq!( + parse_upstream_proxy_credential(":pass"), + Err(UpstreamProxyCredentialError::EmptyUser) + ); + } } diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 9df4f06b76..e205815fdf 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -250,6 +250,18 @@ impl PodmanComputeConfig { })?; } + // The supervisor treats a present-but-empty reserved variable as a + // fatal misconfiguration, so never accept (and later inject) one. + if self + .no_proxy + .as_deref() + .is_some_and(|list| list.trim().is_empty()) + { + return Err(crate::client::PodmanApiError::InvalidInput( + "no_proxy must not be empty when set; omit it instead".to_string(), + )); + } + if let Some(path) = self.proxy_auth_file.as_deref() { if path.trim().is_empty() { return Err(crate::client::PodmanApiError::InvalidInput( @@ -526,6 +538,17 @@ mod tests { assert!(err.to_string().contains("http_proxy"), "{err}"); } + #[test] + fn validate_proxy_config_rejects_empty_no_proxy() { + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + no_proxy: Some(" ".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("no_proxy"), "{err}"); + } + #[test] fn validate_proxy_config_rejects_inline_credentials() { for url in [ diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 49db01c15d..c30cfe901b 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -118,8 +118,11 @@ async fn cleanup_sandbox_token_secret(client: &PodmanClient, secret_name: &str) /// Podman secret, so the credentials reach the supervisor through a root-only /// mount rather than the container environment. /// -/// Fails closed: when `proxy_auth_file` is configured but cannot be read or is -/// empty/malformed, the sandbox is not created. +/// Fails closed: when `proxy_auth_file` is configured but cannot be read or +/// does not hold a valid `user:pass` credential, the sandbox is not created. +/// Credential validation is shared with the in-container supervisor through +/// [`openshell_core::driver_utils::parse_upstream_proxy_credential`], so a +/// credential staged here can never be rejected at supervisor startup. async fn create_sandbox_proxy_auth_secret( client: &PodmanClient, config: &PodmanComputeConfig, @@ -132,19 +135,10 @@ async fn create_sandbox_proxy_auth_secret( let raw = tokio::fs::read_to_string(path).await.map_err(|e| { ComputeDriverError::Message(format!("failed to read proxy_auth_file '{path}': {e}")) })?; - let credential = raw.trim(); - if credential.is_empty() { - return Err(ComputeDriverError::InvalidArgument(format!( - "proxy_auth_file '{path}' is empty" - ))); - } - // A credential containing CR/LF/NUL would allow HTTP header injection once - // the supervisor places it in the Proxy-Authorization header. - if credential.contains(['\r', '\n', '\0']) { - return Err(ComputeDriverError::InvalidArgument(format!( - "proxy_auth_file '{path}' must not contain control characters" - ))); - } + let credential = + openshell_core::driver_utils::parse_upstream_proxy_credential(&raw).map_err(|err| { + ComputeDriverError::InvalidArgument(format!("proxy_auth_file '{path}': {err}")) + })?; let secret_name = container::proxy_auth_secret_name(&sandbox.id); client diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index aeb92e8f1a..2fdfb51150 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -21,13 +21,14 @@ //! //! Scope and invariants: //! - Only `http://` proxy URLs are supported. Configuration is fail-closed: -//! any present-but-invalid reserved value — an unsupported (`https://`, -//! SOCKS) or malformed proxy URL, an unreadable auth file, or a malformed -//! credential — is a fatal startup error rather than being silently -//! ignored, so a typo can never quietly downgrade the operator's egress -//! boundary to direct dialing or unauthenticated proxy access. Validation -//! semantics are shared with the compute driver via -//! [`openshell_core::driver_utils::parse_upstream_proxy_url`]. +//! any present-but-invalid reserved value — a present-but-empty variable, +//! an unsupported (`https://`, SOCKS) or malformed proxy URL, an unreadable +//! auth file, or a malformed credential — is a fatal startup error rather +//! than being silently ignored, so a typo can never quietly downgrade the +//! operator's egress boundary to direct dialing or unauthenticated proxy +//! access. Validation semantics are shared with the compute driver via +//! [`openshell_core::driver_utils::parse_upstream_proxy_url`] and +//! [`openshell_core::driver_utils::parse_upstream_proxy_credential`]. //! - Policy evaluation, DNS resolution, and SSRF checks run exactly as in the //! direct-dial path; the corporate proxy only replaces the final TCP dial. //! - The reserved `NO_PROXY` list decides which destinations bypass the @@ -184,8 +185,7 @@ impl UpstreamProxyConfig { /// [`UPSTREAM_HTTP_PROXY`](openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY), /// [`UPSTREAM_NO_PROXY`](openshell_core::sandbox_env::UPSTREAM_NO_PROXY), /// [`UPSTREAM_PROXY_AUTH_FILE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE)). - /// Returns `Ok(None)` when no proxy is configured (unset or empty - /// variables). + /// Returns `Ok(None)` when no proxy is configured (unset variables). /// /// The conventional `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` /// variables are intentionally ignored here: they are set by the sandbox @@ -199,11 +199,12 @@ impl UpstreamProxyConfig { /// /// These reserved variables are an operator-owned security boundary, so /// any present-but-invalid value is fatal instead of being treated as - /// unset: an invalid or unsupported proxy URL, an auth file that is set - /// but unreadable or holds a malformed credential, or an auth file with - /// no proxy configured. Failing closed here prevents a misconfiguration + /// unset: a present-but-empty (or whitespace-only) reserved variable, an + /// invalid or unsupported proxy URL, an auth file that is set but + /// unreadable or holds a malformed credential, or an auth file with no + /// proxy configured. Failing closed here prevents a misconfiguration /// from silently degrading to direct dialing or unauthenticated proxy - /// access. + /// access. Only fully unset variables mean "no proxy". pub fn from_env() -> Result, String> { Self::from_lookup(|name| std::env::var(name).ok()) } @@ -212,14 +213,27 @@ impl UpstreamProxyConfig { use openshell_core::sandbox_env::{ UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, UPSTREAM_PROXY_AUTH_FILE, }; - let var = |name: &str| lookup(name).filter(|v| !v.trim().is_empty()); - let https = var(UPSTREAM_HTTPS_PROXY) + // Only a fully unset reserved variable means "not configured". A + // present-but-empty value is a misconfiguration (the compute driver + // never writes one), so it is fatal rather than silently downgrading + // the boundary to direct dialing or unauthenticated proxy access. + let var = |name: &str| -> Result, String> { + match lookup(name) { + None => Ok(None), + Some(value) if value.trim().is_empty() => Err(format!( + "{name} is set but empty; unset it to disable the upstream proxy" + )), + Some(value) => Ok(Some(value)), + } + }; + let https = var(UPSTREAM_HTTPS_PROXY)? .map(|url| parse_proxy_url(&url, UPSTREAM_HTTPS_PROXY)) .transpose()?; - let http = var(UPSTREAM_HTTP_PROXY) + let http = var(UPSTREAM_HTTP_PROXY)? .map(|url| parse_proxy_url(&url, UPSTREAM_HTTP_PROXY)) .transpose()?; - let auth_file = var(UPSTREAM_PROXY_AUTH_FILE); + let auth_file = var(UPSTREAM_PROXY_AUTH_FILE)?; + let no_proxy_list = var(UPSTREAM_NO_PROXY)?; if https.is_none() && http.is_none() { if auth_file.is_some() { return Err(format!( @@ -228,7 +242,7 @@ impl UpstreamProxyConfig { } return Ok(None); } - let no_proxy = NoProxy::parse(&var(UPSTREAM_NO_PROXY).unwrap_or_default()); + let no_proxy = NoProxy::parse(&no_proxy_list.unwrap_or_default()); let mut config = Self { https, http, @@ -317,22 +331,21 @@ fn parse_proxy_url(raw: &str, var_name: &str) -> Result { /// Build a `Proxy-Authorization: Basic ` header value from a raw /// `user:pass` credential. /// -/// The credential is used verbatim: it is delivered through a trusted -/// operator file, not a URL, so there is no percent-encoding to decode. +/// The credential is used verbatim after trimming: it is delivered through a +/// trusted operator file, not a URL, so there is no percent-encoding to +/// decode. Validation is shared with the compute driver through +/// [`parse_upstream_proxy_credential`](openshell_core::driver_utils::parse_upstream_proxy_credential), +/// so a credential the driver staged at sandbox-create time is never rejected +/// here. /// /// # Errors /// -/// Rejects an empty credential and one containing control characters (CR, LF, -/// NUL) that could inject additional HTTP headers. Error messages never -/// include the credential content. +/// Rejects an empty credential, one containing control characters that could +/// inject additional HTTP headers, and one not in `user:pass` form. Error +/// messages never include the credential content. fn basic_auth_header(credential: &str) -> Result { - let credential = credential.trim(); - if credential.is_empty() { - return Err("credential is empty".to_string()); - } - if credential.contains(|c: char| c.is_control()) { - return Err("credential contains control characters".to_string()); - } + let credential = openshell_core::driver_utils::parse_upstream_proxy_credential(credential) + .map_err(|err| err.to_string())?; Ok(format!( "Basic {}", base64::engine::general_purpose::STANDARD.encode(credential) @@ -494,12 +507,21 @@ mod tests { } #[test] - fn empty_values_yield_none() { - assert!( - config_from(&[(HTTPS_PROXY, " "), (HTTP_PROXY, "")]) - .unwrap() - .is_none() - ); + fn present_but_empty_values_are_fatal() { + // A reserved variable the operator did not set is absent, never + // empty: the driver only writes configured values. A present-but + // -blank value is therefore a misconfiguration and must not silently + // mean "no proxy". + for (name, value) in [ + (HTTPS_PROXY, ""), + (HTTP_PROXY, " "), + (NO_PROXY, " "), + (PROXY_AUTH_FILE, ""), + ] { + let err = config_from(&[(name, value)]).unwrap_err(); + assert!(err.contains(name), "{err}"); + assert!(err.contains("empty"), "{err}"); + } } #[test] @@ -576,7 +598,9 @@ mod tests { #[test] fn malformed_auth_file_credential_is_fatal() { - for credential in [" ", "user:pa\r\nss"] { + // Empty, header-injecting, and non-`user:pass` credentials are all + // rejected by the parser shared with the compute driver. + for credential in [" ", "user:pa\r\nss", "userpass", ":pass"] { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write(file.path(), credential).unwrap(); let path = file.path().to_str().unwrap().to_string(); @@ -621,7 +645,7 @@ mod tests { } #[test] - fn basic_auth_header_encodes_and_rejects_control_chars() { + fn basic_auth_header_encodes_and_rejects_malformed_credentials() { assert_eq!( basic_auth_header("user:p@ss").as_deref(), Ok(format!( @@ -633,6 +657,7 @@ mod tests { assert!(basic_auth_header(" ").is_err()); assert!(basic_auth_header("user:pa\r\nss").is_err()); assert!(basic_auth_header("user:pa\nInjected: header").is_err()); + assert!(basic_auth_header("no-separator").is_err()); } #[test] @@ -713,7 +738,8 @@ mod tests { #[test] fn loopback_and_localhost_always_bypass() { - let cfg = no_proxy_cfg(""); + // No NO_PROXY at all: loopback still bypasses unconditionally. + let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy:8080")]); assert!(bypasses(&cfg, "localhost")); assert!(bypasses(&cfg, "127.0.0.1")); assert!(bypasses(&cfg, "::1")); diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 3ba1802aa3..eed8e70f45 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -406,6 +406,9 @@ health_check_interval_secs = 10 # rejected at startup, since it would be stored here and exposed in container # metadata). Instead point proxy_auth_file at a file containing "user:pass"; # the gateway delivers it to the supervisor through a root-only secret mount. +# The credential must use the user:pass form (non-empty user, no control +# characters); the same validation runs at sandbox-create time and in the +# supervisor, so a credential accepted here is never rejected in-container. # Keep gateway.toml and the auth file owner-readable only (mode 0600). # https_proxy = "http://proxy.corp.com:8080" # http_proxy = "http://proxy.corp.com:8080" From 2990c6ce892d811fb050129689ba1447db681ef8 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 16 Jul 2026 16:19:10 +0200 Subject: [PATCH 06/28] fix(sandbox,podman): close remaining fail-open upstream proxy config paths Two configuration paths could still silently run without the proxy boundary the operator believed was in effect. A no_proxy bypass list configured without any https_proxy/http_proxy was accepted by both the driver and the supervisor and simply meant "dial everything directly". Reject it on both sides, exactly like the existing proxy_auth_file-without-proxy rule: an operator who wrote a bypass list assumed proxying was active, so accepting it hides a fail-open state. The gateway.sh dev script guarded proxy settings with [[ -n "${VAR:-}" ]], which conflates unset with explicitly-empty and dropped the latter before the gateway's validation could see it. Use ${VAR+x} instead so a set-but-empty variable is written into gateway.toml and rejected at startup by validate_proxy_config rather than silently discarded. Addresses the remaining fail-open configuration review item on #2245. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 9 ++--- crates/openshell-driver-podman/src/config.rs | 32 ++++++++++++----- .../src/upstream_proxy.rs | 34 +++++++++++++------ docs/reference/gateway-config.mdx | 8 +++-- tasks/scripts/gateway.sh | 12 ++++--- 5 files changed, 66 insertions(+), 29 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 42cf8399b2..1fa29b3013 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -120,10 +120,11 @@ policy proxy. The configuration is fail-closed: a reserved variable that is present but invalid — a present-but-empty value, an unsupported or malformed proxy URL, an -unreadable auth file, or a malformed credential — is fatal to supervisor -startup instead of being treated as unset, so a misconfiguration can never -silently degrade to direct dialing or unauthenticated proxy access. Only a -fully unset variable means "no proxy". The driver validates the same rules at +unreadable auth file, a malformed credential, or an auth file or `NO_PROXY` +list set while no proxy URL is configured — is fatal to supervisor startup +instead of being treated as unset, so a misconfiguration can never silently +degrade to direct dialing or unauthenticated proxy access. Only a fully unset +variable means "no proxy". The driver validates the same rules at sandbox-create time through validators shared with the supervisor (`openshell_core::driver_utils::parse_upstream_proxy_url` and `parse_upstream_proxy_credential`). diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index e205815fdf..23b905053b 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -252,14 +252,20 @@ impl PodmanComputeConfig { // The supervisor treats a present-but-empty reserved variable as a // fatal misconfiguration, so never accept (and later inject) one. - if self - .no_proxy - .as_deref() - .is_some_and(|list| list.trim().is_empty()) - { - return Err(crate::client::PodmanApiError::InvalidInput( - "no_proxy must not be empty when set; omit it instead".to_string(), - )); + if let Some(list) = self.no_proxy.as_deref() { + if list.trim().is_empty() { + return Err(crate::client::PodmanApiError::InvalidInput( + "no_proxy must not be empty when set; omit it instead".to_string(), + )); + } + // A bypass list only makes sense relative to a proxy boundary. An + // operator who set one believed proxying was in effect, so accepting + // it while all egress dials directly would hide a fail-open state. + if self.https_proxy.is_none() && self.http_proxy.is_none() { + return Err(crate::client::PodmanApiError::InvalidInput( + "no_proxy is set but no https_proxy/http_proxy is configured".to_string(), + )); + } } if let Some(path) = self.proxy_auth_file.as_deref() { @@ -549,6 +555,16 @@ mod tests { assert!(err.to_string().contains("no_proxy"), "{err}"); } + #[test] + fn validate_proxy_config_rejects_no_proxy_without_proxy() { + let cfg = PodmanComputeConfig { + no_proxy: Some("*.svc.cluster.local".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("no_proxy"), "{err}"); + } + #[test] fn validate_proxy_config_rejects_inline_credentials() { for url in [ diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 2fdfb51150..2b3bd4f01d 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -201,8 +201,9 @@ impl UpstreamProxyConfig { /// any present-but-invalid value is fatal instead of being treated as /// unset: a present-but-empty (or whitespace-only) reserved variable, an /// invalid or unsupported proxy URL, an auth file that is set but - /// unreadable or holds a malformed credential, or an auth file with no - /// proxy configured. Failing closed here prevents a misconfiguration + /// unreadable or holds a malformed credential, or an auth file or + /// `NO_PROXY` list with no proxy configured. Failing closed here + /// prevents a misconfiguration /// from silently degrading to direct dialing or unauthenticated proxy /// access. Only fully unset variables mean "no proxy". pub fn from_env() -> Result, String> { @@ -235,10 +236,16 @@ impl UpstreamProxyConfig { let auth_file = var(UPSTREAM_PROXY_AUTH_FILE)?; let no_proxy_list = var(UPSTREAM_NO_PROXY)?; if https.is_none() && http.is_none() { - if auth_file.is_some() { - return Err(format!( - "{UPSTREAM_PROXY_AUTH_FILE} is set but no upstream proxy is configured" - )); + // Auxiliary proxy settings without a proxy mean the operator + // believed a proxy boundary was in effect; refuse rather than + // silently running with direct egress. + for (name, value) in [ + (UPSTREAM_PROXY_AUTH_FILE, &auth_file), + (UPSTREAM_NO_PROXY, &no_proxy_list), + ] { + if value.is_some() { + return Err(format!("{name} is set but no upstream proxy is configured")); + } } return Ok(None); } @@ -618,10 +625,17 @@ mod tests { } #[test] - fn auth_file_without_proxy_is_fatal() { - let err = - config_from(&[(PROXY_AUTH_FILE, "/etc/openshell/auth/upstream-proxy")]).unwrap_err(); - assert!(err.contains(PROXY_AUTH_FILE), "{err}"); + fn auxiliary_settings_without_proxy_are_fatal() { + // An auth file or NO_PROXY list only makes sense relative to a proxy + // boundary the operator believed was in effect. + for (name, value) in [ + (PROXY_AUTH_FILE, "/etc/openshell/auth/upstream-proxy"), + (NO_PROXY, "*.svc.cluster.local"), + ] { + let err = config_from(&[(name, value)]).unwrap_err(); + assert!(err.contains(name), "{err}"); + assert!(err.contains("no upstream proxy"), "{err}"); + } } #[test] diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index eed8e70f45..30a8a0dc6a 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -398,9 +398,11 @@ health_check_interval_secs = 10 # HTTPS_PROXY/HTTP_PROXY/NO_PROXY variables a sandbox sets do not affect it. # # Configuration is fail-closed: an invalid proxy URL is rejected at gateway -# startup, and a set-but-invalid value reaching a sandbox (for example an -# unreadable or malformed auth file) is fatal to that sandbox's supervisor -# instead of silently falling back to direct or unauthenticated egress. +# startup, setting no_proxy or proxy_auth_file without a proxy URL is +# rejected as well, and a set-but-invalid value reaching a sandbox (for +# example an unreadable or malformed auth file) is fatal to that sandbox's +# supervisor instead of silently falling back to direct or unauthenticated +# egress. # # Credentials must NOT be embedded in the URL (an inline user:pass@ is # rejected at startup, since it would be stored here and exposed in container diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 92e67bddd9..2476776a42 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -355,16 +355,20 @@ EOF if [[ -n "${GRPC_ENDPOINT}" ]]; then printf 'grpc_endpoint = "%s"\n' "${GRPC_ENDPOINT}" >>"${CONFIG_PATH}" fi - if [[ -n "${OPENSHELL_SANDBOX_HTTPS_PROXY:-}" ]]; then + # ${VAR+x} distinguishes unset from set-but-empty: an unset variable + # writes nothing, but an explicitly empty one is written through so the + # gateway's fail-closed proxy validation rejects it at startup instead of + # this script silently dropping it. + if [[ -n "${OPENSHELL_SANDBOX_HTTPS_PROXY+x}" ]]; then printf 'https_proxy = "%s"\n' "${OPENSHELL_SANDBOX_HTTPS_PROXY}" >>"${CONFIG_PATH}" fi - if [[ -n "${OPENSHELL_SANDBOX_HTTP_PROXY:-}" ]]; then + if [[ -n "${OPENSHELL_SANDBOX_HTTP_PROXY+x}" ]]; then printf 'http_proxy = "%s"\n' "${OPENSHELL_SANDBOX_HTTP_PROXY}" >>"${CONFIG_PATH}" fi - if [[ -n "${OPENSHELL_SANDBOX_NO_PROXY:-}" ]]; then + if [[ -n "${OPENSHELL_SANDBOX_NO_PROXY+x}" ]]; then printf 'no_proxy = "%s"\n' "${OPENSHELL_SANDBOX_NO_PROXY}" >>"${CONFIG_PATH}" fi - if [[ -n "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE:-}" ]]; then + if [[ -n "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE+x}" ]]; then printf 'proxy_auth_file = "%s"\n' "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE}" >>"${CONFIG_PATH}" fi ;; From 1394b24832239f013d1b1a0112b8cc2bcf4a9168 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 16 Jul 2026 18:50:23 +0200 Subject: [PATCH 07/28] fix(sandbox,podman): reject upstream proxy URLs with path, query, or fragment parse_upstream_proxy_url accepted URLs like http://proxy.corp.com:8080/some/path and silently discarded everything after host:port, a lenience inherited from the original supervisor parser. A forward proxy is addressed by host:port only, so extra components indicate a misconfiguration (for example a pasted endpoint URL) and silently truncating them violates the present-but-invalid-is-fatal contract enforced everywhere else in this configuration surface. Reject a path, query, or fragment in the shared validator with a new UnexpectedComponent error. A bare trailing slash remains accepted because the url crate normalizes an absent http path to "/", making the two indistinguishable. Both the Podman driver (gateway startup) and the supervisor (sandbox startup) inherit the rule through the shared parser, keeping their semantics identical by construction. Addresses the proxy URL component review item on #2245. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 3 +- crates/openshell-core/src/driver_utils.rs | 42 ++++++++++++++++++- crates/openshell-driver-podman/src/config.rs | 19 +++++++++ .../src/upstream_proxy.rs | 10 +++++ docs/reference/gateway-config.mdx | 6 ++- 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 1fa29b3013..3387bb7b65 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -112,7 +112,8 @@ compute drivers write in their required-variable tier; sandbox and template environment cannot override them. The conventional `HTTPS_PROXY`/`HTTP_PROXY`/ `NO_PROXY` variables a sandbox controls are ignored on this path. Reserved `NO_PROXY` destinations, loopback, and host-gateway aliases always dial -directly. Only `http://` proxy URLs are supported. Local DNS resolution and +directly. Only `http://` proxy URLs in `scheme://host:port` form are +supported; a path, query, or fragment is rejected. 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 diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index dd322fb80c..31f1ccb848 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -122,13 +122,21 @@ pub enum UpstreamProxyUrlError { /// The URL has no host component. #[error("proxy URL is missing a proxy host")] MissingHost, + /// The URL carries a path, query, or fragment. A forward proxy is + /// addressed by `host:port` only, so extra components indicate a + /// misconfiguration (e.g. a pasted endpoint URL) and are rejected instead + /// of being silently discarded. + #[error("proxy URL must not contain a {0}; use scheme://host:port only")] + UnexpectedComponent(&'static str), } /// Parse and validate a corporate upstream-proxy URL. /// /// A bare `host:port` (no `://`) is normalized to `http://host:port`. Only /// `http://` proxies are accepted, inline userinfo is rejected, and the port -/// defaults to 80. +/// defaults to 80. The URL must address the proxy only: a path (other than +/// a bare trailing `/`), query, or fragment is rejected rather than silently +/// discarded. /// /// # Errors /// @@ -164,6 +172,17 @@ pub fn parse_upstream_proxy_url(raw: &str) -> Result Date: Thu, 16 Jul 2026 22:40:41 +0200 Subject: [PATCH 08/28] fix(sandbox,podman): remove plain-HTTP upstream proxy support The http_proxy path tunneled plain-HTTP requests through the corporate proxy with CONNECT to port 80 and then sent origin-form requests down the tunnel. Conventional enterprise forward proxies expect plain HTTP as absolute-form requests sent directly over the proxy connection, and commonly refuse CONNECT to port 80, so the setting looked supported but failed against typical deployments. Tunneling also blinds the proxy to the one protocol it could inspect. Narrow the feature to TLS (CONNECT) egress only, which is the conventional and already-correct case: plain-HTTP requests now always dial the destination directly, and only client CONNECT tunnels chain through the corporate proxy. Remove the http_proxy config field, the --sandbox-http-proxy / OPENSHELL_SANDBOX_HTTP_PROXY driver surface, the reserved OPENSHELL_UPSTREAM_HTTP_PROXY variable, and the UpstreamScheme plumbing. The feature never shipped, so this is a clean removal; a stray http_proxy key in gateway.toml still fails loudly through the config's deny_unknown_fields. Removing the plain-HTTP proxy branch also removes its host-gateway special case; the architecture doc now documents the real host-gateway behavior (add driver-injected host aliases to the reserved NO_PROXY list) instead of an invariant the HTTPS path never implemented. Plain-HTTP forwarding through a corporate proxy can return later as absolute-form forwarding behind its own design review. Addresses the plain-HTTP forwarding review item on #2245. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 34 +++-- crates/openshell-core/src/sandbox_env.rs | 6 - crates/openshell-driver-podman/README.md | 7 +- crates/openshell-driver-podman/src/config.rs | 39 ++---- .../openshell-driver-podman/src/container.rs | 20 +-- crates/openshell-driver-podman/src/main.rs | 8 +- .../openshell-supervisor-network/src/proxy.rs | 43 ++---- .../src/upstream_proxy.rs | 131 ++++++------------ .../src/process.rs | 2 - docs/reference/gateway-config.mdx | 10 +- tasks/scripts/gateway.sh | 3 - 11 files changed, 98 insertions(+), 205 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 3387bb7b65..4a50dc8c27 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -103,21 +103,25 @@ 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 chains the upstream dial through a -corporate forward proxy with HTTP CONNECT instead of connecting directly, once -policy and SSRF checks pass. The proxy configuration is an operator-owned -boundary read from reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` / -`OPENSHELL_UPSTREAM_HTTP_PROXY` / `OPENSHELL_UPSTREAM_NO_PROXY` variables that -compute drivers write in their required-variable tier; sandbox and template -environment cannot override them. The conventional `HTTPS_PROXY`/`HTTP_PROXY`/ -`NO_PROXY` variables a sandbox controls are ignored on this path. Reserved -`NO_PROXY` destinations, loopback, and host-gateway aliases always dial -directly. Only `http://` proxy URLs in `scheme://host:port` form are -supported; a path, query, or fragment is rejected. 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. +In proxy-required networks, the supervisor chains upstream TLS tunnels through +a corporate forward proxy with HTTP CONNECT instead of connecting directly, +once policy and SSRF checks pass. Only TLS (CONNECT) egress is chained: +plain-HTTP requests always dial the destination directly, because forwarding +plain HTTP through a proxy requires absolute-form request forwarding rather +than CONNECT tunneling and is out of scope. The proxy configuration is an +operator-owned boundary read from reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` / +`OPENSHELL_UPSTREAM_NO_PROXY` variables that compute drivers write in their +required-variable tier; sandbox and template environment cannot override them. +The conventional `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox +controls are ignored on this path. Reserved `NO_PROXY` destinations and +loopback always dial directly; add driver-injected host aliases (e.g. +`host.containers.internal`) to the reserved `NO_PROXY` list when the corporate +proxy cannot reach the container host. Only `http://` proxy URLs in +`scheme://host:port` form are supported; a path, query, or fragment is +rejected. 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. The configuration is fail-closed: a reserved variable that is present but invalid — a present-but-empty value, an unsupported or malformed proxy URL, an diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 4b696b05fe..298f27fd92 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -126,12 +126,6 @@ pub const SANDBOX_GID: &str = "OPENSHELL_SANDBOX_GID"; /// reserved for pointing the workload child at the local policy proxy. pub const UPSTREAM_HTTPS_PROXY: &str = "OPENSHELL_UPSTREAM_HTTPS_PROXY"; -/// Corporate forward-proxy URL the supervisor uses for plain-HTTP egress. -/// -/// Operator-owned counterpart to [`UPSTREAM_HTTPS_PROXY`]; see that constant -/// for the trust-boundary rationale. -pub const UPSTREAM_HTTP_PROXY: &str = "OPENSHELL_UPSTREAM_HTTP_PROXY"; - /// Comma-separated `NO_PROXY`-style list of destinations the supervisor dials /// directly instead of chaining through the corporate proxy. /// diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index ebc79e1ecd..0d6d8cb339 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -345,13 +345,12 @@ 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 for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://` URLs are supported. | -| `OPENSHELL_SANDBOX_HTTP_PROXY` | `--sandbox-http-proxy` | unset | Corporate forward proxy URL for plain HTTP requests. | +| `OPENSHELL_SANDBOX_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://` URLs are supported. Plain-HTTP requests always dial directly. | | `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. | | `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. | -Through the gateway, the same settings are the `https_proxy`, `http_proxy`, -`no_proxy`, and `proxy_auth_file` keys under `[openshell.drivers.podman]`; see +Through the gateway, the same settings are the `https_proxy`, `no_proxy`, and +`proxy_auth_file` keys under `[openshell.drivers.podman]`; see `docs/reference/gateway-config.mdx`. This is an operator-owned egress boundary: the supervisor reads it from reserved diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 16245c7928..dc1663ffa4 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -145,10 +145,6 @@ pub struct PodmanComputeConfig { /// required-variable tier so sandbox/template environment cannot override /// it, and the conventional `HTTPS_PROXY` variables are not used. pub https_proxy: Option, - /// Corporate forward proxy URL injected as the reserved - /// `OPENSHELL_UPSTREAM_HTTP_PROXY` variable, used for plain HTTP requests. - /// See `https_proxy`. - pub http_proxy: Option, /// Comma-separated `NO_PROXY` list injected as the reserved /// `OPENSHELL_UPSTREAM_NO_PROXY` variable (e.g. /// `*.svc.cluster.local,10.0.0.0/8`). Destinations matching an entry are @@ -158,7 +154,7 @@ pub struct PodmanComputeConfig { /// credentials as `user:pass`. /// /// Credentials must be supplied through this file, never embedded in the - /// proxy URL: an inline `user:pass@` in `https_proxy`/`http_proxy` is + /// proxy URL: an inline `user:pass@` in `https_proxy` is /// rejected at startup because it would leak into `gateway.toml` and /// container metadata. The gateway reads this file at sandbox-create time /// and delivers it to the supervisor through a root-only secret mount. @@ -231,21 +227,18 @@ impl PodmanComputeConfig { /// container metadata. pub fn validate_proxy_config(&self) -> Result<(), crate::client::PodmanApiError> { use openshell_core::driver_utils::{UpstreamProxyUrlError, parse_upstream_proxy_url}; - for (field, value) in [ - ("https_proxy", &self.https_proxy), - ("http_proxy", &self.http_proxy), - ] { - let Some(url) = value else { continue }; + if let Some(url) = &self.https_proxy { parse_upstream_proxy_url(url).map_err(|err| { crate::client::PodmanApiError::InvalidInput(match err { UpstreamProxyUrlError::Empty => { - format!("{field} must not be empty when set") + "https_proxy must not be empty when set".to_string() } - UpstreamProxyUrlError::InlineCredentials => format!( - "{field} must not embed credentials in the URL; supply them via \ + UpstreamProxyUrlError::InlineCredentials => { + "https_proxy must not embed credentials in the URL; supply them via \ proxy_auth_file so they are not stored in config or container metadata" - ), - err => format!("{field} {err}"), + .to_string() + } + err => format!("https_proxy {err}"), }) })?; } @@ -261,9 +254,9 @@ impl PodmanComputeConfig { // A bypass list only makes sense relative to a proxy boundary. An // operator who set one believed proxying was in effect, so accepting // it while all egress dials directly would hide a fail-open state. - if self.https_proxy.is_none() && self.http_proxy.is_none() { + if self.https_proxy.is_none() { return Err(crate::client::PodmanApiError::InvalidInput( - "no_proxy is set but no https_proxy/http_proxy is configured".to_string(), + "no_proxy is set but no https_proxy is configured".to_string(), )); } } @@ -274,10 +267,9 @@ impl PodmanComputeConfig { "proxy_auth_file must not be empty when set".to_string(), )); } - if self.https_proxy.is_none() && self.http_proxy.is_none() { + if self.https_proxy.is_none() { return Err(crate::client::PodmanApiError::InvalidInput( - "proxy_auth_file is set but no https_proxy/http_proxy is configured" - .to_string(), + "proxy_auth_file is set but no https_proxy is configured".to_string(), )); } } @@ -358,7 +350,6 @@ impl Default for PodmanComputeConfig { enable_bind_mounts: false, health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS, https_proxy: None, - http_proxy: None, no_proxy: None, proxy_auth_file: None, } @@ -389,7 +380,6 @@ impl std::fmt::Debug for PodmanComputeConfig { ) // 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) .field("proxy_auth_file", &self.proxy_auth_file.is_some()) .finish() @@ -512,7 +502,6 @@ mod tests { ); 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() }; @@ -556,11 +545,11 @@ mod tests { #[test] fn validate_proxy_config_rejects_empty_value() { let cfg = PodmanComputeConfig { - http_proxy: Some(" ".to_string()), + https_proxy: Some(" ".to_string()), ..PodmanComputeConfig::default() }; let err = cfg.validate_proxy_config().unwrap_err(); - assert!(err.to_string().contains("http_proxy"), "{err}"); + assert!(err.to_string().contains("https_proxy"), "{err}"); } #[test] diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 1216cb5c5b..91670c93c6 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -421,10 +421,6 @@ fn build_env( openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, &config.https_proxy, ), - ( - openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY, - &config.http_proxy, - ), ( openshell_core::sandbox_env::UPSTREAM_NO_PROXY, &config.no_proxy, @@ -1719,14 +1715,11 @@ mod tests { #[test] fn container_spec_injects_operator_proxy_env() { - use openshell_core::sandbox_env::{ - UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, - }; + use openshell_core::sandbox_env::{UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY}; 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); @@ -1737,11 +1730,6 @@ mod tests { Some("http://proxy.corp.com:8080"), "reserved upstream HTTPS var should carry the operator proxy URL" ); - assert_eq!( - env_map.get(UPSTREAM_HTTP_PROXY).and_then(|v| v.as_str()), - Some("http://proxy.corp.com:3128"), - "reserved upstream HTTP var should carry the operator proxy URL" - ); assert_eq!( env_map.get(UPSTREAM_NO_PROXY).and_then(|v| v.as_str()), Some("*.svc.cluster.local,10.0.0.0/8"), @@ -1760,15 +1748,13 @@ mod tests { #[test] fn container_spec_omits_proxy_env_when_unconfigured() { - use openshell_core::sandbox_env::{ - UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, - }; + use openshell_core::sandbox_env::{UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY}; 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 [UPSTREAM_HTTPS_PROXY, UPSTREAM_HTTP_PROXY, UPSTREAM_NO_PROXY] { + for key in [UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY] { assert!( !env_map.contains_key(key), "{key} should be absent without operator proxy config" diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index e7d8f374d4..0f562abc7f 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -110,12 +110,7 @@ struct Args { #[arg(long, env = "OPENSHELL_SANDBOX_HTTPS_PROXY")] sandbox_https_proxy: Option, - /// Corporate forward proxy URL for plain HTTP (http:// only). Credentials - /// must not be embedded in the URL; use `--sandbox-proxy-auth-file`. - #[arg(long, env = "OPENSHELL_SANDBOX_HTTP_PROXY")] - sandbox_http_proxy: Option, - - /// Comma-separated `NO_PROXY` list injected alongside the proxy URLs. + /// Comma-separated `NO_PROXY` list injected alongside the proxy URL. #[arg(long, env = "OPENSHELL_SANDBOX_NO_PROXY")] sandbox_no_proxy: Option, @@ -159,7 +154,6 @@ async fn main() -> Result<()> { 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, proxy_auth_file: args.sandbox_proxy_auth_file, ..PodmanComputeConfig::default() diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index a5d351c1c4..67888a68fa 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7,7 +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 crate::upstream_proxy::{self, UpstreamProxyConfig}; use miette::{IntoDiagnostic, Result}; use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::denial::DenialEvent; @@ -738,7 +738,6 @@ async fn handle_tcp_connection( entrypoint_pid, policy_local_ctx, trusted_host_gateway, - upstream_proxy, secret_resolver, dynamic_credentials, denial_tx.as_ref(), @@ -1213,15 +1212,9 @@ async fn handle_tcp_connection( return Ok(()); } - let mut upstream = dial_upstream( - &upstream_proxy, - UpstreamScheme::Https, - &host_lc, - port, - &validated_addrs, - ) - .await - .into_diagnostic()?; + let mut upstream = dial_upstream(&upstream_proxy, &host_lc, port, &validated_addrs) + .await + .into_diagnostic()?; debug!( "handle_tcp_connection dns_resolve_and_tcp_connect: {}ms host={host_lc}", @@ -2947,28 +2940,28 @@ fn validate_declared_endpoint_resolved_addrs( Ok(()) } -/// Dial a validated upstream destination. +/// Dial a validated upstream destination for a TLS (CONNECT) tunnel. /// /// 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 reserved upstream proxy variables /// and not excluded by the reserved `NO_PROXY` list. Policy evaluation and /// SSRF validation must have already succeeded; only the final TCP dial -/// changes. +/// changes. Plain-HTTP requests never take this path: they always dial the +/// destination directly. /// /// 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)) + .and_then(|cfg| cfg.proxy_for(host_lc)) { return upstream_proxy::connect_via(endpoint, host_lc, port).await; } @@ -3614,7 +3607,6 @@ 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< @@ -4648,20 +4640,11 @@ async fn handle_forward_proxy( return Ok(()); } - // 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 - }; + // 6. Connect upstream. Plain-HTTP requests always dial the destination + // directly: only TLS (CONNECT) tunnels chain through the corporate + // proxy, since plain-HTTP forwarding would need absolute-form requests + // rather than a CONNECT tunnel. + let dial_result = TcpStream::connect(addrs.as_slice()).await; let mut upstream = match dial_result { Ok(s) => s, Err(e) => { diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index b35aa93ccf..308941b081 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -6,10 +6,14 @@ //! 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 operator-owned -//! reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` / `OPENSHELL_UPSTREAM_HTTP_PROXY` -//! / `OPENSHELL_UPSTREAM_NO_PROXY` variables from the supervisor's **own** -//! environment and chains approved connections through the corporate proxy -//! with HTTP CONNECT. +//! reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` / `OPENSHELL_UPSTREAM_NO_PROXY` +//! variables from the supervisor's **own** environment and chains approved +//! TLS tunnels through the corporate proxy with HTTP CONNECT. +//! +//! Only TLS (CONNECT) egress is chained: plain-HTTP requests always dial the +//! destination directly. Forwarding plain HTTP through a corporate proxy +//! requires absolute-form request forwarding rather than CONNECT tunneling +//! and is out of scope for this feature. //! //! The conventional `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` //! variables are intentionally ignored: those are controlled by the sandbox @@ -51,15 +55,6 @@ const MAX_CONNECT_RESPONSE_BYTES: usize = 8 * 1024; /// 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 { @@ -173,8 +168,7 @@ impl NoProxy { /// Corporate proxy configuration read from the supervisor's environment. #[derive(Debug, Clone)] pub struct UpstreamProxyConfig { - https: Option, - http: Option, + https: ProxyEndpoint, no_proxy: NoProxy, } @@ -182,7 +176,6 @@ impl UpstreamProxyConfig { /// Read the operator-owned corporate proxy configuration from the /// supervisor's reserved environment variables /// ([`UPSTREAM_HTTPS_PROXY`](openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY), - /// [`UPSTREAM_HTTP_PROXY`](openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY), /// [`UPSTREAM_NO_PROXY`](openshell_core::sandbox_env::UPSTREAM_NO_PROXY), /// [`UPSTREAM_PROXY_AUTH_FILE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE)). /// Returns `Ok(None)` when no proxy is configured (unset variables). @@ -212,7 +205,7 @@ impl UpstreamProxyConfig { fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result, String> { use openshell_core::sandbox_env::{ - UPSTREAM_HTTP_PROXY, UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, UPSTREAM_PROXY_AUTH_FILE, + UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, UPSTREAM_PROXY_AUTH_FILE, }; // Only a fully unset reserved variable means "not configured". A // present-but-empty value is a misconfiguration (the compute driver @@ -230,12 +223,9 @@ impl UpstreamProxyConfig { let https = var(UPSTREAM_HTTPS_PROXY)? .map(|url| parse_proxy_url(&url, UPSTREAM_HTTPS_PROXY)) .transpose()?; - let http = var(UPSTREAM_HTTP_PROXY)? - .map(|url| parse_proxy_url(&url, UPSTREAM_HTTP_PROXY)) - .transpose()?; let auth_file = var(UPSTREAM_PROXY_AUTH_FILE)?; let no_proxy_list = var(UPSTREAM_NO_PROXY)?; - if https.is_none() && http.is_none() { + let Some(mut https) = https else { // Auxiliary proxy settings without a proxy mean the operator // believed a proxy boundary was in effect; refuse rather than // silently running with direct egress. @@ -248,18 +238,11 @@ impl UpstreamProxyConfig { } } return Ok(None); - } - let no_proxy = NoProxy::parse(&no_proxy_list.unwrap_or_default()); - let mut config = Self { - https, - http, - no_proxy, }; - // Load proxy credentials from the reserved auth file, if configured, - // and apply them to every endpoint. The file is delivered through a - // root-only secret mount so the credentials never appear in the - // environment or container metadata. + // Load proxy credentials from the reserved auth file, if configured. + // The file is delivered through a root-only secret mount so the + // credentials never appear in the environment or container metadata. if let Some(path) = auth_file { let credential = std::fs::read_to_string(&path).map_err(|err| { format!("failed to read upstream proxy auth file '{path}': {err}") @@ -267,46 +250,31 @@ impl UpstreamProxyConfig { let header = basic_auth_header(&credential).map_err(|err| { format!("invalid credential in upstream proxy auth file '{path}': {err}") })?; - config.apply_proxy_auth(header); + https.proxy_authorization = Some(header); } - Ok(Some(config)) - } - - /// Attach a pre-built `Proxy-Authorization` header value to every - /// configured endpoint. - fn apply_proxy_auth(&mut self, proxy_authorization: String) { - for endpoint in [self.https.as_mut(), self.http.as_mut()] - .into_iter() - .flatten() - { - endpoint.proxy_authorization = Some(proxy_authorization.clone()); - } + Ok(Some(Self { + https, + no_proxy: NoProxy::parse(&no_proxy_list.unwrap_or_default()), + })) } - /// The corporate proxy to use for `host`, or `None` when the destination - /// must be dialed directly. + /// The corporate proxy to use for a TLS tunnel to `host`, or `None` when + /// the destination must be dialed directly. #[must_use] - pub fn proxy_for(&self, scheme: UpstreamScheme, host: &str) -> Option<&ProxyEndpoint> { + pub fn proxy_for(&self, 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(), - } + Some(&self.https) } /// 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()), + "https_proxy={} no_proxy_entries={}", + self.https.display_addr(), self.no_proxy.entries.len() ) } @@ -474,8 +442,8 @@ async fn connect_via_inner( mod tests { use super::*; use openshell_core::sandbox_env::{ - UPSTREAM_HTTP_PROXY as HTTP_PROXY, UPSTREAM_HTTPS_PROXY as HTTPS_PROXY, - UPSTREAM_NO_PROXY as NO_PROXY, UPSTREAM_PROXY_AUTH_FILE as PROXY_AUTH_FILE, + UPSTREAM_HTTPS_PROXY as HTTPS_PROXY, UPSTREAM_NO_PROXY as NO_PROXY, + UPSTREAM_PROXY_AUTH_FILE as PROXY_AUTH_FILE, }; fn config_from(pairs: &[(&str, &str)]) -> Result, String> { @@ -521,7 +489,7 @@ mod tests { // mean "no proxy". for (name, value) in [ (HTTPS_PROXY, ""), - (HTTP_PROXY, " "), + (HTTPS_PROXY, " "), (NO_PROXY, " "), (PROXY_AUTH_FILE, ""), ] { @@ -534,21 +502,15 @@ mod tests { #[test] fn https_proxy_parsed_with_port() { let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy.corp.com:8080")]); - let ep = cfg - .proxy_for(UpstreamScheme::Https, "api.stripe.com") - .unwrap(); + let ep = cfg.proxy_for("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 scheme_defaults_to_http_and_port_defaults_to_80() { - let cfg = config_ok(&[(HTTP_PROXY, "proxy.corp.com")]); - let ep = cfg.proxy_for(UpstreamScheme::Http, "example.com").unwrap(); + let cfg = config_ok(&[(HTTPS_PROXY, "proxy.corp.com")]); + let ep = cfg.proxy_for("example.com").unwrap(); assert_eq!(ep.display_addr(), "proxy.corp.com:80"); } @@ -582,8 +544,8 @@ mod tests { #[test] fn malformed_proxy_address_is_fatal() { - let err = config_from(&[(HTTP_PROXY, "http://proxy:notaport")]).unwrap_err(); - assert!(err.contains(HTTP_PROXY), "{err}"); + let err = config_from(&[(HTTPS_PROXY, "http://proxy:notaport")]).unwrap_err(); + assert!(err.contains(HTTPS_PROXY), "{err}"); // A path/query/fragment addresses an endpoint, not a proxy; it is // rejected rather than silently discarded. for url in [ @@ -594,13 +556,6 @@ mod tests { let err = config_from(&[(HTTPS_PROXY, url)]).unwrap_err(); assert!(err.contains(HTTPS_PROXY), "{url}: {err}"); } - // One invalid endpoint is fatal even when the other one is valid. - let err = config_from(&[ - (HTTPS_PROXY, "http://proxy.corp.com:8080"), - (HTTP_PROXY, "socks5://proxy:1080"), - ]) - .unwrap_err(); - assert!(err.contains(HTTP_PROXY), "{err}"); } #[test] @@ -649,23 +604,17 @@ mod tests { } #[test] - fn auth_file_credentials_are_applied_to_all_endpoints() { + fn auth_file_credentials_are_applied_to_the_endpoint() { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write(file.path(), "user:secret\n").unwrap(); let path = file.path().to_str().unwrap().to_string(); - let cfg = config_ok(&[ - (HTTPS_PROXY, "http://proxy:8080"), - (HTTP_PROXY, "http://proxy:3128"), - (PROXY_AUTH_FILE, &path), - ]); + let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy:8080"), (PROXY_AUTH_FILE, &path)]); let expected = format!( "Basic {}", base64::engine::general_purpose::STANDARD.encode("user:secret") ); - for scheme in [UpstreamScheme::Https, UpstreamScheme::Http] { - let ep = cfg.proxy_for(scheme, "example.com").unwrap(); - assert_eq!(ep.proxy_authorization.as_deref(), Some(expected.as_str())); - } + let ep = cfg.proxy_for("example.com").unwrap(); + assert_eq!(ep.proxy_authorization.as_deref(), Some(expected.as_str())); } #[test] @@ -687,7 +636,7 @@ mod tests { #[test] fn debug_output_hides_credentials() { let mut cfg = config_ok(&[(HTTPS_PROXY, "http://proxy:8080")]); - cfg.apply_proxy_auth(basic_auth_header("user:secret").unwrap()); + cfg.https.proxy_authorization = Some(basic_auth_header("user:secret").unwrap()); let debug = format!("{cfg:?}"); assert!(!debug.contains("secret")); assert!(!cfg.summary().contains("secret")); @@ -696,7 +645,7 @@ mod tests { #[test] fn ipv6_proxy_address_parses() { let cfg = config_ok(&[(HTTPS_PROXY, "http://[fd00::1]:8080")]); - let ep = cfg.proxy_for(UpstreamScheme::Https, "example.com").unwrap(); + let ep = cfg.proxy_for("example.com").unwrap(); assert_eq!(ep.display_addr(), "fd00::1:8080"); } @@ -707,7 +656,7 @@ mod tests { } fn bypasses(cfg: &UpstreamProxyConfig, host: &str) -> bool { - cfg.proxy_for(UpstreamScheme::Https, host).is_none() + cfg.proxy_for(host).is_none() } #[test] diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index b5f08f3f1f..3a9cc45388 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -83,7 +83,6 @@ const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[ // reaches egress through the local policy proxy, never the corporate proxy // directly, so these must not be inherited by sandbox child processes. openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, - openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY, openshell_core::sandbox_env::UPSTREAM_NO_PROXY, openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, ]; @@ -2268,7 +2267,6 @@ mod tests { // and must be treated as supervisor-only so they are stripped above. for key in [ openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, - openshell_core::sandbox_env::UPSTREAM_HTTP_PROXY, openshell_core::sandbox_env::UPSTREAM_NO_PROXY, openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, ] { diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 7b43b3b444..0eac4fc89b 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -390,10 +390,11 @@ sandbox_pids_limit = 2048 # 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, in scheme://host:port form: a URL carrying a -# path, query, or fragment is rejected rather than silently truncated. +# supervisor chains policy-approved TLS tunnels through this proxy with HTTP +# CONNECT instead of dialing destinations directly. Plain-HTTP requests are +# not proxied and always dial the destination directly. Only http:// proxy +# URLs are supported, in scheme://host:port form: a URL carrying a path, +# query, or fragment is rejected rather than silently truncated. # NO_PROXY entries (hostnames, domain suffixes, IPs, CIDRs) are dialed # directly. This is an operator-owned egress boundary: # sandbox and template environment cannot override it, and the conventional @@ -415,7 +416,6 @@ health_check_interval_secs = 10 # supervisor, so a credential accepted here is never rejected in-container. # Keep gateway.toml and the auth file owner-readable only (mode 0600). # https_proxy = "http://proxy.corp.com:8080" -# http_proxy = "http://proxy.corp.com:8080" # no_proxy = "*.svc.cluster.local,10.0.0.0/8" # proxy_auth_file = "/etc/openshell/secrets/proxy-auth" ``` diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 2476776a42..e0e08f1be8 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -362,9 +362,6 @@ EOF if [[ -n "${OPENSHELL_SANDBOX_HTTPS_PROXY+x}" ]]; then printf 'https_proxy = "%s"\n' "${OPENSHELL_SANDBOX_HTTPS_PROXY}" >>"${CONFIG_PATH}" fi - if [[ -n "${OPENSHELL_SANDBOX_HTTP_PROXY+x}" ]]; then - printf 'http_proxy = "%s"\n' "${OPENSHELL_SANDBOX_HTTP_PROXY}" >>"${CONFIG_PATH}" - fi if [[ -n "${OPENSHELL_SANDBOX_NO_PROXY+x}" ]]; then printf 'no_proxy = "%s"\n' "${OPENSHELL_SANDBOX_NO_PROXY}" >>"${CONFIG_PATH}" fi From 1f19d263a05221d6aec9f69c92a6c9f835cf173a Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 09:47:20 +0200 Subject: [PATCH 09/28] fix(sandbox,podman): escape generated TOML and require explicit proxy URL form Escape backslashes, quotes, and control characters when gateway.sh writes proxy values into gateway.toml, so a hostile or unusual environment value cannot corrupt the config or inject extra keys. Restrict the upstream proxy URL grammar to the documented http://host:port form: a scheme-less value is no longer normalized to http:// and a missing port is no longer silently defaulted to 80. Docs, README, and CLI help now state the explicit-form requirement consistently. Also fix a test-only call of handle_tcp_connection that was missing the upstream_proxy argument added in an earlier commit. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 6 +- crates/openshell-core/src/driver_utils.rs | 98 +++++++++++++++---- crates/openshell-driver-podman/README.md | 2 +- crates/openshell-driver-podman/src/config.rs | 19 +++- crates/openshell-driver-podman/src/main.rs | 5 +- .../openshell-supervisor-network/src/proxy.rs | 1 + .../src/upstream_proxy.rs | 18 +++- docs/reference/gateway-config.mdx | 5 +- tasks/scripts/gateway.sh | 19 +++- 9 files changed, 138 insertions(+), 35 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 4a50dc8c27..dd137877fc 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -116,9 +116,9 @@ The conventional `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox controls are ignored on this path. Reserved `NO_PROXY` destinations and loopback always dial directly; add driver-injected host aliases (e.g. `host.containers.internal`) to the reserved `NO_PROXY` list when the corporate -proxy cannot reach the container host. Only `http://` proxy URLs in -`scheme://host:port` form are supported; a path, query, or fragment is -rejected. Local DNS resolution and SSRF validation still run before the +proxy cannot reach the container host. Only `http://` proxy URLs in explicit +`http://host:port` form are supported — the scheme and port are both +required, and a path, query, or fragment is rejected. 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. diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 31f1ccb848..3ee643f13c 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -91,7 +91,7 @@ pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-p pub struct UpstreamProxyAddr { /// Proxy hostname, IPv4, or IPv6 address (IPv6 without brackets). pub host: String, - /// Proxy TCP port (defaults to 80 when the URL has none). + /// Proxy TCP port (always explicit in the accepted URL grammar). pub port: u16, } @@ -108,6 +108,11 @@ pub enum UpstreamProxyUrlError { /// The value does not parse as a URL. #[error("not a valid proxy URL: {0}")] Invalid(url::ParseError), + /// The value has no `scheme://` prefix. Bare `host[:port]` forms are + /// rejected so the accepted grammar matches the documented + /// `http://host:port` contract exactly. + #[error("proxy URL must include an explicit scheme, e.g. http://proxy.corp.com:3128")] + MissingScheme, /// The URL uses a scheme other than `http` (TLS and SOCKS proxies are /// not supported by the sandbox supervisor). #[error( @@ -115,6 +120,11 @@ pub enum UpstreamProxyUrlError { supported by the sandbox supervisor" )] UnsupportedScheme(String), + /// The URL has no explicit port. Corporate proxies rarely listen on the + /// scheme default (80), so a forgotten port is rejected instead of + /// silently dialing port 80. + #[error("proxy URL must include an explicit proxy port, e.g. http://proxy.corp.com:3128")] + MissingPort, /// The URL embeds `user:pass@` credentials, which would leak into config /// and container metadata. Credentials must come from the proxy auth file. #[error("proxy URL must not embed credentials; supply them via the proxy auth file")] @@ -132,11 +142,11 @@ pub enum UpstreamProxyUrlError { /// Parse and validate a corporate upstream-proxy URL. /// -/// A bare `host:port` (no `://`) is normalized to `http://host:port`. Only -/// `http://` proxies are accepted, inline userinfo is rejected, and the port -/// defaults to 80. The URL must address the proxy only: a path (other than -/// a bare trailing `/`), query, or fragment is rejected rather than silently -/// discarded. +/// The accepted grammar is exactly `http://host:port`: the scheme and the +/// port must both be explicit, only `http://` proxies are accepted, and +/// inline userinfo is rejected. The URL must address the proxy only: a path +/// (other than a bare trailing `/`), query, or fragment is rejected rather +/// than silently discarded. /// /// # Errors /// @@ -147,12 +157,10 @@ pub fn parse_upstream_proxy_url(raw: &str) -> Result Result bool { + let after_scheme = raw.split_once("://").map_or(raw, |(_, rest)| rest); + let authority_end = after_scheme + .find(['/', '?', '#']) + .unwrap_or(after_scheme.len()); + let authority = &after_scheme[..authority_end]; + // Userinfo is rejected by the caller, but strip it anyway so this check + // never misreads a `user:pass@` colon as a port. + let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp); + host_port.rfind(']').map_or_else( + || { + host_port + .rsplit_once(':') + .is_some_and(|(_, port)| !port.is_empty()) + }, + // Bracketed IPv6 literal: a port can only follow the bracket. + |end| host_port[end + 1..].starts_with(':'), + ) +} + /// Why an upstream proxy credential was rejected by /// [`parse_upstream_proxy_credential`]. /// @@ -344,12 +383,38 @@ mod tests { } #[test] - fn upstream_proxy_url_defaults_scheme_and_port() { - let addr = parse_upstream_proxy_url("proxy.corp.com").unwrap(); - assert_eq!(addr.host, "proxy.corp.com"); + fn upstream_proxy_url_rejects_missing_scheme() { + for url in [ + "proxy.corp.com", + "proxy.corp.com:3128", + "user:pass@proxy.corp.com:8080", + ] { + assert_eq!( + parse_upstream_proxy_url(url), + Err(UpstreamProxyUrlError::MissingScheme), + "{url}" + ); + } + } + + #[test] + fn upstream_proxy_url_rejects_missing_port() { + for url in [ + "http://proxy.corp.com", + "http://proxy.corp.com/", + "http://proxy.corp.com:", + "http://[fd00::1]", + ] { + assert_eq!( + parse_upstream_proxy_url(url), + Err(UpstreamProxyUrlError::MissingPort), + "{url}" + ); + } + // An explicit scheme-default port is accepted even though the url + // crate normalizes it away in the parsed form. + let addr = parse_upstream_proxy_url("http://proxy.corp.com:80").unwrap(); assert_eq!(addr.port, 80); - let addr = parse_upstream_proxy_url("proxy.corp.com:3128").unwrap(); - assert_eq!(addr.port, 3128); } #[test] @@ -396,7 +461,6 @@ mod tests { fn upstream_proxy_url_rejects_path_query_and_fragment() { for (url, component) in [ ("http://proxy.corp.com:8080/some/path", "path"), - ("proxy.corp.com:8080/some/path", "path"), ("http://proxy.corp.com:8080?x=1", "query"), ("http://proxy.corp.com:8080/?x=1", "query"), ("http://proxy.corp.com:8080#frag", "fragment"), diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 0d6d8cb339..f41c4beddd 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -345,7 +345,7 @@ 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 for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://` URLs are supported. Plain-HTTP requests always dial directly. | +| `OPENSHELL_SANDBOX_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://host:port` URLs are supported (scheme and port required). Plain-HTTP requests always dial directly. | | `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. | | `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. | diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index dc1663ffa4..82361a4a3a 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -140,7 +140,8 @@ pub struct PodmanComputeConfig { /// /// 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. + /// destinations directly. Only `http://` proxy URLs in explicit + /// `http://host:port` form (scheme and port required) are supported. /// This is an operator-owned egress boundary: it is written in the /// required-variable tier so sandbox/template environment cannot override /// it, and the conventional `HTTPS_PROXY` variables are not used. @@ -542,6 +543,21 @@ mod tests { } } + #[test] + fn validate_proxy_config_rejects_missing_scheme_or_port() { + // A scheme-less value (previously normalized to http://) and a + // port-less value (previously defaulted to 80) are both rejected so + // gateway.toml matches the documented http://host:port grammar. + for url in ["proxy.corp.com:8080", "http://proxy.corp.com"] { + let cfg = PodmanComputeConfig { + https_proxy: Some(url.to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("explicit"), "{url}: {err}"); + } + } + #[test] fn validate_proxy_config_rejects_empty_value() { let cfg = PodmanComputeConfig { @@ -578,7 +594,6 @@ mod tests { for url in [ "http://user:pass@proxy.corp.com:8080", "http://user@proxy.corp.com:8080", - "user:pass@proxy.corp.com:8080", ] { let cfg = PodmanComputeConfig { https_proxy: Some(url.to_string()), diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index 0f562abc7f..b14490de17 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -104,8 +104,9 @@ struct Args { #[arg(long, env = "OPENSHELL_PODMAN_TLS_KEY")] podman_tls_key: Option, - /// Corporate forward proxy URL for the supervisor's upstream TLS dials - /// (http:// only). Credentials must not be embedded in the URL; use + /// Corporate forward proxy URL for the supervisor's upstream TLS dials, + /// in explicit `http://host:port` form (scheme and port required). + /// Credentials must not be embedded in the URL; use /// `--sandbox-proxy-auth-file` instead. #[arg(long, env = "OPENSHELL_SANDBOX_HTTPS_PROXY")] sandbox_https_proxy: Option, diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 67888a68fa..828acf57d7 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -5112,6 +5112,7 @@ network_policies: {} None, None, Arc::new(None), + Arc::new(None), None, None, None, diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 308941b081..4b379aa136 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -280,7 +280,7 @@ impl UpstreamProxyConfig { } } -/// Parse an `http://host[:port]` proxy URL with the same validation rules the +/// Parse an `http://host:port` proxy URL with the same validation rules the /// compute driver applies at sandbox-create time /// ([`parse_upstream_proxy_url`](openshell_core::driver_utils::parse_upstream_proxy_url)). /// @@ -508,10 +508,18 @@ mod tests { } #[test] - fn scheme_defaults_to_http_and_port_defaults_to_80() { - let cfg = config_ok(&[(HTTPS_PROXY, "proxy.corp.com")]); - let ep = cfg.proxy_for("example.com").unwrap(); - assert_eq!(ep.display_addr(), "proxy.corp.com:80"); + fn scheme_less_or_port_less_proxy_url_is_fatal() { + // The accepted grammar is exactly `http://host:port`; lenient + // defaulting would let a typo silently target the wrong proxy. + for url in [ + "proxy.corp.com", + "proxy.corp.com:3128", + "http://proxy.corp.com", + ] { + let err = config_from(&[(HTTPS_PROXY, url)]).unwrap_err(); + assert!(err.contains(HTTPS_PROXY), "{url}: {err}"); + assert!(err.contains("explicit"), "{url}: {err}"); + } } // -- Fail-closed configuration validation -- diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 0eac4fc89b..eb45926b5c 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -393,8 +393,9 @@ health_check_interval_secs = 10 # supervisor chains policy-approved TLS tunnels through this proxy with HTTP # CONNECT instead of dialing destinations directly. Plain-HTTP requests are # not proxied and always dial the destination directly. Only http:// proxy -# URLs are supported, in scheme://host:port form: a URL carrying a path, -# query, or fragment is rejected rather than silently truncated. +# URLs in explicit http://host:port form are supported: the scheme and port +# are both required, and a URL carrying a path, query, or fragment is +# rejected rather than silently truncated. # NO_PROXY entries (hostnames, domain suffixes, IPs, CIDRs) are dialed # directly. This is an operator-owned egress boundary: # sandbox and template environment cannot override it, and the conventional diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index e0e08f1be8..196b54e06b 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -110,6 +110,19 @@ detect_driver() { exit 2 } +# Escape a value for embedding in a double-quoted TOML basic string, so +# quotes, backslashes, or control characters in an environment value cannot +# corrupt gateway.toml or inject extra configuration keys. +toml_escape() { + local s=$1 + s=${s//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\n'/\\n} + s=${s//$'\r'/\\r} + s=${s//$'\t'/\\t} + printf '%s' "${s}" +} + port_is_in_use() { local port=$1 if command_available lsof; then @@ -360,13 +373,13 @@ EOF # gateway's fail-closed proxy validation rejects it at startup instead of # this script silently dropping it. if [[ -n "${OPENSHELL_SANDBOX_HTTPS_PROXY+x}" ]]; then - printf 'https_proxy = "%s"\n' "${OPENSHELL_SANDBOX_HTTPS_PROXY}" >>"${CONFIG_PATH}" + printf 'https_proxy = "%s"\n' "$(toml_escape "${OPENSHELL_SANDBOX_HTTPS_PROXY}")" >>"${CONFIG_PATH}" fi if [[ -n "${OPENSHELL_SANDBOX_NO_PROXY+x}" ]]; then - printf 'no_proxy = "%s"\n' "${OPENSHELL_SANDBOX_NO_PROXY}" >>"${CONFIG_PATH}" + printf 'no_proxy = "%s"\n' "$(toml_escape "${OPENSHELL_SANDBOX_NO_PROXY}")" >>"${CONFIG_PATH}" fi if [[ -n "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE+x}" ]]; then - printf 'proxy_auth_file = "%s"\n' "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE}" >>"${CONFIG_PATH}" + printf 'proxy_auth_file = "%s"\n' "$(toml_escape "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE}")" >>"${CONFIG_PATH}" fi ;; esac From 4389400b36c4e2713b791500d7da937247239c52 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 09:57:49 +0200 Subject: [PATCH 10/28] fix(sandbox,podman): preserve tunneled bytes read with the CONNECT response The CONNECT handshake reads the corporate proxy's response in chunks, so the read that completes the header block can also contain the first tunneled payload bytes. Those bytes were discarded, silently corrupting the start of the tunnel for server-speaks-first destinations or proxies that coalesce writes. connect_via now returns a PrefixedStream that replays any bytes received past the response terminator before reading from the socket again; writes pass through unchanged. Direct dials wrap the stream with an empty prefix so downstream relay and TLS paths keep a single stream type, and tls_connect_upstream is generalized to any AsyncRead + AsyncWrite stream. Adds regression coverage for a combined response/payload read and for prefix replay ordering. Signed-off-by: Philippe Martin --- .../src/l7/tls.rs | 2 +- .../openshell-supervisor-network/src/proxy.rs | 10 +- .../src/upstream_proxy.rs | 156 ++++++++++++++++-- 3 files changed, 154 insertions(+), 14 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/tls.rs b/crates/openshell-supervisor-network/src/l7/tls.rs index 70e198f420..f7c923c690 100644 --- a/crates/openshell-supervisor-network/src/l7/tls.rs +++ b/crates/openshell-supervisor-network/src/l7/tls.rs @@ -184,7 +184,7 @@ pub async fn tls_terminate_client( /// /// Returns a TLS stream for re-encrypted upstream communication. pub async fn tls_connect_upstream( - upstream: TcpStream, + upstream: impl AsyncRead + AsyncWrite + Unpin + Send, hostname: &str, client_config: &Arc, ) -> Result { diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 828acf57d7..ccad578da6 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2953,19 +2953,25 @@ fn validate_declared_endpoint_resolved_addrs( /// 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. +/// +/// Both paths return a [`upstream_proxy::PrefixedStream`]: for proxied +/// dials it replays any tunneled bytes that arrived in the same read as the +/// CONNECT response; for direct dials it is a plain passthrough. async fn dial_upstream( upstream_proxy: &Option, host_lc: &str, port: u16, addrs: &[SocketAddr], -) -> std::io::Result { +) -> std::io::Result { if let Some(endpoint) = upstream_proxy .as_ref() .and_then(|cfg| cfg.proxy_for(host_lc)) { return upstream_proxy::connect_via(endpoint, host_lc, port).await; } - TcpStream::connect(addrs).await + Ok(upstream_proxy::PrefixedStream::without_prefix( + TcpStream::connect(addrs).await?, + )) } /// Resolve a host:port using sandbox `/etc/hosts` first (when available), then diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 4b379aa136..909619ba1d 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -41,10 +41,12 @@ use std::io::{Error as IoError, ErrorKind}; use std::net::IpAddr; +use std::pin::Pin; +use std::task::{Context, Poll}; use std::time::Duration; use base64::Engine as _; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::net::TcpStream; use tracing::debug; @@ -327,10 +329,101 @@ fn basic_auth_header(credential: &str) -> Result { )) } +/// A `TcpStream` that first replays bytes already read from the socket. +/// +/// The CONNECT handshake reads from the proxy socket in chunks, so the read +/// that completes the response header block may also contain the first +/// tunneled payload bytes (a server-speaks-first destination, or a proxy +/// that coalesces writes). Those bytes belong to the destination byte +/// stream and must reach the caller rather than being discarded; this +/// wrapper yields them before reading from the socket again. Writes pass +/// straight through. +#[derive(Debug)] +pub struct PrefixedStream { + inner: TcpStream, + /// Bytes read past the CONNECT header terminator, replayed first. + prefix: Vec, + /// Read offset into `prefix`. + pos: usize, +} + +impl PrefixedStream { + /// Wrap `inner`, replaying `prefix` before socket reads. + #[must_use] + pub fn new(inner: TcpStream, prefix: Vec) -> Self { + Self { + inner, + prefix, + pos: 0, + } + } + + /// Wrap a directly dialed stream with nothing to replay. + #[must_use] + pub fn without_prefix(inner: TcpStream) -> Self { + Self::new(inner, Vec::new()) + } +} + +impl AsyncRead for PrefixedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if this.pos < this.prefix.len() { + let n = buf.remaining().min(this.prefix.len() - this.pos); + buf.put_slice(&this.prefix[this.pos..this.pos + n]); + this.pos += n; + if this.pos == this.prefix.len() { + // Drained: release the buffer instead of holding it for the + // tunnel's lifetime. + this.prefix = Vec::new(); + this.pos = 0; + } + return Poll::Ready(Ok(())); + } + Pin::new(&mut this.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for PrefixedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[std::io::IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write_vectored(cx, bufs) + } + + fn is_write_vectored(&self) -> bool { + self.inner.is_write_vectored() + } +} + /// 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. +/// stream is a transparent byte pipe to the destination. Any tunneled bytes +/// received in the same read as the CONNECT response are preserved and +/// replayed by the returned [`PrefixedStream`]. /// /// The destination hostname (not a locally resolved IP) is sent in the /// CONNECT target so hostname-filtering proxies keep working; local DNS @@ -345,7 +438,7 @@ pub async fn connect_via( endpoint: &ProxyEndpoint, host: &str, port: u16, -) -> std::io::Result { +) -> std::io::Result { tokio::time::timeout( CONNECT_HANDSHAKE_TIMEOUT, connect_via_inner(endpoint, host, port), @@ -366,7 +459,7 @@ async fn connect_via_inner( endpoint: &ProxyEndpoint, host: &str, port: u16, -) -> std::io::Result { +) -> std::io::Result { let mut stream = TcpStream::connect((endpoint.host.as_str(), endpoint.port)).await?; let target = if host.contains(':') { @@ -383,10 +476,12 @@ async fn connect_via_inner( request.push_str("\r\n"); stream.write_all(request.as_bytes()).await?; - // Read the proxy's response header block. + // Read the proxy's response header block. A read may run past the + // `\r\n\r\n` terminator into tunneled payload; those bytes are preserved + // below, never discarded. let mut buf = vec![0u8; MAX_CONNECT_RESPONSE_BYTES]; let mut used = 0; - loop { + let header_end = loop { if used == buf.len() { return Err(IoError::other(format!( "upstream proxy {} CONNECT response headers exceed {MAX_CONNECT_RESPONSE_BYTES} bytes", @@ -404,12 +499,12 @@ async fn connect_via_inner( )); } used += n; - if buf[..used].windows(4).any(|win| win == b"\r\n\r\n") { - break; + if let Some(pos) = buf[..used].windows(4).position(|win| win == b"\r\n\r\n") { + break pos + 4; } - } + }; - let response = String::from_utf8_lossy(&buf[..used]); + let response = String::from_utf8_lossy(&buf[..header_end]); let status_line = response.lines().next().unwrap_or_default(); let status_code = status_line .split_whitespace() @@ -422,7 +517,9 @@ async fn connect_via_inner( target = %target, "upstream proxy CONNECT tunnel established" ); - Ok(stream) + buf.truncate(used); + let overflow = buf.split_off(header_end); + Ok(PrefixedStream::new(stream, overflow)) } Some(code) => Err(IoError::other(format!( "upstream proxy {} refused CONNECT to {target}: HTTP {code}", @@ -773,6 +870,43 @@ mod tests { assert!(request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n")); } + #[tokio::test] + async fn connect_via_preserves_payload_after_connect_response() { + // The proxy's 200 response and the first tunneled bytes can arrive + // in a single read; the suffix belongs to the destination stream and + // must be replayed, not discarded. + let (addr, _handle) = + fake_proxy("HTTP/1.1 200 Connection established\r\n\r\nserver-first-payload").await; + let endpoint = endpoint_for(addr, None); + let mut stream = connect_via(&endpoint, "api.example.com", 443) + .await + .unwrap(); + let mut payload = vec![0u8; "server-first-payload".len()]; + stream.read_exact(&mut payload).await.unwrap(); + assert_eq!(payload, b"server-first-payload"); + } + + #[tokio::test] + async fn prefixed_stream_replays_prefix_before_socket_bytes() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let connect = tokio::spawn(async move { TcpStream::connect(addr).await.unwrap() }); + let (mut accepted, _) = listener.accept().await.unwrap(); + let dialed = connect.await.unwrap(); + + accepted.write_all(b" from-socket").await.unwrap(); + let mut stream = PrefixedStream::new(dialed, b"from-prefix".to_vec()); + let mut out = vec![0u8; "from-prefix from-socket".len()]; + stream.read_exact(&mut out).await.unwrap(); + assert_eq!(out, b"from-prefix from-socket"); + + // Writes pass through to the socket untouched by the prefix. + stream.write_all(b"reply").await.unwrap(); + let mut reply = vec![0u8; 5]; + accepted.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply, b"reply"); + } + #[tokio::test] async fn connect_via_rejects_non_200() { let (addr, _handle) = From a1c8b9933f7244454d42451109f5d2997a2d806b Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 10:30:48 +0200 Subject: [PATCH 11/28] fix(sandbox,podman): gate cleartext proxy Basic auth behind an explicit opt-in Proxy-Authorization: Basic is base64 over the plain-TCP connection to the http:// corporate proxy, so anyone on the network path between the sandbox host and the proxy can recover the credential. Sending it is now an explicit operator decision instead of an implicit side effect of configuring proxy_auth_file. Add a proxy_auth_allow_insecure driver setting (CLI --sandbox-proxy-auth-allow-insecure, env OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE), delivered to the supervisor as the reserved OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE variable. Fail-closed pairing on both sides: an auth file without the acknowledgement is rejected at gateway startup and at supervisor startup, as is the acknowledgement without an auth file or any value other than 'true'. gateway.sh writes the key only as a TOML boolean; a non-boolean value is emitted as a quoted string so the gateway rejects it at startup instead of risking injection. Documents the exposure prominently in the gateway config reference, the driver README, and the sandbox architecture doc. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 10 ++ crates/openshell-core/src/sandbox_env.rs | 13 +++ crates/openshell-driver-podman/README.md | 9 +- crates/openshell-driver-podman/src/config.rs | 69 +++++++++++++- .../openshell-driver-podman/src/container.rs | 24 +++++ crates/openshell-driver-podman/src/main.rs | 7 ++ .../src/upstream_proxy.rs | 92 ++++++++++++++++++- docs/reference/gateway-config.mdx | 16 +++- tasks/scripts/gateway.sh | 12 +++ 9 files changed, 242 insertions(+), 10 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index dd137877fc..6d9451abd3 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -144,6 +144,16 @@ empty, contains control characters, or is not in `user:pass` form is fatal on both sides. The reserved proxy variables — including the auth-file path — are stripped from workload child processes. +The Basic header travels over the plain-TCP connection to the `http://` proxy, +so it is readable on the network path between sandbox host and proxy. +Configuring `proxy_auth_file` therefore requires the explicit opt-in +`proxy_auth_allow_insecure = true`, delivered to the supervisor as the +reserved `OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE` variable. Both the +driver (at sandbox-create time) and the supervisor (at startup) reject an +auth file without the acknowledgement, and the acknowledgement without an +auth file, so credentials are never sent in cleartext without an explicit +operator decision. + ## Credentials Provider credentials are stored at the gateway and fetched by the supervisor at diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 298f27fd92..0fdd8d536a 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -141,3 +141,16 @@ pub const UPSTREAM_NO_PROXY: &str = "OPENSHELL_UPSTREAM_NO_PROXY"; /// in container environment or metadata. Compute drivers write this in the /// required-variable tier; the value is a path, not a secret. pub const UPSTREAM_PROXY_AUTH_FILE: &str = "OPENSHELL_UPSTREAM_PROXY_AUTH_FILE"; + +/// Explicit operator acknowledgement (`true`) that the proxy credential is +/// sent as a cleartext `Proxy-Authorization: Basic` header over the plain-TCP +/// connection to the `http://` corporate proxy. +/// +/// Basic auth is base64, not encryption, so anyone on the network path +/// between the sandbox host and the proxy can recover the credential. The +/// supervisor refuses to send credentials without this acknowledgement: +/// [`UPSTREAM_PROXY_AUTH_FILE`] set without this variable equal to `true` is +/// a fatal startup error, as is this variable set without an auth file. +/// Compute drivers write it in the required-variable tier from the driver's +/// `proxy_auth_allow_insecure` setting. +pub const UPSTREAM_PROXY_AUTH_ALLOW_INSECURE: &str = "OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE"; diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index f41c4beddd..28da4ea28f 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -347,7 +347,8 @@ Podman resources after out-of-band container removal or label drift. | `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 for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://host:port` URLs are supported (scheme and port required). Plain-HTTP requests always dial directly. | | `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. | -| `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. | +| `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. Requires the insecure-auth acknowledgement below. | +| `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set; rejected when it is not. | Through the gateway, the same settings are the `https_proxy`, `no_proxy`, and `proxy_auth_file` keys under `[openshell.drivers.podman]`; see @@ -360,6 +361,12 @@ template environment cannot override it, and the conventional it. Credentials must be supplied through `proxy_auth_file`; an inline `user:pass@` in the URL is rejected at startup. +Basic auth over an `http://` proxy is cleartext on the wire: anyone on the +network path between the sandbox host and the proxy can recover the +credential. Setting `proxy_auth_file` therefore requires +`proxy_auth_allow_insecure = true`; both the driver and the in-container +supervisor reject credentials without that explicit acknowledgement. + ## Rootless-Specific Adaptations The Podman driver is designed for rootless operation. The following adaptations diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 82361a4a3a..3a19edb4c7 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -160,6 +160,16 @@ pub struct PodmanComputeConfig { /// container metadata. The gateway reads this file at sandbox-create time /// and delivers it to the supervisor through a root-only secret mount. pub proxy_auth_file: Option, + /// Explicit acknowledgement that proxy credentials are sent in cleartext. + /// + /// `Proxy-Authorization: Basic` is base64, not encryption, and the + /// connection to an `http://` corporate proxy is plain TCP, so anyone on + /// the network path between the sandbox host and the proxy can recover + /// the credential. Setting `proxy_auth_file` therefore requires + /// `proxy_auth_allow_insecure = true`; without it the configuration is + /// rejected at startup. Set it only when the path to the proxy is a + /// trusted network segment. + pub proxy_auth_allow_insecure: Option, } pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10; @@ -273,6 +283,24 @@ impl PodmanComputeConfig { "proxy_auth_file is set but no https_proxy is configured".to_string(), )); } + // Basic auth over the plain-TCP proxy connection is readable by + // anyone on the network path; sending it requires an explicit + // operator acknowledgement rather than being an implicit side + // effect of configuring credentials. + if self.proxy_auth_allow_insecure != Some(true) { + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_auth_file sends the credential as cleartext Basic auth over the \ + plain-TCP connection to the http:// proxy; set proxy_auth_allow_insecure \ + = true to accept that exposure, or remove proxy_auth_file" + .to_string(), + )); + } + } else if self.proxy_auth_allow_insecure.is_some() { + // The acknowledgement without credentials means the operator + // believed an auth file was configured; surface the mismatch. + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_auth_allow_insecure is set but no proxy_auth_file is configured".to_string(), + )); } Ok(()) } @@ -353,6 +381,7 @@ impl Default for PodmanComputeConfig { https_proxy: None, no_proxy: None, proxy_auth_file: None, + proxy_auth_allow_insecure: None, } } } @@ -383,6 +412,7 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("https_proxy", &self.https_proxy.is_some()) .field("no_proxy", &self.no_proxy) .field("proxy_auth_file", &self.proxy_auth_file.is_some()) + .field("proxy_auth_allow_insecure", &self.proxy_auth_allow_insecure) .finish() } } @@ -608,15 +638,52 @@ mod tests { } #[test] - fn validate_proxy_config_accepts_auth_file_with_proxy() { + fn validate_proxy_config_accepts_auth_file_with_proxy_and_acknowledgement() { let cfg = PodmanComputeConfig { https_proxy: Some("http://proxy.corp.com:8080".to_string()), proxy_auth_file: Some("/etc/openshell/secrets/proxy-auth".to_string()), + proxy_auth_allow_insecure: Some(true), ..PodmanComputeConfig::default() }; assert!(cfg.validate_proxy_config().is_ok()); } + #[test] + fn validate_proxy_config_rejects_auth_file_without_insecure_acknowledgement() { + // Basic auth over the plain-TCP proxy connection is readable on the + // network path; sending it must be an explicit operator decision. + for allow in [None, Some(false)] { + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + proxy_auth_file: Some("/etc/openshell/secrets/proxy-auth".to_string()), + proxy_auth_allow_insecure: allow, + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!( + err.to_string().contains("proxy_auth_allow_insecure"), + "{allow:?}: {err}" + ); + assert!(err.to_string().contains("cleartext"), "{allow:?}: {err}"); + } + } + + #[test] + fn validate_proxy_config_rejects_acknowledgement_without_auth_file() { + for allow in [Some(true), Some(false)] { + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + proxy_auth_allow_insecure: allow, + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!( + err.to_string().contains("no proxy_auth_file"), + "{allow:?}: {err}" + ); + } + } + #[test] fn validate_proxy_config_rejects_auth_file_without_proxy() { let cfg = PodmanComputeConfig { diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 91670c93c6..8bc3623b98 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -416,6 +416,13 @@ fn build_env( .proxy_auth_file .as_ref() .map(|_| UPSTREAM_PROXY_AUTH_MOUNT_PATH.to_string()); + // Config validation guarantees the acknowledgement is `true` whenever an + // auth file is configured; the supervisor independently refuses + // credentials without it. + let proxy_auth_allow_insecure = config + .proxy_auth_allow_insecure + .filter(|allowed| *allowed) + .map(|_| "true".to_string()); for (name, value) in [ ( openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, @@ -429,6 +436,10 @@ fn build_env( openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, &proxy_auth_mount, ), + ( + openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, + &proxy_auth_allow_insecure, + ), ] { env.remove(name); if let Some(value) = value { @@ -2548,6 +2559,7 @@ mod tests { let mut config = test_config(); config.https_proxy = Some("http://proxy.corp.com:8080".to_string()); config.proxy_auth_file = Some("/etc/openshell/secrets/proxy-auth".to_string()); + config.proxy_auth_allow_insecure = Some(true); let spec = build_container_spec(&sandbox, &config); let env_map = spec["env"].as_object().expect("env should be an object"); @@ -2559,6 +2571,14 @@ mod tests { .and_then(|v| v.as_str()), Some(UPSTREAM_PROXY_AUTH_MOUNT_PATH) ); + // The cleartext-credential acknowledgement travels with the auth + // file so the supervisor's fail-closed pairing check passes. + assert_eq!( + env_map + .get(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE) + .and_then(|v| v.as_str()), + Some("true") + ); // The URL carried in the environment must remain credential-free. assert_eq!( env_map @@ -2592,6 +2612,10 @@ mod tests { !env_map.contains_key(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE), "auth-file path must be absent when no proxy_auth_file is configured" ); + assert!( + !env_map.contains_key(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE), + "acknowledgement must be absent when no proxy_auth_file is configured" + ); } #[test] diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index b14490de17..bbceea5100 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -120,6 +120,12 @@ struct Args { /// mount so the credentials never appear in config or container metadata. #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_AUTH_FILE")] sandbox_proxy_auth_file: Option, + + /// Explicit acknowledgement (`true`) that the proxy credential is sent + /// as cleartext Basic auth over the plain-TCP connection to the http:// + /// proxy. Required when `--sandbox-proxy-auth-file` is set. + #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE")] + sandbox_proxy_auth_allow_insecure: Option, } #[tokio::main] @@ -157,6 +163,7 @@ async fn main() -> Result<()> { https_proxy: args.sandbox_https_proxy, no_proxy: args.sandbox_no_proxy, proxy_auth_file: args.sandbox_proxy_auth_file, + proxy_auth_allow_insecure: args.sandbox_proxy_auth_allow_insecure, ..PodmanComputeConfig::default() }) .await diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 909619ba1d..46ffdc6ddd 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -27,7 +27,8 @@ //! - Only `http://` proxy URLs are supported. Configuration is fail-closed: //! any present-but-invalid reserved value — a present-but-empty variable, //! an unsupported (`https://`, SOCKS) or malformed proxy URL, an unreadable -//! auth file, or a malformed credential — is a fatal startup error rather +//! auth file, a malformed credential, or an auth file without the explicit +//! cleartext-credential acknowledgement — is a fatal startup error rather //! than being silently ignored, so a typo can never quietly downgrade the //! operator's egress boundary to direct dialing or unauthenticated proxy //! access. Validation semantics are shared with the compute driver via @@ -196,8 +197,11 @@ impl UpstreamProxyConfig { /// any present-but-invalid value is fatal instead of being treated as /// unset: a present-but-empty (or whitespace-only) reserved variable, an /// invalid or unsupported proxy URL, an auth file that is set but - /// unreadable or holds a malformed credential, or an auth file or - /// `NO_PROXY` list with no proxy configured. Failing closed here + /// unreadable or holds a malformed credential, an auth file without the + /// explicit cleartext-credential acknowledgement + /// ([`UPSTREAM_PROXY_AUTH_ALLOW_INSECURE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE)), + /// or an auth file or `NO_PROXY` list with no proxy configured. Failing + /// closed here /// prevents a misconfiguration /// from silently degrading to direct dialing or unauthenticated proxy /// access. Only fully unset variables mean "no proxy". @@ -207,7 +211,8 @@ impl UpstreamProxyConfig { fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result, String> { use openshell_core::sandbox_env::{ - UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, UPSTREAM_PROXY_AUTH_FILE, + UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, + UPSTREAM_PROXY_AUTH_FILE, }; // Only a fully unset reserved variable means "not configured". A // present-but-empty value is a misconfiguration (the compute driver @@ -226,6 +231,7 @@ impl UpstreamProxyConfig { .map(|url| parse_proxy_url(&url, UPSTREAM_HTTPS_PROXY)) .transpose()?; let auth_file = var(UPSTREAM_PROXY_AUTH_FILE)?; + let auth_allow_insecure = var(UPSTREAM_PROXY_AUTH_ALLOW_INSECURE)?; let no_proxy_list = var(UPSTREAM_NO_PROXY)?; let Some(mut https) = https else { // Auxiliary proxy settings without a proxy mean the operator @@ -233,6 +239,7 @@ impl UpstreamProxyConfig { // silently running with direct egress. for (name, value) in [ (UPSTREAM_PROXY_AUTH_FILE, &auth_file), + (UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, &auth_allow_insecure), (UPSTREAM_NO_PROXY, &no_proxy_list), ] { if value.is_some() { @@ -242,6 +249,35 @@ impl UpstreamProxyConfig { return Ok(None); }; + // Cleartext-credential acknowledgement. `Proxy-Authorization: Basic` + // travels over plain TCP to the http:// proxy, so credentials are + // only sent when the operator explicitly opted in. The compute driver + // enforces the same pairing at sandbox-create time; enforcing it here + // as well keeps the supervisor fail-closed against a bypassed or + // foreign driver. + let allow_insecure = match auth_allow_insecure.as_deref().map(str::trim) { + None => false, + Some("true") => true, + Some(_) => { + return Err(format!( + "{UPSTREAM_PROXY_AUTH_ALLOW_INSECURE} must be 'true' when set" + )); + } + }; + if auth_file.is_none() && auth_allow_insecure.is_some() { + return Err(format!( + "{UPSTREAM_PROXY_AUTH_ALLOW_INSECURE} is set but no \ + {UPSTREAM_PROXY_AUTH_FILE} is configured" + )); + } + if auth_file.is_some() && !allow_insecure { + return Err(format!( + "{UPSTREAM_PROXY_AUTH_FILE} sends the credential as cleartext Basic auth \ + over the plain-TCP proxy connection; refusing without \ + {UPSTREAM_PROXY_AUTH_ALLOW_INSECURE}=true" + )); + } + // Load proxy credentials from the reserved auth file, if configured. // The file is delivered through a root-only secret mount so the // credentials never appear in the environment or container metadata. @@ -540,6 +576,7 @@ mod tests { use super::*; use openshell_core::sandbox_env::{ UPSTREAM_HTTPS_PROXY as HTTPS_PROXY, UPSTREAM_NO_PROXY as NO_PROXY, + UPSTREAM_PROXY_AUTH_ALLOW_INSECURE as PROXY_AUTH_ALLOW_INSECURE, UPSTREAM_PROXY_AUTH_FILE as PROXY_AUTH_FILE, }; @@ -589,6 +626,7 @@ mod tests { (HTTPS_PROXY, " "), (NO_PROXY, " "), (PROXY_AUTH_FILE, ""), + (PROXY_AUTH_ALLOW_INSECURE, ""), ] { let err = config_from(&[(name, value)]).unwrap_err(); assert!(err.contains(name), "{err}"); @@ -668,11 +706,49 @@ mod tests { let err = config_from(&[ (HTTPS_PROXY, "http://proxy.corp.com:8080"), (PROXY_AUTH_FILE, "/nonexistent/upstream-proxy-auth"), + (PROXY_AUTH_ALLOW_INSECURE, "true"), ]) .unwrap_err(); assert!(err.contains("auth file"), "{err}"); } + #[test] + fn auth_file_without_insecure_acknowledgement_is_fatal() { + // Basic auth over the plain-TCP proxy connection is readable on the + // network path; sending it requires the explicit opt-in. + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (PROXY_AUTH_FILE, "/etc/openshell/auth/upstream-proxy"), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_AUTH_ALLOW_INSECURE), "{err}"); + assert!(err.contains("cleartext"), "{err}"); + } + + #[test] + fn invalid_insecure_acknowledgement_value_is_fatal() { + for value in ["false", "yes", "1", "TRUE"] { + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (PROXY_AUTH_FILE, "/etc/openshell/auth/upstream-proxy"), + (PROXY_AUTH_ALLOW_INSECURE, value), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_AUTH_ALLOW_INSECURE), "{value}: {err}"); + } + } + + #[test] + fn insecure_acknowledgement_without_auth_file_is_fatal() { + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (PROXY_AUTH_ALLOW_INSECURE, "true"), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_AUTH_ALLOW_INSECURE), "{err}"); + assert!(err.contains(PROXY_AUTH_FILE), "{err}"); + } + #[test] fn malformed_auth_file_credential_is_fatal() { // Empty, header-injecting, and non-`user:pass` credentials are all @@ -684,6 +760,7 @@ mod tests { let err = config_from(&[ (HTTPS_PROXY, "http://proxy.corp.com:8080"), (PROXY_AUTH_FILE, &path), + (PROXY_AUTH_ALLOW_INSECURE, "true"), ]) .unwrap_err(); assert!(err.contains("auth file"), "{err}"); @@ -700,6 +777,7 @@ mod tests { // boundary the operator believed was in effect. for (name, value) in [ (PROXY_AUTH_FILE, "/etc/openshell/auth/upstream-proxy"), + (PROXY_AUTH_ALLOW_INSECURE, "true"), (NO_PROXY, "*.svc.cluster.local"), ] { let err = config_from(&[(name, value)]).unwrap_err(); @@ -713,7 +791,11 @@ mod tests { let file = tempfile::NamedTempFile::new().unwrap(); std::fs::write(file.path(), "user:secret\n").unwrap(); let path = file.path().to_str().unwrap().to_string(); - let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy:8080"), (PROXY_AUTH_FILE, &path)]); + let cfg = config_ok(&[ + (HTTPS_PROXY, "http://proxy:8080"), + (PROXY_AUTH_FILE, &path), + (PROXY_AUTH_ALLOW_INSECURE, "true"), + ]); let expected = format!( "Basic {}", base64::engine::general_purpose::STANDARD.encode("user:secret") diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index eb45926b5c..c6d7f36458 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -416,9 +416,19 @@ health_check_interval_secs = 10 # characters); the same validation runs at sandbox-create time and in the # supervisor, so a credential accepted here is never rejected in-container. # Keep gateway.toml and the auth file owner-readable only (mode 0600). -# https_proxy = "http://proxy.corp.com:8080" -# no_proxy = "*.svc.cluster.local,10.0.0.0/8" -# proxy_auth_file = "/etc/openshell/secrets/proxy-auth" +# +# WARNING: the supervisor sends the credential as a Proxy-Authorization: +# Basic header over the plain-TCP connection to the http:// proxy. Basic +# auth is base64, not encryption: anyone on the network path between the +# sandbox host and the proxy can recover the credential. Because of that, +# proxy_auth_file requires the explicit acknowledgement +# proxy_auth_allow_insecure = true; without it the configuration is +# rejected at gateway startup. Only opt in when the path to the proxy is a +# trusted network segment. +# https_proxy = "http://proxy.corp.com:8080" +# no_proxy = "*.svc.cluster.local,10.0.0.0/8" +# proxy_auth_file = "/etc/openshell/secrets/proxy-auth" +# proxy_auth_allow_insecure = true ``` ### MicroVM diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 196b54e06b..bc059af768 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -381,6 +381,18 @@ EOF if [[ -n "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE+x}" ]]; then printf 'proxy_auth_file = "%s"\n' "$(toml_escape "${OPENSHELL_SANDBOX_PROXY_AUTH_FILE}")" >>"${CONFIG_PATH}" fi + if [[ -n "${OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE+x}" ]]; then + case "${OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE}" in + true|false) + printf 'proxy_auth_allow_insecure = %s\n' "${OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE}" >>"${CONFIG_PATH}" + ;; + *) + # Not a TOML boolean: write it as a quoted string so the gateway's + # config parser rejects it at startup (fail closed, no injection). + printf 'proxy_auth_allow_insecure = "%s"\n' "$(toml_escape "${OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE}")" >>"${CONFIG_PATH}" + ;; + esac + fi ;; esac From f86d73c42794ba9e662459eb5f8cdf3736465894 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 10:48:10 +0200 Subject: [PATCH 12/28] fix(sandbox,podman): honor port qualifiers and resolved addresses in NO_PROXY Two divergences from the documented NO_PROXY contract: - A port-qualified entry (internal.corp:8443) was silently stripped to its hostname and bypassed the proxy for every port, excluding traffic the operator never listed. Entries now keep the optional :port qualifier (also on IP and CIDR entries) and only apply to that destination port; a trailing qualifier that is not a valid port stays part of the pattern instead of widening the entry. - IP and CIDR entries only matched IP-literal hosts, so a bypass like 10.0.0.0/8 never applied to hostnames resolving into that range. NO_PROXY evaluation now sees the validated resolved addresses: an IP/CIDR entry matching through resolution authorizes a direct dial of only the addresses it contains, so a bypass scoped to an internal range cannot widen into a direct dial of addresses outside it. Hostname-level matches (loopback, wildcard, domain entries, IP-literal hosts) keep authorizing all validated addresses. proxy_for is replaced by decision(host, port, resolved) returning either the proxy endpoint or the permitted direct-dial subset, and dial_upstream restricts the direct connect to that subset. Adds regression coverage for port-scoped bypasses on domain, IP, and CIDR entries, invalid port qualifiers, resolved-address matching, and split-resolution subset dialing. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 5 +- crates/openshell-driver-podman/README.md | 2 +- crates/openshell-driver-podman/src/config.rs | 5 +- .../openshell-supervisor-network/src/proxy.rs | 21 +- .../src/upstream_proxy.rs | 303 ++++++++++++++---- docs/reference/gateway-config.mdx | 8 +- 6 files changed, 277 insertions(+), 67 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 6d9451abd3..c9f188590c 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -116,7 +116,10 @@ The conventional `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox controls are ignored on this path. Reserved `NO_PROXY` destinations and loopback always dial directly; add driver-injected host aliases (e.g. `host.containers.internal`) to the reserved `NO_PROXY` list when the corporate -proxy cannot reach the container host. Only `http://` proxy URLs in explicit +proxy cannot reach the container host. `NO_PROXY` matching is port-aware and +resolution-aware: an entry with a `:port` qualifier only bypasses that port, +and IP/CIDR entries also match hostnames through their validated resolved +addresses, with the direct dial limited to the addresses the entry contains. Only `http://` proxy URLs in explicit `http://host:port` form are supported — the scheme and port are both required, and a path, query, or fragment is rejected. Local DNS resolution and SSRF validation still run before the proxied dial; the CONNECT target sent to the corporate proxy is the requested diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 28da4ea28f..e9aed97a5b 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -346,7 +346,7 @@ Podman resources after out-of-band container removal or label drift. | `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 for the supervisor's upstream TLS dials, chained with HTTP CONNECT. Only credential-free `http://host:port` URLs are supported (scheme and port required). Plain-HTTP requests always dial directly. | -| `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. | +| `OPENSHELL_SANDBOX_NO_PROXY` | `--sandbox-no-proxy` | unset | Comma-separated `NO_PROXY` list (hostnames, domain suffixes, IPs, CIDRs, each with an optional `:port` qualifier) dialed directly instead of through the corporate proxy. IP/CIDR entries also match hostnames through their validated DNS resolution. | | `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. Requires the insecure-auth acknowledgement below. | | `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set; rejected when it is not. | diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 3a19edb4c7..9c2a625144 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -149,7 +149,10 @@ pub struct PodmanComputeConfig { /// Comma-separated `NO_PROXY` list injected as the reserved /// `OPENSHELL_UPSTREAM_NO_PROXY` variable (e.g. /// `*.svc.cluster.local,10.0.0.0/8`). Destinations matching an entry are - /// dialed directly instead of through the corporate proxy. + /// dialed directly instead of through the corporate proxy. Entries take + /// an optional `:port` qualifier that limits them to that destination + /// port, and IP/CIDR entries also match hostnames through their + /// validated DNS resolution. pub no_proxy: Option, /// Path (on the gateway host) to a file containing the corporate proxy /// credentials as `user:pass`. diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index ccad578da6..d43358a9a3 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2950,6 +2950,11 @@ fn validate_declared_endpoint_resolved_addrs( /// changes. Plain-HTTP requests never take this path: they always dial the /// destination directly. /// +/// `NO_PROXY` evaluation is port-aware and sees the validated resolved +/// addresses: an entry with a `:port` qualifier only bypasses that port, +/// and an IP/CIDR entry that matches through resolution limits the direct +/// dial to the addresses it contains. +/// /// 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. @@ -2963,11 +2968,17 @@ async fn dial_upstream( port: u16, addrs: &[SocketAddr], ) -> std::io::Result { - if let Some(endpoint) = upstream_proxy - .as_ref() - .and_then(|cfg| cfg.proxy_for(host_lc)) - { - return upstream_proxy::connect_via(endpoint, host_lc, port).await; + if let Some(cfg) = upstream_proxy.as_ref() { + return match cfg.decision(host_lc, port, addrs) { + upstream_proxy::ProxyDecision::Proxy(endpoint) => { + upstream_proxy::connect_via(endpoint, host_lc, port).await + } + upstream_proxy::ProxyDecision::Direct(direct_addrs) => { + Ok(upstream_proxy::PrefixedStream::without_prefix( + TcpStream::connect(&direct_addrs[..]).await?, + )) + } + }; } Ok(upstream_proxy::PrefixedStream::without_prefix( TcpStream::connect(addrs).await?, diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 46ffdc6ddd..3743392e9c 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -41,7 +41,7 @@ //! host gateway, etc.). Loopback destinations always bypass the proxy. use std::io::{Error as IoError, ErrorKind}; -use std::net::IpAddr; +use std::net::{IpAddr, SocketAddr}; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; @@ -86,19 +86,28 @@ impl std::fmt::Debug for ProxyEndpoint { } } -/// One parsed `NO_PROXY` entry. +/// The pattern half of one parsed `NO_PROXY` entry. #[derive(Debug, Clone)] -enum NoProxyEntry { +enum NoProxyPattern { /// `*` — 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. + /// Exact IP match, against an IP-literal host or its resolved addresses. Ip(IpAddr), - /// CIDR match against IP-literal destination hosts. + /// CIDR match, against an IP-literal host or its resolved addresses. Cidr(ipnet::IpNet), } +/// One parsed `NO_PROXY` entry. +#[derive(Debug, Clone)] +struct NoProxyEntry { + pattern: NoProxyPattern, + /// When set (`internal.corp:8443`), the entry only applies to this + /// destination port instead of every port. + port: Option, +} + /// Parsed `NO_PROXY` list. #[derive(Debug, Clone, Default)] struct NoProxy { @@ -114,60 +123,139 @@ impl NoProxy { continue; } if item == "*" { - entries.push(NoProxyEntry::Wildcard); + entries.push(NoProxyEntry { + pattern: NoProxyPattern::Wildcard, + port: None, + }); continue; } + // Whole-item IP/CIDR forms first: a bare IPv6 literal contains + // colons that must never be misread as a port qualifier. if let Ok(net) = item.parse::() { - entries.push(NoProxyEntry::Cidr(net)); + entries.push(NoProxyEntry { + pattern: NoProxyPattern::Cidr(net), + port: None, + }); continue; } if let Ok(ip) = item.trim_matches(['[', ']']).parse::() { - entries.push(NoProxyEntry::Ip(ip)); + entries.push(NoProxyEntry { + pattern: NoProxyPattern::Ip(ip), + port: None, + }); 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 + // Optional `:port` qualifier: only a valid trailing u16 counts; + // anything else stays part of the pattern. + let (head, port) = item + .rsplit_once(':') + .map_or((item, None), |(head, port_str)| { + port_str + .parse::() + .map_or((item, None), |port| (head, Some(port))) + }); + let pattern = if let Ok(net) = head.parse::() { + NoProxyPattern::Cidr(net) + } else if let Ok(ip) = head.trim_matches(['[', ']']).parse::() { + NoProxyPattern::Ip(ip) + } else { + // Domain entry. Strip any leading `*.` or `.` so + // `.corp.com`, `*.corp.com`, and `corp.com` all behave + // identically. + let name = head + .strip_prefix("*.") + .or_else(|| head.strip_prefix('.')) + .unwrap_or(head) + .to_ascii_lowercase(); + if name.is_empty() { + continue; } - }); - 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)); - } + NoProxyPattern::Domain(name) + }; + entries.push(NoProxyEntry { pattern, port }); } Self { entries } } - /// Whether `host` (lowercase hostname or IP literal) must bypass the - /// corporate proxy. Loopback destinations always match. - fn matches(&self, host: &str) -> bool { + /// The validated addresses that may be dialed directly for + /// `(host, port)`, or `None` when no entry matches and the corporate + /// proxy must be used. + /// + /// Hostname-level matches — loopback, `*`, a domain entry, or an IP/CIDR + /// entry matching an IP-literal host — authorize every validated + /// address. IP/CIDR entries match hostnames through their *resolved* + /// addresses and authorize only the addresses they contain, so a bypass + /// scoped to an internal range can never widen into a direct dial of an + /// address outside that range. + fn direct_addrs( + &self, + host: &str, + port: u16, + resolved: &[SocketAddr], + ) -> Option> { let host_ip = host.trim_matches(['[', ']']).parse::().ok(); if host == "localhost" || host_ip.is_some_and(|ip| ip.is_loopback()) { - return true; + return Some(resolved.to_vec()); } - 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('.')) + let mut subset: Vec = Vec::new(); + for entry in &self.entries { + if entry.port.is_some_and(|entry_port| entry_port != port) { + continue; } - NoProxyEntry::Ip(ip) => host_ip == Some(*ip), - NoProxyEntry::Cidr(net) => host_ip.is_some_and(|ip| net.contains(&ip)), - }) + match &entry.pattern { + NoProxyPattern::Wildcard => return Some(resolved.to_vec()), + NoProxyPattern::Domain(suffix) => { + if host == suffix + || host + .strip_suffix(suffix) + .is_some_and(|prefix| prefix.ends_with('.')) + { + return Some(resolved.to_vec()); + } + } + NoProxyPattern::Ip(ip) => { + if host_ip == Some(*ip) { + return Some(resolved.to_vec()); + } + for addr in resolved { + if addr.ip() == *ip && !subset.contains(addr) { + subset.push(*addr); + } + } + } + NoProxyPattern::Cidr(net) => { + if host_ip.is_some_and(|ip| net.contains(&ip)) { + return Some(resolved.to_vec()); + } + for addr in resolved { + if net.contains(&addr.ip()) && !subset.contains(addr) { + subset.push(*addr); + } + } + } + } + } + if subset.is_empty() { + None + } else { + Some(subset) + } } } +/// How a validated destination must be dialed, per the reserved `NO_PROXY` +/// contract. Produced by [`UpstreamProxyConfig::decision`]. +#[derive(Debug)] +pub enum ProxyDecision<'a> { + /// Chain through the corporate proxy with HTTP CONNECT. + Proxy(&'a ProxyEndpoint), + /// Dial directly, restricted to this subset of the validated addresses: + /// all of them for hostname-level matches (loopback, `*`, domain + /// entries, IP-literal hosts), only the addresses contained in the + /// matching IP/CIDR entries otherwise. + Direct(Vec), +} + /// Corporate proxy configuration read from the supervisor's environment. #[derive(Debug, Clone)] pub struct UpstreamProxyConfig { @@ -297,14 +385,24 @@ impl UpstreamProxyConfig { })) } - /// The corporate proxy to use for a TLS tunnel to `host`, or `None` when - /// the destination must be dialed directly. + /// How to dial the validated destination `(host, port, resolved)`, + /// honoring the reserved `NO_PROXY` list. + /// + /// Entries may carry a `:port` qualifier that limits them to that + /// destination port. IP/CIDR entries also match a hostname through its + /// already-validated resolved addresses; such a match authorizes a + /// direct dial of only the addresses inside the entry (see + /// [`ProxyDecision::Direct`]). #[must_use] - pub fn proxy_for(&self, host: &str) -> Option<&ProxyEndpoint> { - if self.no_proxy.matches(host) { - return None; - } - Some(&self.https) + pub fn decision<'a>( + &'a self, + host: &str, + port: u16, + resolved: &[SocketAddr], + ) -> ProxyDecision<'a> { + self.no_proxy + .direct_addrs(host, port, resolved) + .map_or(ProxyDecision::Proxy(&self.https), ProxyDecision::Direct) } /// Credential-free summary for startup logging. @@ -634,10 +732,19 @@ mod tests { } } + /// Shorthand: the proxy endpoint chosen for `host:443` with no resolved + /// addresses in play, or `None` when the destination dials directly. + fn proxy_endpoint<'a>(cfg: &'a UpstreamProxyConfig, host: &str) -> Option<&'a ProxyEndpoint> { + match cfg.decision(host, 443, &[]) { + ProxyDecision::Proxy(ep) => Some(ep), + ProxyDecision::Direct(_) => None, + } + } + #[test] fn https_proxy_parsed_with_port() { let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy.corp.com:8080")]); - let ep = cfg.proxy_for("api.stripe.com").unwrap(); + let ep = proxy_endpoint(&cfg, "api.stripe.com").unwrap(); assert_eq!(ep.display_addr(), "proxy.corp.com:8080"); assert!(ep.proxy_authorization.is_none()); } @@ -800,7 +907,7 @@ mod tests { "Basic {}", base64::engine::general_purpose::STANDARD.encode("user:secret") ); - let ep = cfg.proxy_for("example.com").unwrap(); + let ep = proxy_endpoint(&cfg, "example.com").unwrap(); assert_eq!(ep.proxy_authorization.as_deref(), Some(expected.as_str())); } @@ -832,7 +939,7 @@ mod tests { #[test] fn ipv6_proxy_address_parses() { let cfg = config_ok(&[(HTTPS_PROXY, "http://[fd00::1]:8080")]); - let ep = cfg.proxy_for("example.com").unwrap(); + let ep = proxy_endpoint(&cfg, "example.com").unwrap(); assert_eq!(ep.display_addr(), "fd00::1:8080"); } @@ -842,8 +949,18 @@ mod tests { config_ok(&[(HTTPS_PROXY, "http://proxy:8080"), (NO_PROXY, no_proxy)]) } + /// Hostname-level bypass check at an arbitrary port with no resolved + /// addresses in play. fn bypasses(cfg: &UpstreamProxyConfig, host: &str) -> bool { - cfg.proxy_for(host).is_none() + bypasses_port(cfg, host, 443) + } + + fn bypasses_port(cfg: &UpstreamProxyConfig, host: &str, port: u16) -> bool { + matches!(cfg.decision(host, port, &[]), ProxyDecision::Direct(_)) + } + + fn sock(ip: &str, port: u16) -> SocketAddr { + SocketAddr::new(ip.parse().unwrap(), port) } #[test] @@ -890,10 +1007,84 @@ mod tests { } #[test] - fn no_proxy_ignores_port_qualifiers() { + fn no_proxy_port_qualifier_limits_bypass_to_that_port() { + // `internal.corp:8443` documents a bypass for one port; broadening + // it to every port would bypass the proxy for traffic the operator + // never excluded. let cfg = no_proxy_cfg("internal.corp:8443"); - assert!(bypasses(&cfg, "internal.corp")); - assert!(bypasses(&cfg, "svc.internal.corp")); + assert!(bypasses_port(&cfg, "internal.corp", 8443)); + assert!(bypasses_port(&cfg, "svc.internal.corp", 8443)); + assert!(!bypasses_port(&cfg, "internal.corp", 443)); + assert!(!bypasses_port(&cfg, "svc.internal.corp", 80)); + } + + #[test] + fn no_proxy_port_qualifier_applies_to_ip_and_cidr_entries() { + let cfg = no_proxy_cfg("192.168.1.5:8443,10.96.0.0/12:6443"); + assert!(bypasses_port(&cfg, "192.168.1.5", 8443)); + assert!(!bypasses_port(&cfg, "192.168.1.5", 443)); + assert!(bypasses_port(&cfg, "10.96.0.1", 6443)); + assert!(!bypasses_port(&cfg, "10.96.0.1", 443)); + } + + #[test] + fn no_proxy_invalid_port_qualifier_is_not_stripped() { + // A trailing qualifier that is not a valid port stays part of the + // pattern instead of silently widening the entry to every port. + let cfg = no_proxy_cfg("internal.corp:99999"); + assert!(!bypasses(&cfg, "internal.corp")); + } + + #[test] + fn no_proxy_cidr_matches_resolved_addresses_of_hostnames() { + // The operator's `10.96.0.0/12` bypass covers cluster-internal + // destinations however they are named; a hostname whose validated + // resolution lands in the range must dial directly. + let cfg = no_proxy_cfg("10.96.0.0/12"); + let inside = sock("10.96.0.7", 443); + match cfg.decision("svc.internal", 443, &[inside]) { + ProxyDecision::Direct(addrs) => assert_eq!(addrs, vec![inside]), + ProxyDecision::Proxy(ep) => panic!("expected direct dial, got proxy {ep:?}"), + } + // Resolution outside the range keeps the proxy. + assert!(matches!( + cfg.decision("example.com", 443, &[sock("93.184.216.34", 443)]), + ProxyDecision::Proxy(_) + )); + } + + #[test] + fn no_proxy_resolved_address_match_limits_direct_dial_to_matching_addrs() { + // Split resolution: only the addresses inside the bypassed range may + // be dialed directly; the others are not covered by the operator's + // exclusion and must not ride along. + let cfg = no_proxy_cfg("10.96.0.0/12"); + let inside = sock("10.96.0.7", 443); + let outside = sock("93.184.216.34", 443); + match cfg.decision("svc.internal", 443, &[outside, inside]) { + ProxyDecision::Direct(addrs) => assert_eq!(addrs, vec![inside]), + ProxyDecision::Proxy(ep) => panic!("expected direct dial, got proxy {ep:?}"), + } + } + + #[test] + fn no_proxy_ip_entry_matches_resolved_addresses() { + let cfg = no_proxy_cfg("10.0.0.5"); + let matching = sock("10.0.0.5", 443); + match cfg.decision("db.internal", 443, &[matching, sock("10.0.0.6", 443)]) { + ProxyDecision::Direct(addrs) => assert_eq!(addrs, vec![matching]), + ProxyDecision::Proxy(ep) => panic!("expected direct dial, got proxy {ep:?}"), + } + } + + #[test] + fn no_proxy_hostname_match_authorizes_all_resolved_addresses() { + let cfg = no_proxy_cfg("internal.corp"); + let addrs = [sock("10.0.0.5", 443), sock("93.184.216.34", 443)]; + match cfg.decision("internal.corp", 443, &addrs) { + ProxyDecision::Direct(direct) => assert_eq!(direct, addrs.to_vec()), + ProxyDecision::Proxy(ep) => panic!("expected direct dial, got proxy {ep:?}"), + } } #[test] @@ -908,9 +1099,7 @@ mod tests { // -- CONNECT handshake -- - async fn fake_proxy( - response: &'static str, - ) -> (std::net::SocketAddr, tokio::task::JoinHandle) { + async fn fake_proxy(response: &'static str) -> (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 { @@ -930,7 +1119,7 @@ mod tests { (addr, handle) } - fn endpoint_for(addr: std::net::SocketAddr, auth: Option<&str>) -> ProxyEndpoint { + fn endpoint_for(addr: SocketAddr, auth: Option<&str>) -> ProxyEndpoint { ProxyEndpoint { host: addr.ip().to_string(), port: addr.port(), diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index c6d7f36458..6b037a9c47 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -396,8 +396,12 @@ health_check_interval_secs = 10 # URLs in explicit http://host:port form are supported: the scheme and port # are both required, and a URL carrying a path, query, or fragment is # rejected rather than silently truncated. -# NO_PROXY entries (hostnames, domain suffixes, IPs, CIDRs) are dialed -# directly. This is an operator-owned egress boundary: +# NO_PROXY entries (hostnames, domain suffixes, IPs, CIDRs, each with an +# optional :port qualifier) are dialed directly. A port-qualified entry only +# bypasses that destination port. IP and CIDR entries also match hostnames +# through their validated DNS resolution; such a match dials directly only +# the resolved addresses inside the entry. This is an operator-owned egress +# boundary: # sandbox and template environment cannot override it, and the conventional # HTTPS_PROXY/HTTP_PROXY/NO_PROXY variables a sandbox sets do not affect it. # From 5617bcf6110e5d5a6832376f12c41894fad2747b Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 11:24:24 +0200 Subject: [PATCH 13/28] fix(sandbox,podman): bind proxied CONNECT tunnels to validated addresses The CONNECT request sent to the corporate proxy carried the destination hostname, so the proxy resolved the name itself and the addresses that had passed SSRF and allowed_ips validation were discarded. Split-horizon DNS or rebinding at the proxy could then reach internal or otherwise unapproved destinations through a tunnel the supervisor logged as validated, and IP-range policy could not be enforced at all on proxied dials. CONNECT now targets a validated resolved address by default: the proxy performs no DNS resolution and the tunnel stays bound to the answer the supervisor checked. The hostname still travels inside the tunnel (TLS SNI, application Host), so destination servers behave normally. In split-horizon networks, operators point the gateway host at the corporate resolver so internal names validate to their internal addresses. For proxies whose ACLs filter on hostnames and reject IP CONNECT targets, a new proxy_connect_by_hostname opt-in (CLI --sandbox-proxy-connect-by-hostname, env OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME, reserved OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME) restores hostname CONNECT, documented as re-opening proxy-side resolution and making the proxy's ACLs the effective egress control. Fail-closed pairing on both sides: the opt-in without a proxy, or any value other than 'true', is fatal. Adds regression coverage for the IP CONNECT request line (including IPv6 bracketing and hostname non-leakage), the hostname opt-in, and the config pairing rules in driver and supervisor. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 19 +- crates/openshell-core/src/sandbox_env.rs | 14 ++ crates/openshell-driver-podman/README.md | 10 + crates/openshell-driver-podman/src/config.rs | 46 +++++ .../openshell-driver-podman/src/container.rs | 36 ++++ crates/openshell-driver-podman/src/main.rs | 8 + .../openshell-supervisor-network/src/proxy.rs | 25 ++- .../src/upstream_proxy.rs | 178 +++++++++++++++--- docs/reference/gateway-config.mdx | 26 ++- tasks/scripts/gateway.sh | 12 ++ 10 files changed, 338 insertions(+), 36 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index c9f188590c..37cf82bd01 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -121,10 +121,21 @@ resolution-aware: an entry with a `:port` qualifier only bypasses that port, and IP/CIDR entries also match hostnames through their validated resolved addresses, with the direct dial limited to the addresses the entry contains. Only `http://` proxy URLs in explicit `http://host:port` form are supported — the scheme and port are both -required, and a path, query, or fragment is rejected. 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. +required, and a path, query, or fragment is rejected. Local DNS resolution +and SSRF validation still run before the proxied dial, and the CONNECT +target sent to the corporate proxy is a validated resolved address, so the +proxy performs no DNS resolution of its own and the tunnel stays bound to +the answer that passed SSRF and `allowed_ips` validation. The hostname still +travels inside the tunnel (TLS SNI, application `Host`). In split-horizon +networks, point the gateway host at the corporate resolver so internal names +validate to their internal addresses; the `proxy_connect_by_hostname` +opt-in (reserved `OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME`) exists as a +last resort for proxies whose ACLs filter on hostnames and reject IP CONNECT +targets — with it, the proxy resolves the name itself and its ACLs become +the effective egress control for proxied TLS. (Resolving through the proxy's +own DNS view, e.g. DoH tunneled via CONNECT, is a possible future +enhancement and out of scope.) The workload child's proxy variables are +unaffected — they are always rewritten to point at the local policy proxy. The configuration is fail-closed: a reserved variable that is present but invalid — a present-but-empty value, an unsupported or malformed proxy URL, an diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 0fdd8d536a..03b5efc935 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -154,3 +154,17 @@ pub const UPSTREAM_PROXY_AUTH_FILE: &str = "OPENSHELL_UPSTREAM_PROXY_AUTH_FILE"; /// Compute drivers write it in the required-variable tier from the driver's /// `proxy_auth_allow_insecure` setting. pub const UPSTREAM_PROXY_AUTH_ALLOW_INSECURE: &str = "OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE"; + +/// Explicit operator opt-out (`true`) that sends the destination *hostname* +/// in the CONNECT request to the corporate proxy instead of a validated IP. +/// +/// By default the supervisor CONNECTs to an address that already passed +/// SSRF and `allowed_ips` validation, so the proxy performs no DNS +/// resolution and the connection is bound to the validated answer. With +/// this opt-out the proxy resolves the name itself: hostname-filtering +/// proxy ACLs keep working, but a name whose resolution differs at the +/// proxy (split-horizon DNS, rebinding) can reach destinations the sandbox +/// policy never approved — the proxy's own ACLs become the effective +/// egress control. Compute drivers write it in the required-variable tier +/// from the driver's `proxy_connect_by_hostname` setting. +pub const UPSTREAM_PROXY_CONNECT_BY_HOSTNAME: &str = "OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME"; diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index e9aed97a5b..f99c7e3939 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -349,6 +349,7 @@ Podman resources after out-of-band container removal or label drift. | `OPENSHELL_SANDBOX_NO_PROXY` | `--sandbox-no-proxy` | unset | Comma-separated `NO_PROXY` list (hostnames, domain suffixes, IPs, CIDRs, each with an optional `:port` qualifier) dialed directly instead of through the corporate proxy. IP/CIDR entries also match hostnames through their validated DNS resolution. | | `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. Requires the insecure-auth acknowledgement below. | | `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set; rejected when it is not. | +| `OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME` | `--sandbox-proxy-connect-by-hostname` | unset | Send the destination hostname in CONNECT requests instead of a validated IP. Last resort for proxies whose ACLs filter on hostnames: the proxy then resolves the name itself, so sandbox SSRF/`allowed_ips` validation no longer binds the connection. | Through the gateway, the same settings are the `https_proxy`, `no_proxy`, and `proxy_auth_file` keys under `[openshell.drivers.podman]`; see @@ -367,6 +368,15 @@ credential. Setting `proxy_auth_file` therefore requires `proxy_auth_allow_insecure = true`; both the driver and the in-container supervisor reject credentials without that explicit acknowledgement. +CONNECT requests target a validated resolved IP by default, so the proxy +performs no DNS resolution and the tunnel stays bound to the address that +passed the sandbox's SSRF and `allowed_ips` checks; the hostname still +travels inside the tunnel (TLS SNI, application `Host`). In split-horizon +networks, point the gateway host at the corporate resolver. Set +`proxy_connect_by_hostname = true` only when the proxy's ACLs filter on +hostnames and reject IP CONNECT targets — it re-opens proxy-side DNS +resolution, making the proxy's ACLs the effective egress control. + ## Rootless-Specific Adaptations The Podman driver is designed for rootless operation. The following adaptations diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 9c2a625144..ee148d0c67 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -173,6 +173,20 @@ pub struct PodmanComputeConfig { /// rejected at startup. Set it only when the path to the proxy is a /// trusted network segment. pub proxy_auth_allow_insecure: Option, + /// Send the destination *hostname* in CONNECT requests to the corporate + /// proxy instead of a validated IP. + /// + /// By default the supervisor CONNECTs to an address that already passed + /// SSRF and `allowed_ips` validation, so the proxy performs no DNS + /// resolution and the tunnel stays bound to the validated answer. Set + /// this to `true` only when the proxy's ACLs filter on hostnames and + /// reject IP CONNECT targets: the proxy then resolves the name itself, + /// so a name that resolves differently at the proxy (split-horizon DNS, + /// rebinding) can reach destinations the sandbox policy never approved, + /// and the proxy's own ACLs become the effective egress control. Prefer + /// pointing the gateway host at the corporate resolver so validated-IP + /// CONNECT works in split-horizon networks. + pub proxy_connect_by_hostname: Option, } pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10; @@ -305,6 +319,14 @@ impl PodmanComputeConfig { "proxy_auth_allow_insecure is set but no proxy_auth_file is configured".to_string(), )); } + + // The CONNECT-target mode only means something relative to a proxy + // boundary the operator believed was in effect. + if self.proxy_connect_by_hostname.is_some() && self.https_proxy.is_none() { + return Err(crate::client::PodmanApiError::InvalidInput( + "proxy_connect_by_hostname is set but no https_proxy is configured".to_string(), + )); + } Ok(()) } @@ -385,6 +407,7 @@ impl Default for PodmanComputeConfig { no_proxy: None, proxy_auth_file: None, proxy_auth_allow_insecure: None, + proxy_connect_by_hostname: None, } } } @@ -416,6 +439,7 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("no_proxy", &self.no_proxy) .field("proxy_auth_file", &self.proxy_auth_file.is_some()) .field("proxy_auth_allow_insecure", &self.proxy_auth_allow_insecure) + .field("proxy_connect_by_hostname", &self.proxy_connect_by_hostname) .finish() } } @@ -671,6 +695,28 @@ mod tests { } } + #[test] + fn validate_proxy_config_accepts_connect_by_hostname_with_proxy() { + for by_hostname in [Some(true), Some(false)] { + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + proxy_connect_by_hostname: by_hostname, + ..PodmanComputeConfig::default() + }; + assert!(cfg.validate_proxy_config().is_ok(), "{by_hostname:?}"); + } + } + + #[test] + fn validate_proxy_config_rejects_connect_by_hostname_without_proxy() { + let cfg = PodmanComputeConfig { + proxy_connect_by_hostname: Some(true), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("no https_proxy"), "{err}"); + } + #[test] fn validate_proxy_config_rejects_acknowledgement_without_auth_file() { for allow in [Some(true), Some(false)] { diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 8bc3623b98..c8304ec929 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -423,6 +423,12 @@ fn build_env( .proxy_auth_allow_insecure .filter(|allowed| *allowed) .map(|_| "true".to_string()); + // Absent means the default validated-IP CONNECT binding; only the + // explicit hostname opt-in is written through. + let proxy_connect_by_hostname = config + .proxy_connect_by_hostname + .filter(|by_hostname| *by_hostname) + .map(|_| "true".to_string()); for (name, value) in [ ( openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, @@ -440,6 +446,10 @@ fn build_env( openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, &proxy_auth_allow_insecure, ), + ( + openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, + &proxy_connect_by_hostname, + ), ] { env.remove(name); if let Some(value) = value { @@ -2618,6 +2628,32 @@ mod tests { ); } + #[test] + fn container_spec_connect_by_hostname_written_only_on_opt_in() { + let sandbox = test_sandbox("proxy-id", "proxy-name"); + let mut config = test_config(); + config.https_proxy = Some("http://proxy.corp.com:8080".to_string()); + + // Default: no reserved variable, the supervisor uses validated-IP + // CONNECT binding. + let spec = build_container_spec(&sandbox, &config); + let env_map = spec["env"].as_object().expect("env should be an object"); + assert!( + !env_map.contains_key(openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME), + "hostname CONNECT must be absent without the operator opt-in" + ); + + config.proxy_connect_by_hostname = Some(true); + 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(openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME) + .and_then(|v| v.as_str()), + Some("true") + ); + } + #[test] fn container_spec_omits_tls_without_config() { let sandbox = test_sandbox("notls-id", "notls-name"); diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index bbceea5100..ba15fd2556 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -126,6 +126,13 @@ struct Args { /// proxy. Required when `--sandbox-proxy-auth-file` is set. #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE")] sandbox_proxy_auth_allow_insecure: Option, + + /// Send the destination hostname in CONNECT requests to the corporate + /// proxy instead of a validated IP. Only for proxies whose ACLs filter + /// on hostnames: the proxy then resolves the name itself, so sandbox + /// SSRF/`allowed_ips` validation no longer binds the connection. + #[arg(long, env = "OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME")] + sandbox_proxy_connect_by_hostname: Option, } #[tokio::main] @@ -164,6 +171,7 @@ async fn main() -> Result<()> { no_proxy: args.sandbox_no_proxy, proxy_auth_file: args.sandbox_proxy_auth_file, proxy_auth_allow_insecure: args.sandbox_proxy_auth_allow_insecure, + proxy_connect_by_hostname: args.sandbox_proxy_connect_by_hostname, ..PodmanComputeConfig::default() }) .await diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index d43358a9a3..0bc636ad0e 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2955,9 +2955,13 @@ fn validate_declared_endpoint_resolved_addrs( /// and an IP/CIDR entry that matches through resolution limits the direct /// dial to the addresses it contains. /// -/// 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. +/// The CONNECT target sent to the corporate proxy is a validated resolved +/// address, so the proxy performs no DNS resolution of its own and the +/// tunnel stays bound to the answer that passed SSRF and `allowed_ips` +/// validation; the hostname still travels inside the tunnel (TLS SNI, +/// application `Host`). The operator opt-in `proxy_connect_by_hostname` +/// sends the client-requested hostname instead, for proxies whose ACLs +/// filter on hostnames, at the cost of re-opening proxy-side resolution. /// /// Both paths return a [`upstream_proxy::PrefixedStream`]: for proxied /// dials it replays any tunneled bytes that arrived in the same read as the @@ -2971,7 +2975,20 @@ async fn dial_upstream( if let Some(cfg) = upstream_proxy.as_ref() { return match cfg.decision(host_lc, port, addrs) { upstream_proxy::ProxyDecision::Proxy(endpoint) => { - upstream_proxy::connect_via(endpoint, host_lc, port).await + let target = if cfg.connect_by_hostname() { + upstream_proxy::ConnectTarget::Hostname + } else { + // First validated address, matching the order-of-attempt + // the direct path's `TcpStream::connect` uses. + let ip = addrs.first().map(SocketAddr::ip).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("no validated addresses to CONNECT to for {host_lc}"), + ) + })?; + upstream_proxy::ConnectTarget::Ip(ip) + }; + upstream_proxy::connect_via(endpoint, host_lc, port, target).await } upstream_proxy::ProxyDecision::Direct(direct_addrs) => { Ok(upstream_proxy::PrefixedStream::without_prefix( diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 3743392e9c..c52891b077 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -36,6 +36,11 @@ //! [`openshell_core::driver_utils::parse_upstream_proxy_credential`]. //! - Policy evaluation, DNS resolution, and SSRF checks run exactly as in the //! direct-dial path; the corporate proxy only replaces the final TCP dial. +//! - CONNECT requests target a validated resolved address by default, so the +//! proxy performs no DNS resolution and the tunnel stays bound to the +//! answer that passed SSRF/`allowed_ips` validation. The reserved +//! `OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME` opt-in sends the +//! hostname instead, for proxies whose ACLs filter on hostnames. //! - The reserved `NO_PROXY` list decides which destinations bypass the //! corporate proxy and keep dialing directly (cluster-internal services, //! host gateway, etc.). Loopback destinations always bypass the proxy. @@ -256,11 +261,27 @@ pub enum ProxyDecision<'a> { Direct(Vec), } +/// What the supervisor puts in the CONNECT request line sent to the +/// corporate proxy. +#[derive(Debug, Clone, Copy)] +pub enum ConnectTarget { + /// CONNECT to this validated address; the proxy performs no DNS + /// resolution, so the tunnel is bound to the answer that already passed + /// SSRF and `allowed_ips` validation. The destination hostname still + /// travels inside the tunnel (TLS SNI, application `Host`). + Ip(IpAddr), + /// CONNECT by hostname; the proxy resolves the name itself. Operator + /// opt-in for hostname-filtering proxy ACLs — see + /// [`UPSTREAM_PROXY_CONNECT_BY_HOSTNAME`](openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME). + Hostname, +} + /// Corporate proxy configuration read from the supervisor's environment. #[derive(Debug, Clone)] pub struct UpstreamProxyConfig { https: ProxyEndpoint, no_proxy: NoProxy, + connect_by_hostname: bool, } impl UpstreamProxyConfig { @@ -288,9 +309,10 @@ impl UpstreamProxyConfig { /// unreadable or holds a malformed credential, an auth file without the /// explicit cleartext-credential acknowledgement /// ([`UPSTREAM_PROXY_AUTH_ALLOW_INSECURE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE)), - /// or an auth file or `NO_PROXY` list with no proxy configured. Failing - /// closed here - /// prevents a misconfiguration + /// a CONNECT-target opt-in + /// ([`UPSTREAM_PROXY_CONNECT_BY_HOSTNAME`](openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME)) + /// with any value other than `true`, or any auxiliary variable with no + /// proxy configured. Failing closed here prevents a misconfiguration /// from silently degrading to direct dialing or unauthenticated proxy /// access. Only fully unset variables mean "no proxy". pub fn from_env() -> Result, String> { @@ -300,7 +322,7 @@ impl UpstreamProxyConfig { fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result, String> { use openshell_core::sandbox_env::{ UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, - UPSTREAM_PROXY_AUTH_FILE, + UPSTREAM_PROXY_AUTH_FILE, UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, }; // Only a fully unset reserved variable means "not configured". A // present-but-empty value is a misconfiguration (the compute driver @@ -320,6 +342,7 @@ impl UpstreamProxyConfig { .transpose()?; let auth_file = var(UPSTREAM_PROXY_AUTH_FILE)?; let auth_allow_insecure = var(UPSTREAM_PROXY_AUTH_ALLOW_INSECURE)?; + let connect_by_hostname_raw = var(UPSTREAM_PROXY_CONNECT_BY_HOSTNAME)?; let no_proxy_list = var(UPSTREAM_NO_PROXY)?; let Some(mut https) = https else { // Auxiliary proxy settings without a proxy mean the operator @@ -328,6 +351,7 @@ impl UpstreamProxyConfig { for (name, value) in [ (UPSTREAM_PROXY_AUTH_FILE, &auth_file), (UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, &auth_allow_insecure), + (UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, &connect_by_hostname_raw), (UPSTREAM_NO_PROXY, &no_proxy_list), ] { if value.is_some() { @@ -337,6 +361,19 @@ impl UpstreamProxyConfig { return Ok(None); }; + // CONNECT-target mode. The default binds the tunnel to a validated + // address; hostname CONNECT re-opens proxy-side DNS resolution and + // is only honored as the exact opt-in value the driver writes. + let connect_by_hostname = match connect_by_hostname_raw.as_deref().map(str::trim) { + None => false, + Some("true") => true, + Some(_) => { + return Err(format!( + "{UPSTREAM_PROXY_CONNECT_BY_HOSTNAME} must be 'true' when set" + )); + } + }; + // Cleartext-credential acknowledgement. `Proxy-Authorization: Basic` // travels over plain TCP to the http:// proxy, so credentials are // only sent when the operator explicitly opted in. The compute driver @@ -382,9 +419,17 @@ impl UpstreamProxyConfig { Ok(Some(Self { https, no_proxy: NoProxy::parse(&no_proxy_list.unwrap_or_default()), + connect_by_hostname, })) } + /// Whether CONNECT requests carry the destination hostname instead of a + /// validated IP (operator opt-in for hostname-filtering proxy ACLs). + #[must_use] + pub fn connect_by_hostname(&self) -> bool { + self.connect_by_hostname + } + /// How to dial the validated destination `(host, port, resolved)`, /// honoring the reserved `NO_PROXY` list. /// @@ -409,9 +454,14 @@ impl UpstreamProxyConfig { #[must_use] pub fn summary(&self) -> String { format!( - "https_proxy={} no_proxy_entries={}", + "https_proxy={} no_proxy_entries={} connect_target={}", self.https.display_addr(), - self.no_proxy.entries.len() + self.no_proxy.entries.len(), + if self.connect_by_hostname { + "hostname" + } else { + "validated-ip" + } ) } } @@ -552,17 +602,23 @@ impl AsyncWrite for PrefixedStream { } } -/// Open a tunnel to `host:port` through the corporate proxy with HTTP CONNECT. +/// Open a tunnel to the destination 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. Any tunneled bytes /// received in the same read as the CONNECT response are preserved and /// replayed by the returned [`PrefixedStream`]. /// -/// 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. +/// `target` selects the CONNECT request target. The default mode sends a +/// validated IP ([`ConnectTarget::Ip`]) so the proxy performs no DNS +/// resolution and the tunnel is bound to an address that already passed +/// SSRF and `allowed_ips` validation; `host` then only labels logs and is +/// used by the caller for TLS SNI inside the tunnel. With the operator +/// opt-in ([`ConnectTarget::Hostname`]) the hostname is sent instead so +/// hostname-filtering proxy ACLs keep working, at the cost of proxy-side +/// resolution. Local DNS resolution and SSRF validation must already have +/// happened at the call site in both modes. /// /// # Errors /// @@ -572,10 +628,11 @@ pub async fn connect_via( endpoint: &ProxyEndpoint, host: &str, port: u16, + target: ConnectTarget, ) -> std::io::Result { tokio::time::timeout( CONNECT_HANDSHAKE_TIMEOUT, - connect_via_inner(endpoint, host, port), + connect_via_inner(endpoint, host, port, target), ) .await .map_err(|_| { @@ -593,13 +650,15 @@ async fn connect_via_inner( endpoint: &ProxyEndpoint, host: &str, port: u16, + target: ConnectTarget, ) -> 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 target = match target { + ConnectTarget::Ip(IpAddr::V6(ip)) => format!("[{ip}]:{port}"), + ConnectTarget::Ip(ip) => format!("{ip}:{port}"), + ConnectTarget::Hostname if host.contains(':') => format!("[{host}]:{port}"), + ConnectTarget::Hostname => format!("{host}:{port}"), }; let mut request = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n"); if let Some(auth) = &endpoint.proxy_authorization { @@ -676,6 +735,7 @@ mod tests { UPSTREAM_HTTPS_PROXY as HTTPS_PROXY, UPSTREAM_NO_PROXY as NO_PROXY, UPSTREAM_PROXY_AUTH_ALLOW_INSECURE as PROXY_AUTH_ALLOW_INSECURE, UPSTREAM_PROXY_AUTH_FILE as PROXY_AUTH_FILE, + UPSTREAM_PROXY_CONNECT_BY_HOSTNAME as PROXY_CONNECT_BY_HOSTNAME, }; fn config_from(pairs: &[(&str, &str)]) -> Result, String> { @@ -1128,17 +1188,42 @@ mod tests { } #[tokio::test] - async fn connect_via_success_sends_connect_and_auth() { + async fn connect_via_targets_validated_ip_by_default() { + // The default CONNECT target is a validated address, so the proxy + // performs no DNS resolution; the hostname only travels inside the + // tunnel (TLS SNI, application Host). 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) + let stream = connect_via( + &endpoint, + "api.example.com", + 443, + ConnectTarget::Ip("93.184.216.34".parse().unwrap()), + ) + .await + .unwrap(); + drop(stream); + let request = handle.await.unwrap(); + assert!(request.starts_with("CONNECT 93.184.216.34:443 HTTP/1.1\r\n")); + assert!(request.contains("Host: 93.184.216.34:443\r\n")); + assert!( + !request.contains("api.example.com"), + "hostname must not leak into the CONNECT request in IP mode: {request}" + ); + assert!(request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n")); + } + + #[tokio::test] + async fn connect_via_sends_hostname_on_operator_opt_in() { + let (addr, handle) = fake_proxy("HTTP/1.1 200 Connection established\r\n\r\n").await; + let endpoint = endpoint_for(addr, None); + let stream = connect_via(&endpoint, "api.example.com", 443, ConnectTarget::Hostname) .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] @@ -1149,7 +1234,7 @@ mod tests { let (addr, _handle) = fake_proxy("HTTP/1.1 200 Connection established\r\n\r\nserver-first-payload").await; let endpoint = endpoint_for(addr, None); - let mut stream = connect_via(&endpoint, "api.example.com", 443) + let mut stream = connect_via(&endpoint, "api.example.com", 443, ConnectTarget::Hostname) .await .unwrap(); let mut payload = vec![0u8; "server-first-payload".len()]; @@ -1183,7 +1268,7 @@ mod tests { 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) + let err = connect_via(&endpoint, "api.example.com", 443, ConnectTarget::Hostname) .await .unwrap_err(); assert!(err.to_string().contains("407"), "{err}"); @@ -1193,7 +1278,7 @@ mod tests { 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) + let err = connect_via(&endpoint, "api.example.com", 443, ConnectTarget::Hostname) .await .unwrap_err(); assert_eq!(err.kind(), ErrorKind::InvalidData); @@ -1201,10 +1286,57 @@ mod tests { #[tokio::test] async fn connect_via_ipv6_target_is_bracketed() { + // Both modes must bracket IPv6 authorities in the request line. 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 _ = connect_via( + &endpoint, + "v6.example.com", + 443, + ConnectTarget::Ip("2001:db8::1".parse().unwrap()), + ) + .await + .unwrap(); let request = handle.await.unwrap(); assert!(request.starts_with("CONNECT [2001:db8::1]:443 HTTP/1.1\r\n")); + + 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, ConnectTarget::Hostname) + .await + .unwrap(); + let request = handle.await.unwrap(); + assert!(request.starts_with("CONNECT [2001:db8::1]:443 HTTP/1.1\r\n")); + } + + #[test] + fn connect_by_hostname_requires_exact_true() { + // Default is the validated-IP binding. + let cfg = config_ok(&[(HTTPS_PROXY, "http://proxy:8080")]); + assert!(!cfg.connect_by_hostname()); + + let cfg = config_ok(&[ + (HTTPS_PROXY, "http://proxy:8080"), + (PROXY_CONNECT_BY_HOSTNAME, "true"), + ]); + assert!(cfg.connect_by_hostname()); + + // Anything other than the exact opt-in value the driver writes is a + // misconfiguration, not a silent fallback to either mode. + for value in ["false", "yes", "1", "TRUE"] { + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy:8080"), + (PROXY_CONNECT_BY_HOSTNAME, value), + ]) + .unwrap_err(); + assert!(err.contains(PROXY_CONNECT_BY_HOSTNAME), "{value}: {err}"); + } + } + + #[test] + fn connect_by_hostname_without_proxy_is_fatal() { + let err = config_from(&[(PROXY_CONNECT_BY_HOSTNAME, "true")]).unwrap_err(); + assert!(err.contains(PROXY_CONNECT_BY_HOSTNAME), "{err}"); + assert!(err.contains("no upstream proxy"), "{err}"); } } diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 6b037a9c47..a644f3f602 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -405,12 +405,26 @@ health_check_interval_secs = 10 # sandbox and template environment cannot override it, and the conventional # HTTPS_PROXY/HTTP_PROXY/NO_PROXY variables a sandbox sets do not affect it. # +# The CONNECT request sent to the proxy targets a validated resolved IP, +# not the hostname, so the proxy performs no DNS resolution of its own and +# the tunnel stays bound to the address that passed the sandbox's SSRF and +# allowed_ips validation. The hostname still travels inside the tunnel (TLS +# SNI, application Host), so destination servers behave normally. In +# split-horizon networks, point the gateway host at the corporate resolver +# so internal names validate to their internal addresses. If the proxy's +# ACLs filter on hostnames and reject IP CONNECT targets, set +# proxy_connect_by_hostname = true as a last resort: the proxy then +# resolves the name itself, so a name that resolves differently at the +# proxy (split-horizon DNS, rebinding) can reach destinations the sandbox +# policy never approved, and the proxy's own ACLs become the effective +# egress control for proxied TLS. +# # Configuration is fail-closed: an invalid proxy URL is rejected at gateway -# startup, setting no_proxy or proxy_auth_file without a proxy URL is -# rejected as well, and a set-but-invalid value reaching a sandbox (for -# example an unreadable or malformed auth file) is fatal to that sandbox's -# supervisor instead of silently falling back to direct or unauthenticated -# egress. +# startup, setting no_proxy, proxy_auth_file, or proxy_connect_by_hostname +# without a proxy URL is rejected as well, and a set-but-invalid value +# reaching a sandbox (for example an unreadable or malformed auth file) is +# fatal to that sandbox's supervisor instead of silently falling back to +# direct or unauthenticated egress. # # Credentials must NOT be embedded in the URL (an inline user:pass@ is # rejected at startup, since it would be stored here and exposed in container @@ -433,6 +447,8 @@ health_check_interval_secs = 10 # no_proxy = "*.svc.cluster.local,10.0.0.0/8" # proxy_auth_file = "/etc/openshell/secrets/proxy-auth" # proxy_auth_allow_insecure = true +# Last resort for hostname-filtering proxy ACLs; see the warning above. +# proxy_connect_by_hostname = true ``` ### MicroVM diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index bc059af768..2d3ec1d441 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -393,6 +393,18 @@ EOF ;; esac fi + if [[ -n "${OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME+x}" ]]; then + case "${OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME}" in + true|false) + printf 'proxy_connect_by_hostname = %s\n' "${OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME}" >>"${CONFIG_PATH}" + ;; + *) + # Not a TOML boolean: write it as a quoted string so the gateway's + # config parser rejects it at startup (fail closed, no injection). + printf 'proxy_connect_by_hostname = "%s"\n' "$(toml_escape "${OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME}")" >>"${CONFIG_PATH}" + ;; + esac + fi ;; esac From 32930c22b4b0d806ab16526d2a6653eb3be43d9f Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 14:36:34 +0200 Subject: [PATCH 14/28] fix(sandbox,podman): reject empty port after bracketed IPv6 proxy host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http://[fd00::1]: passed the explicit-port check because the bracketed branch only tested for a colon after the bracket, then fell back to port 80 — violating the fail-closed http://host:port contract and potentially sending configured Basic credentials to an unintended service. Require a non-empty suffix after ]:, matching the unbracketed branch, and cover the case in the shared parser tests. Signed-off-by: Philippe Martin --- crates/openshell-core/src/driver_utils.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 3ee643f13c..5f93fb0cdb 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -223,8 +223,13 @@ fn authority_has_explicit_port(raw: &str) -> bool { .rsplit_once(':') .is_some_and(|(_, port)| !port.is_empty()) }, - // Bracketed IPv6 literal: a port can only follow the bracket. - |end| host_port[end + 1..].starts_with(':'), + // Bracketed IPv6 literal: a port can only follow the bracket, and a + // bare trailing `]:` is no more explicit than no port at all. + |end| { + host_port[end + 1..] + .strip_prefix(':') + .is_some_and(|port| !port.is_empty()) + }, ) } @@ -404,6 +409,7 @@ mod tests { "http://proxy.corp.com/", "http://proxy.corp.com:", "http://[fd00::1]", + "http://[fd00::1]:", ] { assert_eq!( parse_upstream_proxy_url(url), From cdf5d8957f2166594578e5f8e8436f6c92bf8b39 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 14:42:00 +0200 Subject: [PATCH 15/28] fix(sandbox,podman): fall back across validated addresses in proxied CONNECT The direct path hands TcpStream::connect the whole validated address list and it falls back across them, but the validated-IP CONNECT path attempted only the first address, so a dual-stack destination could fail through the corporate proxy even when a later validated address was reachable. connect_via_validated tries each validated address in order under one aggregate CONNECT_HANDSHAKE_TIMEOUT budget, returning the first success; when every attempt fails the error names the attempt count and carries the last failure. An empty address list is rejected up front. Adds regressions for first-fails/second-succeeds fallback (asserting both CONNECT request lines), the aggregate all-addresses failure message, and the empty-list rejection. Signed-off-by: Philippe Martin --- .../openshell-supervisor-network/src/proxy.rs | 25 ++- .../src/upstream_proxy.rs | 155 ++++++++++++++++-- 2 files changed, 157 insertions(+), 23 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 0bc636ad0e..3182cd6f4b 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2975,20 +2975,19 @@ async fn dial_upstream( if let Some(cfg) = upstream_proxy.as_ref() { return match cfg.decision(host_lc, port, addrs) { upstream_proxy::ProxyDecision::Proxy(endpoint) => { - let target = if cfg.connect_by_hostname() { - upstream_proxy::ConnectTarget::Hostname + if cfg.connect_by_hostname() { + upstream_proxy::connect_via( + endpoint, + host_lc, + port, + upstream_proxy::ConnectTarget::Hostname, + ) + .await } else { - // First validated address, matching the order-of-attempt - // the direct path's `TcpStream::connect` uses. - let ip = addrs.first().map(SocketAddr::ip).ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("no validated addresses to CONNECT to for {host_lc}"), - ) - })?; - upstream_proxy::ConnectTarget::Ip(ip) - }; - upstream_proxy::connect_via(endpoint, host_lc, port, target).await + // Try every validated address in order, matching the + // fallback the direct path's `TcpStream::connect` does. + upstream_proxy::connect_via_validated(endpoint, host_lc, port, addrs).await + } } upstream_proxy::ProxyDecision::Direct(direct_addrs) => { Ok(upstream_proxy::PrefixedStream::without_prefix( diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index c52891b077..e188f7f6b5 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -646,6 +646,67 @@ pub async fn connect_via( })? } +/// Open a validated-IP CONNECT tunnel, trying each validated address in +/// order until one succeeds. +/// +/// The direct-dial path hands `TcpStream::connect` the whole validated list +/// and it falls back across addresses; this is the proxied equivalent, so a +/// dual-stack destination whose first validated address is unreachable +/// through the corporate proxy still connects via a later one. All attempts +/// share one aggregate handshake budget ([`CONNECT_HANDSHAKE_TIMEOUT`]), +/// matching the single-attempt paths. +/// +/// # Errors +/// +/// Returns an error when `addrs` is empty, when every attempt fails (the +/// last attempt's error, annotated with the attempt count when there were +/// several), or when the aggregate budget expires. +pub async fn connect_via_validated( + endpoint: &ProxyEndpoint, + host: &str, + port: u16, + addrs: &[SocketAddr], +) -> std::io::Result { + if addrs.is_empty() { + return Err(IoError::new( + ErrorKind::InvalidInput, + format!("no validated addresses to CONNECT to for {host}"), + )); + } + tokio::time::timeout(CONNECT_HANDSHAKE_TIMEOUT, async { + let mut last_err = None; + for addr in addrs { + match connect_via_inner(endpoint, host, port, ConnectTarget::Ip(addr.ip())).await { + Ok(stream) => return Ok(stream), + Err(err) => last_err = Some(err), + } + } + // `addrs` is non-empty, so at least one attempt recorded an error. + let last = last_err.expect("at least one CONNECT attempt"); + if addrs.len() > 1 { + Err(IoError::new( + last.kind(), + format!( + "CONNECT failed for all {} validated addresses of {host}; last: {last}", + addrs.len() + ), + )) + } else { + Err(last) + } + }) + .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, @@ -1164,17 +1225,9 @@ mod tests { 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; - } - } + let request = read_request(&mut socket).await; socket.write_all(response.as_bytes()).await.unwrap(); - String::from_utf8_lossy(&buf[..used]).into_owned() + request }); (addr, handle) } @@ -1213,6 +1266,88 @@ mod tests { assert!(request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n")); } + /// Read one request's header block from a fake-proxy socket. + async fn read_request(socket: &mut TcpStream) -> String { + 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; + } + } + String::from_utf8_lossy(&buf[..used]).into_owned() + } + + #[tokio::test] + async fn connect_via_validated_falls_back_across_validated_addresses() { + // The direct path tries every validated address; the proxied path + // must too, or a dual-stack destination whose first address is + // unreachable through the proxy fails needlessly. + 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 { + // First attempt: refuse the tunnel. + let (mut refused, _) = listener.accept().await.unwrap(); + let first = read_request(&mut refused).await; + refused + .write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + .await + .unwrap(); + drop(refused); + // Second attempt: establish it. + let (mut accepted, _) = listener.accept().await.unwrap(); + let second = read_request(&mut accepted).await; + accepted + .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + (first, second, accepted) + }); + + let endpoint = endpoint_for(addr, None); + let addrs = [sock("192.0.2.1", 443), sock("192.0.2.2", 443)]; + let stream = connect_via_validated(&endpoint, "api.example.com", 443, &addrs) + .await + .unwrap(); + let (first, second, _accepted) = handle.await.unwrap(); + drop(stream); + assert!( + first.starts_with("CONNECT 192.0.2.1:443 HTTP/1.1\r\n"), + "{first}" + ); + assert!( + second.starts_with("CONNECT 192.0.2.2:443 HTTP/1.1\r\n"), + "{second}" + ); + } + + #[tokio::test] + async fn connect_via_validated_reports_failure_across_all_addresses() { + let (addr, _handle) = fake_proxy("HTTP/1.1 502 Bad Gateway\r\n\r\n").await; + let endpoint = endpoint_for(addr, None); + // Only the first attempt gets a response; the second finds the + // listener gone, and the aggregate error must say both were tried. + let addrs = [sock("192.0.2.1", 443), sock("192.0.2.2", 443)]; + let err = connect_via_validated(&endpoint, "api.example.com", 443, &addrs) + .await + .unwrap_err(); + assert!( + err.to_string().contains("all 2 validated addresses"), + "{err}" + ); + } + + #[tokio::test] + async fn connect_via_validated_rejects_empty_address_list() { + let endpoint = endpoint_for(sock("127.0.0.1", 3128), None); + let err = connect_via_validated(&endpoint, "api.example.com", 443, &[]) + .await + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + } + #[tokio::test] async fn connect_via_sends_hostname_on_operator_opt_in() { let (addr, handle) = fake_proxy("HTTP/1.1 200 Connection established\r\n\r\n").await; From 707a50f7878a14872535f414040716b414a3b602 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 14:47:06 +0200 Subject: [PATCH 16/28] fix(sandbox,podman): strip new reserved proxy vars and complete docs/tests Follow-ups from review: - Add OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE and OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME to the supervisor-only strip list so workload child processes never inherit them, matching the documented contract for the other reserved proxy variables, and cover both in the supervisor-only variable test. - Document all five OPENSHELL_SANDBOX_* proxy variables in the mise run gateway help text, marked Podman-only and stating the auth-file/acknowledgement pairing, and complete the gateway-key list in the Podman README. - Add NO_PROXY composition coverage for bracketed IPv6 entries with port qualifiers, bare IPv6 entries, and IPv6 CIDR matching against an IPv6-literal host and against a hostname's resolved addresses, including the port-qualified CIDR form. Signed-off-by: Philippe Martin --- crates/openshell-driver-podman/README.md | 5 +- .../src/upstream_proxy.rs | 48 +++++++++++++++++++ .../src/process.rs | 4 ++ tasks/scripts/gateway.sh | 22 +++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index f99c7e3939..53838ecd83 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -351,8 +351,9 @@ Podman resources after out-of-band container removal or label drift. | `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set; rejected when it is not. | | `OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME` | `--sandbox-proxy-connect-by-hostname` | unset | Send the destination hostname in CONNECT requests instead of a validated IP. Last resort for proxies whose ACLs filter on hostnames: the proxy then resolves the name itself, so sandbox SSRF/`allowed_ips` validation no longer binds the connection. | -Through the gateway, the same settings are the `https_proxy`, `no_proxy`, and -`proxy_auth_file` keys under `[openshell.drivers.podman]`; see +Through the gateway, the same settings are the `https_proxy`, `no_proxy`, +`proxy_auth_file`, `proxy_auth_allow_insecure`, and +`proxy_connect_by_hostname` keys under `[openshell.drivers.podman]`; see `docs/reference/gateway-config.mdx`. This is an operator-owned egress boundary: the supervisor reads it from reserved diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index e188f7f6b5..8f853fb7fb 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -1198,6 +1198,54 @@ mod tests { } } + #[test] + fn no_proxy_bracketed_ipv6_entry_honors_port_qualifier() { + // The colons of an IPv6 literal must not be misread as a port + // qualifier; only the bracketed form can carry one. + let cfg = no_proxy_cfg("[fd00::1]:8443"); + assert!(bypasses_port(&cfg, "fd00::1", 8443)); + assert!(bypasses_port(&cfg, "[fd00::1]", 8443)); + assert!(!bypasses_port(&cfg, "fd00::1", 443)); + assert!(!bypasses_port(&cfg, "fd00::2", 8443)); + + // A bare IPv6 entry keeps every-port semantics. + let cfg = no_proxy_cfg("fd00::1"); + assert!(bypasses_port(&cfg, "fd00::1", 8443)); + assert!(bypasses_port(&cfg, "fd00::1", 443)); + } + + #[test] + fn no_proxy_ipv6_cidr_matches_resolved_addresses_of_hostnames() { + let cfg = no_proxy_cfg("fd00::/8"); + // An IPv6-literal host inside the range bypasses at hostname level. + assert!(bypasses(&cfg, "fd00::7")); + assert!(!bypasses(&cfg, "2001:db8::1")); + + // A hostname resolving into the range dials directly, restricted to + // the resolved addresses the entry contains. + let inside = sock("fd00::42", 443); + let outside = sock("2001:db8::1", 443); + match cfg.decision("svc.internal", 443, &[outside, inside]) { + ProxyDecision::Direct(addrs) => assert_eq!(addrs, vec![inside]), + ProxyDecision::Proxy(ep) => panic!("expected direct dial, got proxy {ep:?}"), + } + } + + #[test] + fn no_proxy_ipv6_cidr_honors_port_qualifier() { + let cfg = no_proxy_cfg("fd00::/8:6443"); + assert!(bypasses_port(&cfg, "fd00::7", 6443)); + assert!(!bypasses_port(&cfg, "fd00::7", 443)); + match cfg.decision("svc.internal", 6443, &[sock("fd00::42", 6443)]) { + ProxyDecision::Direct(addrs) => assert_eq!(addrs, vec![sock("fd00::42", 6443)]), + ProxyDecision::Proxy(ep) => panic!("expected direct dial, got proxy {ep:?}"), + } + assert!(matches!( + cfg.decision("svc.internal", 443, &[sock("fd00::42", 443)]), + ProxyDecision::Proxy(_) + )); + } + #[test] fn no_proxy_hostname_match_authorizes_all_resolved_addresses() { let cfg = no_proxy_cfg("internal.corp"); diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 3a9cc45388..e7229cd227 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -85,6 +85,8 @@ const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[ openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, openshell_core::sandbox_env::UPSTREAM_NO_PROXY, openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, + openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, + openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, ]; pub fn is_supervisor_only_env_var(key: &str) -> bool { @@ -2269,6 +2271,8 @@ mod tests { openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, openshell_core::sandbox_env::UPSTREAM_NO_PROXY, openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, + openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, + openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, ] { assert!( is_supervisor_only_env_var(key), diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 2d3ec1d441..3e94afe108 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -41,6 +41,28 @@ Environment: Podman supervisor sideload image. Defaults to openshell/supervisor:dev and is built on demand. +Corporate proxy (Podman driver only): + OPENSHELL_SANDBOX_HTTPS_PROXY + Corporate forward proxy URL for sandbox TLS + egress, in explicit http://host:port form. + OPENSHELL_SANDBOX_NO_PROXY + Comma-separated NO_PROXY list (hostnames, domain + suffixes, IPs, CIDRs, optional :port qualifiers) + dialed directly instead of through the proxy. + OPENSHELL_SANDBOX_PROXY_AUTH_FILE + Path to a file with proxy credentials as + user:pass. Requires the acknowledgement below — + the gateway refuses to start with one but not + the other. + OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE + Set to true to acknowledge that the credential + is sent as cleartext Basic auth over the + plain-TCP connection to the http:// proxy. + OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME + Set to true to send the destination hostname in + CONNECT instead of a validated IP. Last resort + for hostname-filtering proxy ACLs. + Docker and VM runs delegate to gateway:docker and gateway:vm setup scripts. EOF } From 2768c08c1b8415735146a663d971eb6d01ce5733 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 16:17:08 +0200 Subject: [PATCH 17/28] fix(sandbox,podman): cap each proxied CONNECT attempt within the shared budget A proxy that accepted the first CONNECT request but never responded consumed the entire aggregate handshake timeout, so later validated addresses were never tried and the hang defeated the multi-address fallback. Each attempt is now time-boxed to its fair share of the time remaining before the shared deadline (remaining / attempts_left): a hanging attempt is cut off with enough budget left for every remaining address, while time a fast failure does not use rolls over to later attempts and the total never exceeds CONNECT_HANDSHAKE_TIMEOUT. A timed-out attempt is recorded like any other failure, and the aggregate error distinguishes all-attempted from budget-exhausted runs. Adds a first-hangs/second-succeeds regression driven through a test-visible budget parameter so it runs in about a second instead of a real 30s window. Signed-off-by: Philippe Martin --- .../src/upstream_proxy.rs | 147 ++++++++++++++---- 1 file changed, 114 insertions(+), 33 deletions(-) diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 8f853fb7fb..73933f4ab8 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -654,18 +654,34 @@ pub async fn connect_via( /// dual-stack destination whose first validated address is unreachable /// through the corporate proxy still connects via a later one. All attempts /// share one aggregate handshake budget ([`CONNECT_HANDSHAKE_TIMEOUT`]), -/// matching the single-attempt paths. +/// matching the single-attempt paths; within it, each attempt is capped at +/// its fair share of the remaining time (`remaining / attempts_left`) so a +/// proxy that accepts a CONNECT but never answers cannot starve the +/// remaining validated addresses. Time an attempt fails without using +/// rolls over to later attempts. /// /// # Errors /// -/// Returns an error when `addrs` is empty, when every attempt fails (the -/// last attempt's error, annotated with the attempt count when there were -/// several), or when the aggregate budget expires. +/// Returns an error when `addrs` is empty, or when every attempt fails or +/// times out (the last attempt's error, annotated with the attempt count +/// when there were several). pub async fn connect_via_validated( endpoint: &ProxyEndpoint, host: &str, port: u16, addrs: &[SocketAddr], +) -> std::io::Result { + connect_via_validated_with_budget(endpoint, host, port, addrs, CONNECT_HANDSHAKE_TIMEOUT).await +} + +/// [`connect_via_validated`] with an explicit aggregate budget, split out so +/// tests can exercise the hang-fallback behavior without real 30s waits. +async fn connect_via_validated_with_budget( + endpoint: &ProxyEndpoint, + host: &str, + port: u16, + addrs: &[SocketAddr], + budget: Duration, ) -> std::io::Result { if addrs.is_empty() { return Err(IoError::new( @@ -673,38 +689,55 @@ pub async fn connect_via_validated( format!("no validated addresses to CONNECT to for {host}"), )); } - tokio::time::timeout(CONNECT_HANDSHAKE_TIMEOUT, async { - let mut last_err = None; - for addr in addrs { - match connect_via_inner(endpoint, host, port, ConnectTarget::Ip(addr.ip())).await { - Ok(stream) => return Ok(stream), - Err(err) => last_err = Some(err), - } + let deadline = tokio::time::Instant::now() + budget; + let mut last_err = None; + let mut attempted = 0usize; + for (index, addr) in addrs.iter().enumerate() { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; } - // `addrs` is non-empty, so at least one attempt recorded an error. - let last = last_err.expect("at least one CONNECT attempt"); - if addrs.len() > 1 { - Err(IoError::new( - last.kind(), - format!( - "CONNECT failed for all {} validated addresses of {host}; last: {last}", - addrs.len() - ), - )) - } else { - Err(last) + let attempts_left = u32::try_from(addrs.len() - index).unwrap_or(u32::MAX); + let attempt_budget = remaining / attempts_left; + attempted += 1; + match tokio::time::timeout( + attempt_budget, + connect_via_inner(endpoint, host, port, ConnectTarget::Ip(addr.ip())), + ) + .await + { + Ok(Ok(stream)) => return Ok(stream), + Ok(Err(err)) => last_err = Some(err), + Err(_) => { + last_err = Some(IoError::new( + ErrorKind::TimedOut, + format!( + "upstream proxy {} CONNECT to {} timed out", + endpoint.display_addr(), + addr.ip(), + ), + )); + } } - }) - .await - .map_err(|_| { - IoError::new( - ErrorKind::TimedOut, - format!( - "upstream proxy {} CONNECT handshake timed out", - endpoint.display_addr() - ), + } + // The first iteration always runs (`remaining` starts at the full + // budget), so at least one attempt recorded an error. + let last = last_err.expect("at least one CONNECT attempt"); + if addrs.len() == 1 { + return Err(last); + } + let scope = if attempted == addrs.len() { + format!("all {} validated addresses", addrs.len()) + } else { + format!( + "{attempted} of {} validated addresses (handshake budget exhausted)", + addrs.len() ) - })? + }; + Err(IoError::new( + last.kind(), + format!("CONNECT failed for {scope} of {host}; last: {last}"), + )) } async fn connect_via_inner( @@ -1371,6 +1404,54 @@ mod tests { ); } + #[tokio::test] + async fn connect_via_validated_survives_a_hanging_first_attempt() { + // A proxy that accepts the CONNECT but never answers must not + // consume the whole aggregate budget: the per-address cap moves on + // to the next validated address. + 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 { + // First attempt: read the CONNECT, then hang with the socket + // held open until the client times the attempt out. + let (mut hung, _) = listener.accept().await.unwrap(); + let first = read_request(&mut hung).await; + // Second attempt: establish the tunnel. + let (mut accepted, _) = listener.accept().await.unwrap(); + let second = read_request(&mut accepted).await; + accepted + .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + drop(hung); + (first, second, accepted) + }); + + let endpoint = endpoint_for(addr, None); + let addrs = [sock("192.0.2.1", 443), sock("192.0.2.2", 443)]; + // Small real-time budget: the first attempt hangs for its fair + // share (half), then the second succeeds within the remainder. + let stream = connect_via_validated_with_budget( + &endpoint, + "api.example.com", + 443, + &addrs, + Duration::from_secs(2), + ) + .await + .unwrap(); + let (first, second, _accepted) = handle.await.unwrap(); + drop(stream); + assert!( + first.starts_with("CONNECT 192.0.2.1:443 HTTP/1.1\r\n"), + "{first}" + ); + assert!( + second.starts_with("CONNECT 192.0.2.2:443 HTTP/1.1\r\n"), + "{second}" + ); + } + #[tokio::test] async fn connect_via_validated_reports_failure_across_all_addresses() { let (addr, _handle) = fake_proxy("HTTP/1.1 502 Bad Gateway\r\n\r\n").await; From fcc2651253c3374f6cd71798a923191828f3b01e Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Fri, 17 Jul 2026 22:39:13 +0200 Subject: [PATCH 18/28] fix(sandbox,podman): deliver corporate proxy config on the supervisor argv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy settings were injected as reserved OPENSHELL_UPSTREAM_* container environment variables. The driver only wrote names the operator configured, but container runtimes layer the spec environment over ENV values baked into the sandbox image, so an image could supply NO_PROXY=*, enable hostname CONNECT, or point an unconfigured deployment at an attacker-controlled proxy whenever the operator left a field unset. The settings now travel as supervisor command-line arguments (--upstream-proxy, --upstream-no-proxy, --upstream-proxy-auth-file, --upstream-proxy-auth-allow-insecure, --upstream-proxy-connect-by-hostname) built by the driver from operator config. The driver sets the container entrypoint and command explicitly, so neither sandbox spec/template environment nor image ENV can influence argv, and an omitted flag genuinely means unconfigured — in every supervisor topology, since the supervisor no longer consults its environment for these settings at all. Credentials stay on the root-only secret mount; only the mount path appears on argv. The reserved environment names, their strip-list entries, and the env-based validation surface are removed. UpstreamProxyConfig::from_args replaces from_env, reusing the same shared fail-closed validation and pairing rules keyed by the CLI flag names. Driver tests now assert the argv contract, including that sandbox-supplied environment cannot add, remove, or redirect proxy flags; supervisor tests cover from_args mapping and its pairing rules. Signed-off-by: Philippe Martin --- architecture/sandbox.md | 41 ++- crates/openshell-core/src/driver_utils.rs | 6 +- crates/openshell-core/src/sandbox_env.rs | 57 +--- crates/openshell-driver-podman/README.md | 12 +- crates/openshell-driver-podman/src/config.rs | 20 +- .../openshell-driver-podman/src/container.rs | 277 +++++++++--------- crates/openshell-sandbox/src/lib.rs | 2 + crates/openshell-sandbox/src/main.rs | 38 +++ .../openshell-supervisor-network/src/proxy.rs | 17 +- .../openshell-supervisor-network/src/run.rs | 2 + .../src/upstream_proxy.rs | 226 +++++++++----- .../src/process.rs | 24 -- 12 files changed, 390 insertions(+), 332 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 37cf82bd01..f105b8a27e 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -109,13 +109,14 @@ once policy and SSRF checks pass. Only TLS (CONNECT) egress is chained: plain-HTTP requests always dial the destination directly, because forwarding plain HTTP through a proxy requires absolute-form request forwarding rather than CONNECT tunneling and is out of scope. The proxy configuration is an -operator-owned boundary read from reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` / -`OPENSHELL_UPSTREAM_NO_PROXY` variables that compute drivers write in their -required-variable tier; sandbox and template environment cannot override them. -The conventional `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox -controls are ignored on this path. Reserved `NO_PROXY` destinations and +operator-owned boundary delivered on the supervisor's command line +(`--upstream-proxy` and friends) by the compute driver; sandbox and template +environment — and `ENV` values baked into the sandbox image — cannot +influence it, since none of these can alter the argv the driver sets. The +conventional `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox +controls are ignored on this path. Operator `NO_PROXY` destinations and loopback always dial directly; add driver-injected host aliases (e.g. -`host.containers.internal`) to the reserved `NO_PROXY` list when the corporate +`host.containers.internal`) to the operator `NO_PROXY` list when the corporate proxy cannot reach the container host. `NO_PROXY` matching is port-aware and resolution-aware: an entry with a `:port` qualifier only bypasses that port, and IP/CIDR entries also match hostnames through their validated resolved @@ -129,7 +130,7 @@ the answer that passed SSRF and `allowed_ips` validation. The hostname still travels inside the tunnel (TLS SNI, application `Host`). In split-horizon networks, point the gateway host at the corporate resolver so internal names validate to their internal addresses; the `proxy_connect_by_hostname` -opt-in (reserved `OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME`) exists as a +opt-in exists as a last resort for proxies whose ACLs filter on hostnames and reject IP CONNECT targets — with it, the proxy resolves the name itself and its ACLs become the effective egress control for proxied TLS. (Resolving through the proxy's @@ -137,32 +138,30 @@ own DNS view, e.g. DoH tunneled via CONNECT, is a possible future enhancement and out of scope.) The workload child's proxy variables are unaffected — they are always rewritten to point at the local policy proxy. -The configuration is fail-closed: a reserved variable that is present but -invalid — a present-but-empty value, an unsupported or malformed proxy URL, an -unreadable auth file, a malformed credential, or an auth file or `NO_PROXY` -list set while no proxy URL is configured — is fatal to supervisor startup -instead of being treated as unset, so a misconfiguration can never silently -degrade to direct dialing or unauthenticated proxy access. Only a fully unset -variable means "no proxy". The driver validates the same rules at -sandbox-create time through validators shared with the supervisor +The configuration is fail-closed: a setting that is present but invalid — an +empty value, an unsupported or malformed proxy URL, an unreadable auth file, +a malformed credential, or an auth file or `NO_PROXY` list set while no proxy +URL is configured — is fatal to supervisor startup instead of being treated +as unset, so a misconfiguration can never silently degrade to direct dialing +or unauthenticated proxy access. Only an omitted argument means "no proxy". +The driver validates the same rules at sandbox-create time through +validators shared with the supervisor (`openshell_core::driver_utils::parse_upstream_proxy_url` and `parse_upstream_proxy_credential`). Proxy credentials are never embedded in the URL: an inline `user:pass@` is rejected because it would be stored in `gateway.toml` and exposed in container metadata. Operators supply credentials via `proxy_auth_file`; the driver -stages them as a root-only secret mounted at a fixed path and exports only -that path in `OPENSHELL_UPSTREAM_PROXY_AUTH_FILE`. The supervisor reads the +stages them as a root-only secret mounted at a fixed path and passes only +that path on the supervisor's command line. The supervisor reads the file and builds the `Proxy-Authorization: Basic` header; a credential that is empty, contains control characters, or is not in `user:pass` form is fatal on -both sides. The reserved proxy variables — -including the auth-file path — are stripped from workload child processes. +both sides. The Basic header travels over the plain-TCP connection to the `http://` proxy, so it is readable on the network path between sandbox host and proxy. Configuring `proxy_auth_file` therefore requires the explicit opt-in -`proxy_auth_allow_insecure = true`, delivered to the supervisor as the -reserved `OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE` variable. Both the +`proxy_auth_allow_insecure = true`. Both the driver (at sandbox-create time) and the supervisor (at startup) reject an auth file without the acknowledgement, and the acknowledgement without an auth file, so credentials are never sent in cleartext without an explicit diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 5f93fb0cdb..7d7393b924 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -84,9 +84,9 @@ pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-p /// Produced by [`parse_upstream_proxy_url`], which is the single source of /// truth for what counts as a valid upstream proxy URL. Compute drivers use /// it to reject bad operator config at sandbox-create time, and the -/// in-container supervisor applies the same rules to the reserved -/// `OPENSHELL_UPSTREAM_*` variables so a value one side accepts is never -/// rejected (or silently ignored) by the other. +/// in-container supervisor applies the same rules to its driver-supplied +/// arguments so a value one side accepts is never rejected (or silently +/// ignored) by the other. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpstreamProxyAddr { /// Proxy hostname, IPv4, or IPv6 address (IPv6 without brackets). diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 03b5efc935..f15ce34bb1 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -115,56 +115,7 @@ pub const SANDBOX_UID: &str = "OPENSHELL_SANDBOX_UID"; /// supervisor drops privileges to a group other than the UID's primary group. pub const SANDBOX_GID: &str = "OPENSHELL_SANDBOX_GID"; -/// Corporate forward-proxy URL the supervisor chains TLS (CONNECT) egress -/// through, e.g. `http://proxy.corp.com:8080`. -/// -/// This is an operator-owned egress boundary. Compute drivers set it from -/// gateway configuration in the supervisor's required-variable tier so that -/// sandbox spec/template environment cannot override it. The supervisor reads -/// *only* this reserved name — never the conventional `HTTPS_PROXY` / -/// `ALL_PROXY` variables, which the sandbox creator controls and which are -/// reserved for pointing the workload child at the local policy proxy. -pub const UPSTREAM_HTTPS_PROXY: &str = "OPENSHELL_UPSTREAM_HTTPS_PROXY"; - -/// Comma-separated `NO_PROXY`-style list of destinations the supervisor dials -/// directly instead of chaining through the corporate proxy. -/// -/// Operator-owned counterpart to [`UPSTREAM_HTTPS_PROXY`]; see that constant -/// for the trust-boundary rationale. -pub const UPSTREAM_NO_PROXY: &str = "OPENSHELL_UPSTREAM_NO_PROXY"; - -/// Filesystem path (inside the sandbox) to the corporate proxy credentials. -/// -/// The file holds `user:pass` userinfo the supervisor turns into a -/// `Proxy-Authorization: Basic` header. Credentials are delivered through this -/// root-only file rather than embedded in the proxy URL, so they never appear -/// in container environment or metadata. Compute drivers write this in the -/// required-variable tier; the value is a path, not a secret. -pub const UPSTREAM_PROXY_AUTH_FILE: &str = "OPENSHELL_UPSTREAM_PROXY_AUTH_FILE"; - -/// Explicit operator acknowledgement (`true`) that the proxy credential is -/// sent as a cleartext `Proxy-Authorization: Basic` header over the plain-TCP -/// connection to the `http://` corporate proxy. -/// -/// Basic auth is base64, not encryption, so anyone on the network path -/// between the sandbox host and the proxy can recover the credential. The -/// supervisor refuses to send credentials without this acknowledgement: -/// [`UPSTREAM_PROXY_AUTH_FILE`] set without this variable equal to `true` is -/// a fatal startup error, as is this variable set without an auth file. -/// Compute drivers write it in the required-variable tier from the driver's -/// `proxy_auth_allow_insecure` setting. -pub const UPSTREAM_PROXY_AUTH_ALLOW_INSECURE: &str = "OPENSHELL_UPSTREAM_PROXY_AUTH_ALLOW_INSECURE"; - -/// Explicit operator opt-out (`true`) that sends the destination *hostname* -/// in the CONNECT request to the corporate proxy instead of a validated IP. -/// -/// By default the supervisor CONNECTs to an address that already passed -/// SSRF and `allowed_ips` validation, so the proxy performs no DNS -/// resolution and the connection is bound to the validated answer. With -/// this opt-out the proxy resolves the name itself: hostname-filtering -/// proxy ACLs keep working, but a name whose resolution differs at the -/// proxy (split-horizon DNS, rebinding) can reach destinations the sandbox -/// policy never approved — the proxy's own ACLs become the effective -/// egress control. Compute drivers write it in the required-variable tier -/// from the driver's `proxy_connect_by_hostname` setting. -pub const UPSTREAM_PROXY_CONNECT_BY_HOSTNAME: &str = "OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME"; +// The corporate upstream-proxy configuration deliberately has no reserved +// environment variables: it travels on the supervisor's argv +// (`--upstream-proxy` and friends), which a sandbox image cannot forge the +// way it could bake `ENV` values. diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 53838ecd83..25185baf31 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -356,12 +356,12 @@ Through the gateway, the same settings are the `https_proxy`, `no_proxy`, `proxy_connect_by_hostname` keys under `[openshell.drivers.podman]`; see `docs/reference/gateway-config.mdx`. -This is an operator-owned egress boundary: the supervisor reads it from reserved -`OPENSHELL_UPSTREAM_*` variables written at highest priority, so sandbox and -template environment cannot override it, and the conventional -`HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox controls do not steer -it. Credentials must be supplied through `proxy_auth_file`; an inline -`user:pass@` in the URL is rejected at startup. +This is an operator-owned egress boundary: the driver passes the settings on +the supervisor's command line, so sandbox and template environment — and any +`ENV` baked into the sandbox image — cannot override them, and the +conventional `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` variables a sandbox +controls do not steer it. Credentials must be supplied through +`proxy_auth_file`; an inline `user:pass@` in the URL is rejected at startup. Basic auth over an `http://` proxy is cleartext on the wire: anyone on the network path between the sandbox host and the proxy can recover the diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index ee148d0c67..b528b5011f 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -134,20 +134,18 @@ 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 the - /// reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` supervisor variable + /// Corporate forward proxy URL passed to the in-container supervisor /// (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 in explicit - /// `http://host:port` form (scheme and port required) are supported. - /// This is an operator-owned egress boundary: it is written in the - /// required-variable tier so sandbox/template environment cannot override - /// it, and the conventional `HTTPS_PROXY` variables are not used. + /// The supervisor chains policy-approved TLS tunnels through this proxy + /// with HTTP CONNECT instead of dialing upstream destinations directly. + /// Only `http://` proxy URLs in explicit `http://host:port` form (scheme + /// and port required) are supported. This is an operator-owned egress + /// boundary delivered on the supervisor's command line, so + /// sandbox/template environment cannot override it, and the conventional + /// `HTTPS_PROXY` variables are not used. pub https_proxy: Option, - /// Comma-separated `NO_PROXY` list injected as the reserved - /// `OPENSHELL_UPSTREAM_NO_PROXY` variable (e.g. + /// Comma-separated `NO_PROXY` list passed alongside the proxy URL (e.g. /// `*.svc.cluster.local,10.0.0.0/8`). Destinations matching an entry are /// dialed directly instead of through the corporate proxy. Entries take /// an optional `:port` qualifier that limits them to that destination diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index c8304ec929..1d09bef162 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -358,6 +358,41 @@ pub fn resolve_image<'a>(sandbox: &'a DriverSandbox, config: &'a PodmanComputeCo /// User-supplied vars are inserted first so that the required driver /// vars always win -- preventing spec/template overrides of security- /// critical values like `OPENSHELL_ENDPOINT` or `OPENSHELL_SANDBOX_ID`. +/// Build the corporate upstream-proxy command-line arguments passed to the +/// supervisor. +/// +/// This operator-owned egress boundary travels on argv, which sandbox +/// spec/template environment and image `ENV` cannot influence. Credentials +/// are never on argv — only the root-only mount path is passed; the +/// supervisor reads the secret from the mount. +fn upstream_proxy_cli_args(config: &PodmanComputeConfig) -> Vec { + let mut args = Vec::new(); + if let Some(url) = &config.https_proxy { + args.push("--upstream-proxy".to_string()); + args.push(url.clone()); + } + if let Some(list) = &config.no_proxy { + args.push("--upstream-no-proxy".to_string()); + args.push(list.clone()); + } + if config.proxy_auth_file.is_some() { + args.push("--upstream-proxy-auth-file".to_string()); + args.push(UPSTREAM_PROXY_AUTH_MOUNT_PATH.to_string()); + } + // Config validation guarantees the acknowledgement is `true` whenever an + // auth file is configured; the supervisor independently refuses + // credentials without it. + if config.proxy_auth_allow_insecure == Some(true) { + args.push("--upstream-proxy-auth-allow-insecure".to_string()); + } + // Absent means the default validated-IP CONNECT binding; only the + // explicit hostname opt-in is passed through. + if config.proxy_connect_by_hostname == Some(true) { + args.push("--upstream-proxy-connect-by-hostname".to_string()); + } + args +} + fn build_env( sandbox: &DriverSandbox, config: &PodmanComputeConfig, @@ -396,66 +431,10 @@ fn build_env( // 2. Required driver vars (highest priority -- always overwrite). - // Operator-configured corporate egress proxy. This is a security boundary, - // so it is written in the required-variable tier under reserved - // supervisor-only names. The conventional `HTTPS_PROXY`/`NO_PROXY` variants - // are deliberately NOT used here: those belong to the sandbox creator and - // are separately rewritten to point the workload child at the local policy - // proxy, so letting them steer the supervisor would let a sandbox pick an - // arbitrary proxy or disable proxying with `NO_PROXY=*`. - // - // Any sandbox/template-supplied value under a reserved name is first - // stripped, then replaced with the operator value (if configured), so the - // supervisor can never observe a reserved proxy variable the operator did - // not set. - // - // Proxy credentials are never passed through the environment: when - // `proxy_auth_file` is configured the supervisor reads them from a - // root-only secret mount, and only the mount *path* is exported. - let proxy_auth_mount = config - .proxy_auth_file - .as_ref() - .map(|_| UPSTREAM_PROXY_AUTH_MOUNT_PATH.to_string()); - // Config validation guarantees the acknowledgement is `true` whenever an - // auth file is configured; the supervisor independently refuses - // credentials without it. - let proxy_auth_allow_insecure = config - .proxy_auth_allow_insecure - .filter(|allowed| *allowed) - .map(|_| "true".to_string()); - // Absent means the default validated-IP CONNECT binding; only the - // explicit hostname opt-in is written through. - let proxy_connect_by_hostname = config - .proxy_connect_by_hostname - .filter(|by_hostname| *by_hostname) - .map(|_| "true".to_string()); - for (name, value) in [ - ( - openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, - &config.https_proxy, - ), - ( - openshell_core::sandbox_env::UPSTREAM_NO_PROXY, - &config.no_proxy, - ), - ( - openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, - &proxy_auth_mount, - ), - ( - openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, - &proxy_auth_allow_insecure, - ), - ( - openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, - &proxy_connect_by_hostname, - ), - ] { - env.remove(name); - if let Some(value) = value { - env.insert(name.into(), value.clone()); - } - } + // The operator's corporate egress proxy settings are not environment + // variables: they travel on the supervisor's argv (see + // `upstream_proxy_cli_args`), which sandbox spec/template environment + // and image ENV cannot influence. env.insert( openshell_core::sandbox_env::SANDBOX.into(), @@ -970,7 +949,10 @@ pub fn build_container_spec_with_token_and_gpu_devices( // Without this, the container would run the entrypoint binary with // the supervisor path as an argument instead of executing it directly. entrypoint: vec![SUPERVISOR_BINARY_PATH.into()], - command: vec![], + // Operator-owned corporate proxy flags. The workload command is not + // part of argv (the supervisor takes it from the reserved command + // env var), so these flags are the whole command list. + command: upstream_proxy_cli_args(config), // Force the supervisor to run as root (UID 0). Sandbox images may // set a non-root USER directive (e.g. `USER sandbox`), but the // supervisor needs root to create network namespaces, set up the @@ -1734,31 +1716,53 @@ mod tests { ); } - #[test] - fn container_spec_injects_operator_proxy_env() { - use openshell_core::sandbox_env::{UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY}; + /// Extract the container spec's supervisor argv (`command`) as strings. + fn spec_command(spec: &Value) -> Vec { + spec["command"] + .as_array() + .expect("command should be an array") + .iter() + .map(|v| { + v.as_str() + .expect("command arg should be a string") + .to_string() + }) + .collect() + } + #[test] + fn container_spec_passes_operator_proxy_on_supervisor_argv() { 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.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"); + let command = spec_command(&spec); + // Config travels on argv (the image cannot forge process arguments), + // as flag/value pairs. + let idx = command + .iter() + .position(|a| a == "--upstream-proxy") + .expect("proxy URL flag present"); assert_eq!( - env_map.get(UPSTREAM_HTTPS_PROXY).and_then(|v| v.as_str()), - Some("http://proxy.corp.com:8080"), - "reserved upstream HTTPS var should carry the operator proxy URL" + command.get(idx + 1).map(String::as_str), + Some("http://proxy.corp.com:8080") ); + let idx = command + .iter() + .position(|a| a == "--upstream-no-proxy") + .expect("no_proxy flag present"); assert_eq!( - env_map.get(UPSTREAM_NO_PROXY).and_then(|v| v.as_str()), - Some("*.svc.cluster.local,10.0.0.0/8"), - "reserved upstream NO_PROXY var should carry the operator list" + command.get(idx + 1).map(String::as_str), + Some("*.svc.cluster.local,10.0.0.0/8") ); - // The conventional proxy variables must NOT be set from operator - // config: they belong to the sandbox creator, not the supervisor. + // The proxy settings are argv-only; nothing about them lands in the + // environment, and the conventional proxy variables (which belong to + // the sandbox creator) are not touched by operator config. + let env_map = spec["env"].as_object().expect("env should be an object"); for key in ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] { assert!( !env_map.contains_key(key), @@ -1768,28 +1772,26 @@ mod tests { } #[test] - fn container_spec_omits_proxy_env_when_unconfigured() { - use openshell_core::sandbox_env::{UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY}; - + fn container_spec_omits_proxy_argv_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"); + let command = spec_command(&spec); - for key in [UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY] { - assert!( - !env_map.contains_key(key), - "{key} should be absent without operator proxy config" - ); - } + assert!( + !command.iter().any(|a| a.starts_with("--upstream-proxy")), + "no proxy flags without operator proxy config: {command:?}" + ); } #[test] - fn container_spec_user_env_cannot_override_operator_proxy() { + fn container_spec_sandbox_env_cannot_influence_proxy_argv() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; - use openshell_core::sandbox_env::{UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY}; - // A sandbox creator tries to redirect egress at an attacker proxy and - // disable proxying with `NO_PROXY=*` through both spec and template env. + // A sandbox creator tries to steer the egress boundary through spec + // and template environment (image-baked ENV behaves the same at the + // runtime layer). The supervisor takes proxy config only from the + // argv the driver builds out of operator config, so none of it has + // any effect. let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { environment: std::collections::HashMap::from([ @@ -1797,15 +1799,11 @@ mod tests { "HTTPS_PROXY".to_string(), "http://attacker:9999".to_string(), ), - ( - UPSTREAM_HTTPS_PROXY.to_string(), - "http://attacker:9999".to_string(), - ), - (UPSTREAM_NO_PROXY.to_string(), "*".to_string()), + ("NO_PROXY".to_string(), "*".to_string()), ]), template: Some(DriverSandboxTemplate { environment: std::collections::HashMap::from([( - UPSTREAM_NO_PROXY.to_string(), + "NO_PROXY".to_string(), "*".to_string(), )]), ..Default::default() @@ -1816,16 +1814,24 @@ mod tests { 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"); + let command = spec_command(&spec); + // Only the operator's proxy is delivered, and only on argv. + let idx = command + .iter() + .position(|a| a == "--upstream-proxy") + .expect("operator proxy flag present"); assert_eq!( - env_map.get(UPSTREAM_HTTPS_PROXY).and_then(|v| v.as_str()), - Some("http://proxy.corp.com:8080"), - "operator proxy must win over any sandbox-supplied reserved value" + command.get(idx + 1).map(String::as_str), + Some("http://proxy.corp.com:8080") + ); + assert!( + !command.iter().any(|a| a == "--upstream-no-proxy"), + "sandbox environment must not add a NO_PROXY bypass: {command:?}" ); assert!( - !env_map.contains_key(UPSTREAM_NO_PROXY), - "sandbox must not be able to inject a reserved NO_PROXY bypass" + !command.iter().any(|a| a.contains("attacker")), + "attacker proxy must not reach argv: {command:?}" ); } @@ -2572,29 +2578,33 @@ mod tests { config.proxy_auth_allow_insecure = Some(true); let spec = build_container_spec(&sandbox, &config); - let env_map = spec["env"].as_object().expect("env should be an object"); + let command = spec_command(&spec); - // The supervisor gets only the mount *path*, never the credential. + // The supervisor gets only the mount *path* on argv, never the + // credential itself. + let idx = command + .iter() + .position(|a| a == "--upstream-proxy-auth-file") + .expect("auth-file flag present"); assert_eq!( - env_map - .get(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE) - .and_then(|v| v.as_str()), + command.get(idx + 1).map(String::as_str), Some(UPSTREAM_PROXY_AUTH_MOUNT_PATH) ); // The cleartext-credential acknowledgement travels with the auth // file so the supervisor's fail-closed pairing check passes. - assert_eq!( - env_map - .get(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE) - .and_then(|v| v.as_str()), - Some("true") + assert!( + command + .iter() + .any(|a| a == "--upstream-proxy-auth-allow-insecure"), + "acknowledgement flag present: {command:?}" ); - // The URL carried in the environment must remain credential-free. - assert_eq!( - env_map - .get(openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY) - .and_then(|v| v.as_str()), - Some("http://proxy.corp.com:8080") + // The raw credential path from config never appears anywhere in the + // spec (only the fixed mount path does). + assert!( + !command + .iter() + .any(|a| a == "/etc/openshell/secrets/proxy-auth"), + "host-side credential path must not reach the container: {command:?}" ); let secrets = spec["secrets"] @@ -2617,40 +2627,43 @@ mod tests { 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"); + let command = spec_command(&spec); assert!( - !env_map.contains_key(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE), - "auth-file path must be absent when no proxy_auth_file is configured" + !command.iter().any(|a| a == "--upstream-proxy-auth-file"), + "auth-file flag must be absent when no proxy_auth_file is configured: {command:?}" ); assert!( - !env_map.contains_key(openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE), - "acknowledgement must be absent when no proxy_auth_file is configured" + !command + .iter() + .any(|a| a == "--upstream-proxy-auth-allow-insecure"), + "acknowledgement flag must be absent when no proxy_auth_file is configured: {command:?}" ); } #[test] - fn container_spec_connect_by_hostname_written_only_on_opt_in() { + fn container_spec_connect_by_hostname_passed_only_on_opt_in() { let sandbox = test_sandbox("proxy-id", "proxy-name"); let mut config = test_config(); config.https_proxy = Some("http://proxy.corp.com:8080".to_string()); - // Default: no reserved variable, the supervisor uses validated-IP - // CONNECT binding. + // Default: no flag, the supervisor uses validated-IP CONNECT binding. let spec = build_container_spec(&sandbox, &config); - let env_map = spec["env"].as_object().expect("env should be an object"); + let command = spec_command(&spec); assert!( - !env_map.contains_key(openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME), - "hostname CONNECT must be absent without the operator opt-in" + !command + .iter() + .any(|a| a == "--upstream-proxy-connect-by-hostname"), + "hostname CONNECT must be absent without the operator opt-in: {command:?}" ); config.proxy_connect_by_hostname = Some(true); 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(openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME) - .and_then(|v| v.as_str()), - Some("true") + let command = spec_command(&spec); + assert!( + command + .iter() + .any(|a| a == "--upstream-proxy-connect-by-hostname"), + "hostname CONNECT flag present on opt-in: {command:?}" ); } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 78e5027953..bafd3e81b8 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -106,6 +106,7 @@ pub async fn run_sandbox( ocsf_enabled: Arc, network_enabled: bool, process_enabled: bool, + upstream_proxy_args: openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs, ) -> Result { let (program, args) = command .split_first() @@ -384,6 +385,7 @@ pub async fn run_sandbox( inference_routes.as_deref(), denial_tx, activity_tx, + &upstream_proxy_args, ) .await?, ) diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 71f881f68a..62ae37b5a1 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -101,6 +101,9 @@ impl std::str::FromStr for Mode { } /// `OpenShell` Sandbox - process isolation and monitoring. +// CLI flags are naturally boolean switches; grouping them into structs would +// only obscure the clap definition. +#[allow(clippy::struct_excessive_bools)] #[derive(Parser, Debug)] #[command(name = "openshell-sandbox")] #[command(version = openshell_core::VERSION)] @@ -200,6 +203,32 @@ struct Args { /// Shared TLS work directory between the network init container and sidecar. #[arg(long, env = "OPENSHELL_PROXY_TLS_DIR", default_value = SIDECAR_TLS_DIR)] sidecar_tls_dir: String, + + // Corporate upstream proxy. Operator-owned egress boundary: accepted + // only as command-line arguments (no `env =`), because the driver + // controls the supervisor's argv while a sandbox image could bake + // matching `ENV` values. + /// Corporate forward proxy URL (`http://host:port`) for upstream TLS egress. + #[arg(long)] + upstream_proxy: Option, + + /// Comma-separated `NO_PROXY` list for the corporate proxy. + #[arg(long)] + upstream_no_proxy: Option, + + /// Path to the root-only file holding corporate proxy credentials (`user:pass`). + #[arg(long)] + upstream_proxy_auth_file: Option, + + /// Acknowledge that proxy credentials travel as cleartext Basic auth over + /// the plain-TCP connection to the `http://` proxy. + #[arg(long)] + upstream_proxy_auth_allow_insecure: bool, + + /// Send the destination hostname in CONNECT instead of a validated IP + /// (for proxies whose ACLs filter on hostnames). + #[arg(long)] + upstream_proxy_connect_by_hostname: bool, } /// Copy the running executable to `dest`, creating parent directories as @@ -581,6 +610,14 @@ fn main() -> Result<()> { // is not yet initialized at this point (run_sandbox hasn't been called). // The shorthand layer will render it in fallback format. + let upstream_proxy_args = openshell_supervisor_network::upstream_proxy::UpstreamProxyArgs { + https_proxy: args.upstream_proxy, + no_proxy: args.upstream_no_proxy, + proxy_auth_file: args.upstream_proxy_auth_file, + proxy_auth_allow_insecure: args.upstream_proxy_auth_allow_insecure, + proxy_connect_by_hostname: args.upstream_proxy_connect_by_hostname, + }; + run_sandbox( command, args.workdir, @@ -598,6 +635,7 @@ fn main() -> Result<()> { ocsf_enabled, args.mode.network, args.mode.process, + upstream_proxy_args, ) .await })?; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 3182cd6f4b..5ef0de76e7 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -201,6 +201,7 @@ impl ProxyHandle { denial_tx: Option>, activity_tx: Option, engine_ready: tokio::sync::watch::Receiver, + upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default // to loopback:3128. The default allows the proxy to function when no @@ -241,19 +242,18 @@ impl ProxyHandle { ); } - // Corporate egress proxy configured on the supervisor's own - // environment via the operator-owned reserved OPENSHELL_UPSTREAM_* - // variables. Read once at startup; the workload cannot influence the - // supervisor's environment, and the conventional HTTPS_PROXY/NO_PROXY - // variables it does control are ignored on this path. + // Corporate egress proxy configured by the operator and delivered on + // the supervisor's command line by the compute driver; the + // conventional HTTPS_PROXY/NO_PROXY variables the sandbox controls + // are ignored here. // // This is an operator-owned security boundary, so a present-but-invalid // value (bad proxy URL, unreadable auth file, malformed credential) is // fatal to proxy startup: failing closed prevents a misconfiguration // from silently degrading to direct dialing or unauthenticated proxy // access. - let upstream_proxy: Arc> = - Arc::new(UpstreamProxyConfig::from_env().map_err(|err| { + let upstream_proxy: Arc> = Arc::new( + UpstreamProxyConfig::from_args(upstream_proxy_args).map_err(|err| { let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(SeverityId::High) @@ -266,7 +266,8 @@ impl ProxyHandle { .build(); ocsf_emit!(event); miette::miette!("invalid upstream corporate proxy configuration: {err}") - })?); + })?, + ); if let Some(cfg) = upstream_proxy.as_ref() { let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(SeverityId::Informational) diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index d68db6f1c3..c9eaa08753 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -86,6 +86,7 @@ pub async fn run_networking( inference_routes: Option<&str>, denial_tx: Option>, activity_tx: Option, + upstream_proxy_args: &crate::upstream_proxy::UpstreamProxyArgs, ) -> Result { // Build the policy-local route context. The orchestrator's policy poll // loop also holds an `Arc` clone (via `Networking::policy_local_ctx`) so @@ -309,6 +310,7 @@ pub async fn run_networking( denial_tx, activity_tx, engine_ready_rx, + upstream_proxy_args, ) .await?; Some(proxy_handle) diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 73933f4ab8..1f73738604 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -5,10 +5,12 @@ //! //! 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 operator-owned -//! reserved `OPENSHELL_UPSTREAM_HTTPS_PROXY` / `OPENSHELL_UPSTREAM_NO_PROXY` -//! variables from the supervisor's **own** environment and chains approved -//! TLS tunnels through the corporate proxy with HTTP CONNECT. +//! through a corporate forward proxy. The operator-owned proxy configuration +//! reaches the supervisor as command-line arguments ([`UpstreamProxyArgs`]) +//! that the compute driver sets when it launches the supervisor — never +//! through the environment, which sandbox images could bake `ENV` values +//! into — and this module chains approved TLS tunnels through the corporate +//! proxy with HTTP CONNECT. //! //! Only TLS (CONNECT) egress is chained: plain-HTTP requests always dial the //! destination directly. Forwarding plain HTTP through a corporate proxy @@ -19,9 +21,7 @@ //! variables are intentionally ignored: those are controlled by the sandbox //! creator and are rewritten separately to point the workload child at the //! local policy proxy, so honoring them would let a sandbox pick an arbitrary -//! upstream proxy or disable proxying with `NO_PROXY=*`. The compute driver -//! writes the reserved names in its required-variable tier, which sandbox and -//! template environment cannot override. +//! upstream proxy or disable proxying with `NO_PROXY=*`. //! //! Scope and invariants: //! - Only `http://` proxy URLs are supported. Configuration is fail-closed: @@ -39,8 +39,8 @@ //! - CONNECT requests target a validated resolved address by default, so the //! proxy performs no DNS resolution and the tunnel stays bound to the //! answer that passed SSRF/`allowed_ips` validation. The reserved -//! `OPENSHELL_UPSTREAM_PROXY_CONNECT_BY_HOSTNAME` opt-in sends the -//! hostname instead, for proxies whose ACLs filter on hostnames. +//! `--upstream-proxy-connect-by-hostname` opt-in sends the hostname +//! instead, for proxies whose ACLs filter on hostnames. //! - The reserved `NO_PROXY` list decides which destinations bypass the //! corporate proxy and keep dialing directly (cluster-internal services, //! host gateway, etc.). Loopback destinations always bypass the proxy. @@ -271,12 +271,13 @@ pub enum ConnectTarget { /// travels inside the tunnel (TLS SNI, application `Host`). Ip(IpAddr), /// CONNECT by hostname; the proxy resolves the name itself. Operator - /// opt-in for hostname-filtering proxy ACLs — see - /// [`UPSTREAM_PROXY_CONNECT_BY_HOSTNAME`](openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME). + /// opt-in for hostname-filtering proxy ACLs + /// (`--upstream-proxy-connect-by-hostname`). Hostname, } -/// Corporate proxy configuration read from the supervisor's environment. +/// Corporate proxy configuration built from the driver-supplied +/// command-line arguments ([`UpstreamProxyArgs`]). #[derive(Debug, Clone)] pub struct UpstreamProxyConfig { https: ProxyEndpoint, @@ -284,75 +285,110 @@ pub struct UpstreamProxyConfig { connect_by_hostname: bool, } +/// Operator-owned corporate proxy settings passed to the supervisor as +/// command-line arguments by the compute driver. +/// +/// Argv is set by the driver when it creates the container/VM and cannot be +/// influenced by sandbox environment or image `ENV`, so a field left `None` +/// / `false` genuinely means "not configured by the operator". +#[derive(Debug, Clone, Default)] +pub struct UpstreamProxyArgs { + /// `http://host:port` corporate proxy URL, or `None` for direct egress. + pub https_proxy: Option, + /// Comma-separated `NO_PROXY` list. + pub no_proxy: Option, + /// Path to the root-only credential mount (`user:pass`). + pub proxy_auth_file: Option, + /// Operator acknowledgement that credentials travel as cleartext Basic + /// auth over the plain-TCP proxy connection. + pub proxy_auth_allow_insecure: bool, + /// Send the destination hostname in CONNECT instead of a validated IP. + pub proxy_connect_by_hostname: bool, +} + +// Supervisor CLI flag names for the corporate-proxy settings, used as the +// dispatch keys in `from_lookup` and in operator-facing error messages. +const ARG_HTTPS_PROXY: &str = "--upstream-proxy"; +const ARG_NO_PROXY: &str = "--upstream-no-proxy"; +const ARG_PROXY_AUTH_FILE: &str = "--upstream-proxy-auth-file"; +const ARG_PROXY_AUTH_ALLOW_INSECURE: &str = "--upstream-proxy-auth-allow-insecure"; +const ARG_PROXY_CONNECT_BY_HOSTNAME: &str = "--upstream-proxy-connect-by-hostname"; + impl UpstreamProxyConfig { - /// Read the operator-owned corporate proxy configuration from the - /// supervisor's reserved environment variables - /// ([`UPSTREAM_HTTPS_PROXY`](openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY), - /// [`UPSTREAM_NO_PROXY`](openshell_core::sandbox_env::UPSTREAM_NO_PROXY), - /// [`UPSTREAM_PROXY_AUTH_FILE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE)). - /// Returns `Ok(None)` when no proxy is configured (unset variables). + /// Build the corporate proxy configuration from the driver-supplied + /// command-line [`UpstreamProxyArgs`]. Returns `Ok(None)` when no proxy + /// is configured. /// - /// The conventional `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` - /// variables are intentionally ignored here: they are set by the sandbox - /// creator (and rewritten to point workload children at the local policy - /// proxy), so honoring them would let a sandbox choose an arbitrary - /// upstream proxy or disable proxying entirely. The compute driver writes - /// the reserved names in its required-variable tier, where sandbox and - /// template environment cannot override them. + /// The conventional `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` / + /// `NO_PROXY` environment variables are intentionally never consulted: + /// they are controlled by the sandbox creator (and rewritten to point + /// workload children at the local policy proxy), so honoring them would + /// let a sandbox choose an arbitrary upstream proxy or disable proxying + /// entirely. /// /// # Errors /// - /// These reserved variables are an operator-owned security boundary, so - /// any present-but-invalid value is fatal instead of being treated as - /// unset: a present-but-empty (or whitespace-only) reserved variable, an - /// invalid or unsupported proxy URL, an auth file that is set but - /// unreadable or holds a malformed credential, an auth file without the - /// explicit cleartext-credential acknowledgement - /// ([`UPSTREAM_PROXY_AUTH_ALLOW_INSECURE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE)), - /// a CONNECT-target opt-in - /// ([`UPSTREAM_PROXY_CONNECT_BY_HOSTNAME`](openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME)) - /// with any value other than `true`, or any auxiliary variable with no - /// proxy configured. Failing closed here prevents a misconfiguration - /// from silently degrading to direct dialing or unauthenticated proxy - /// access. Only fully unset variables mean "no proxy". - pub fn from_env() -> Result, String> { - Self::from_lookup(|name| std::env::var(name).ok()) + /// This is an operator-owned security boundary, so a present-but-invalid + /// value is fatal instead of being treated as unset: an empty (or + /// whitespace-only) argument, an invalid or unsupported proxy URL, an + /// auth file that is set but unreadable or holds a malformed credential, + /// an auth file without the cleartext-credential acknowledgement, or an + /// auth file / `NO_PROXY` list / acknowledgement / connect-by-hostname + /// flag with no proxy configured. Failing closed prevents a + /// misconfiguration from silently degrading to direct dialing or + /// unauthenticated proxy access. + pub fn from_args(args: &UpstreamProxyArgs) -> Result, String> { + // Present the typed argv fields under their canonical identifiers so + // the shared validation runs once. Booleans map to Some("true") / + // None, so every pairing rule (auth file needs the acknowledgement, + // no auxiliary setting without a proxy) applies unchanged. + Self::from_lookup(|name| { + if name == ARG_HTTPS_PROXY { + args.https_proxy.clone() + } else if name == ARG_NO_PROXY { + args.no_proxy.clone() + } else if name == ARG_PROXY_AUTH_FILE { + args.proxy_auth_file.clone() + } else if name == ARG_PROXY_AUTH_ALLOW_INSECURE { + args.proxy_auth_allow_insecure.then(|| "true".to_string()) + } else if name == ARG_PROXY_CONNECT_BY_HOSTNAME { + args.proxy_connect_by_hostname.then(|| "true".to_string()) + } else { + None + } + }) } fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result, String> { - use openshell_core::sandbox_env::{ - UPSTREAM_HTTPS_PROXY, UPSTREAM_NO_PROXY, UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, - UPSTREAM_PROXY_AUTH_FILE, UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, - }; - // Only a fully unset reserved variable means "not configured". A - // present-but-empty value is a misconfiguration (the compute driver - // never writes one), so it is fatal rather than silently downgrading - // the boundary to direct dialing or unauthenticated proxy access. + // A missing setting means "not configured". A present-but-empty value + // is a misconfiguration (the driver never emits one), so it is fatal + // rather than silently downgrading the boundary to direct dialing or + // unauthenticated proxy access. let var = |name: &str| -> Result, String> { match lookup(name) { None => Ok(None), - Some(value) if value.trim().is_empty() => Err(format!( - "{name} is set but empty; unset it to disable the upstream proxy" - )), + Some(value) if value.trim().is_empty() => { + Err(format!("{name} must not be empty when set")) + } Some(value) => Ok(Some(value)), } }; - let https = var(UPSTREAM_HTTPS_PROXY)? - .map(|url| parse_proxy_url(&url, UPSTREAM_HTTPS_PROXY)) + let https = var(ARG_HTTPS_PROXY)? + .map(|url| parse_proxy_url(&url, ARG_HTTPS_PROXY)) .transpose()?; - let auth_file = var(UPSTREAM_PROXY_AUTH_FILE)?; - let auth_allow_insecure = var(UPSTREAM_PROXY_AUTH_ALLOW_INSECURE)?; - let connect_by_hostname_raw = var(UPSTREAM_PROXY_CONNECT_BY_HOSTNAME)?; - let no_proxy_list = var(UPSTREAM_NO_PROXY)?; + let auth_file = var(ARG_PROXY_AUTH_FILE)?; + let auth_allow_insecure = var(ARG_PROXY_AUTH_ALLOW_INSECURE)?; + let connect_by_hostname_raw = var(ARG_PROXY_CONNECT_BY_HOSTNAME)?; + let no_proxy_list = var(ARG_NO_PROXY)?; let Some(mut https) = https else { // Auxiliary proxy settings without a proxy mean the operator // believed a proxy boundary was in effect; refuse rather than // silently running with direct egress. for (name, value) in [ - (UPSTREAM_PROXY_AUTH_FILE, &auth_file), - (UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, &auth_allow_insecure), - (UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, &connect_by_hostname_raw), - (UPSTREAM_NO_PROXY, &no_proxy_list), + (ARG_PROXY_AUTH_FILE, &auth_file), + (ARG_PROXY_AUTH_ALLOW_INSECURE, &auth_allow_insecure), + (ARG_PROXY_CONNECT_BY_HOSTNAME, &connect_by_hostname_raw), + (ARG_NO_PROXY, &no_proxy_list), ] { if value.is_some() { return Err(format!("{name} is set but no upstream proxy is configured")); @@ -369,7 +405,7 @@ impl UpstreamProxyConfig { Some("true") => true, Some(_) => { return Err(format!( - "{UPSTREAM_PROXY_CONNECT_BY_HOSTNAME} must be 'true' when set" + "{ARG_PROXY_CONNECT_BY_HOSTNAME} must be 'true' when set" )); } }; @@ -385,21 +421,20 @@ impl UpstreamProxyConfig { Some("true") => true, Some(_) => { return Err(format!( - "{UPSTREAM_PROXY_AUTH_ALLOW_INSECURE} must be 'true' when set" + "{ARG_PROXY_AUTH_ALLOW_INSECURE} must be 'true' when set" )); } }; if auth_file.is_none() && auth_allow_insecure.is_some() { return Err(format!( - "{UPSTREAM_PROXY_AUTH_ALLOW_INSECURE} is set but no \ - {UPSTREAM_PROXY_AUTH_FILE} is configured" + "{ARG_PROXY_AUTH_ALLOW_INSECURE} is set but no {ARG_PROXY_AUTH_FILE} is configured" )); } if auth_file.is_some() && !allow_insecure { return Err(format!( - "{UPSTREAM_PROXY_AUTH_FILE} sends the credential as cleartext Basic auth \ + "{ARG_PROXY_AUTH_FILE} sends the credential as cleartext Basic auth \ over the plain-TCP proxy connection; refusing without \ - {UPSTREAM_PROXY_AUTH_ALLOW_INSECURE}=true" + {ARG_PROXY_AUTH_ALLOW_INSECURE}" )); } @@ -471,8 +506,8 @@ impl UpstreamProxyConfig { /// ([`parse_upstream_proxy_url`](openshell_core::driver_utils::parse_upstream_proxy_url)). /// /// Credentials are never taken from the URL: they are delivered out of band -/// through [`UPSTREAM_PROXY_AUTH_FILE`](openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE) -/// so they never appear in config or container metadata. +/// through the root-only auth-file mount (`--upstream-proxy-auth-file`) so +/// they never appear in config or container metadata. /// /// # Errors /// @@ -825,11 +860,11 @@ async fn connect_via_inner( #[cfg(test)] mod tests { use super::*; - use openshell_core::sandbox_env::{ - UPSTREAM_HTTPS_PROXY as HTTPS_PROXY, UPSTREAM_NO_PROXY as NO_PROXY, - UPSTREAM_PROXY_AUTH_ALLOW_INSECURE as PROXY_AUTH_ALLOW_INSECURE, - UPSTREAM_PROXY_AUTH_FILE as PROXY_AUTH_FILE, - UPSTREAM_PROXY_CONNECT_BY_HOSTNAME as PROXY_CONNECT_BY_HOSTNAME, + use super::{ + ARG_HTTPS_PROXY as HTTPS_PROXY, ARG_NO_PROXY as NO_PROXY, + ARG_PROXY_AUTH_ALLOW_INSECURE as PROXY_AUTH_ALLOW_INSECURE, + ARG_PROXY_AUTH_FILE as PROXY_AUTH_FILE, + ARG_PROXY_CONNECT_BY_HOSTNAME as PROXY_CONNECT_BY_HOSTNAME, }; fn config_from(pairs: &[(&str, &str)]) -> Result, String> { @@ -851,6 +886,49 @@ mod tests { assert!(config_from(&[]).unwrap().is_none()); } + #[test] + fn from_args_maps_cli_arguments_to_config() { + // The driver-supplied argv is the authoritative source; the shared + // validation applies exactly as for the reserved-name lookup. + let args = UpstreamProxyArgs { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + no_proxy: Some("*.svc.cluster.local".to_string()), + proxy_connect_by_hostname: true, + ..UpstreamProxyArgs::default() + }; + let cfg = UpstreamProxyConfig::from_args(&args).unwrap().unwrap(); + assert!(cfg.connect_by_hostname()); + assert!(bypasses(&cfg, "kubernetes.default.svc.cluster.local")); + + // No proxy URL means no configuration. + assert!( + UpstreamProxyConfig::from_args(&UpstreamProxyArgs::default()) + .unwrap() + .is_none() + ); + } + + #[test] + fn from_args_enforces_the_auth_file_acknowledgement_pairing() { + // An auth file without the acknowledgement is fatal, exactly as when + // the same pairing arrives via the reserved names. + let args = UpstreamProxyArgs { + https_proxy: Some("http://proxy.corp.com:8080".to_string()), + proxy_auth_file: Some("/etc/openshell/auth/upstream-proxy".to_string()), + ..UpstreamProxyArgs::default() + }; + let err = UpstreamProxyConfig::from_args(&args).unwrap_err(); + assert!(err.contains(PROXY_AUTH_ALLOW_INSECURE), "{err}"); + + // Auxiliary settings without a proxy are fatal. + let args = UpstreamProxyArgs { + proxy_connect_by_hostname: true, + ..UpstreamProxyArgs::default() + }; + let err = UpstreamProxyConfig::from_args(&args).unwrap_err(); + assert!(err.contains("no upstream proxy"), "{err}"); + } + #[test] fn conventional_proxy_vars_are_ignored() { // The sandbox creator controls these names; they must not steer the diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index e7229cd227..72effa98f8 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -78,15 +78,6 @@ const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[ openshell_core::sandbox_env::TLS_CERT, openshell_core::sandbox_env::TLS_KEY, openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, - // Corporate proxy routing is a supervisor-only egress boundary and the URLs - // may embed proxy credentials (`http://user:pass@proxy`). The workload - // reaches egress through the local policy proxy, never the corporate proxy - // directly, so these must not be inherited by sandbox child processes. - openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, - openshell_core::sandbox_env::UPSTREAM_NO_PROXY, - openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, - openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, - openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, ]; pub fn is_supervisor_only_env_var(key: &str) -> bool { @@ -2264,21 +2255,6 @@ mod tests { ); } assert!(stdout.contains("OPENSHELL_ENDPOINT=https://gateway.example.test")); - - // The reserved corporate-proxy variables can carry proxy credentials - // and must be treated as supervisor-only so they are stripped above. - for key in [ - openshell_core::sandbox_env::UPSTREAM_HTTPS_PROXY, - openshell_core::sandbox_env::UPSTREAM_NO_PROXY, - openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_FILE, - openshell_core::sandbox_env::UPSTREAM_PROXY_AUTH_ALLOW_INSECURE, - openshell_core::sandbox_env::UPSTREAM_PROXY_CONNECT_BY_HOSTNAME, - ] { - assert!( - is_supervisor_only_env_var(key), - "{key} must be supervisor-only so it is not leaked to the workload" - ); - } } #[test] From 5be990500b9e10475ee609c5e270de8fa41411da Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 18 Jul 2026 10:13:48 +0200 Subject: [PATCH 19/28] docs(sandbox): align proxy comments with the argv transport The argv migration left comments describing the configuration as reserved environment variables ("reserved value", "present-but-empty variable", "reserved upstream proxy variables"). Rephrase them as driver-supplied arguments and operator settings so the documented trust boundary matches the implementation. Comment-only change. Signed-off-by: Philippe Martin --- .../openshell-supervisor-network/src/proxy.rs | 4 +-- .../src/upstream_proxy.rs | 36 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 5ef0de76e7..14e7ce709c 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2945,8 +2945,8 @@ fn validate_declared_endpoint_resolved_addrs( /// /// 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 reserved upstream proxy variables -/// and not excluded by the reserved `NO_PROXY` list. Policy evaluation and +/// this destination via the driver-supplied upstream proxy arguments +/// and not excluded by the operator `NO_PROXY` list. Policy evaluation and /// SSRF validation must have already succeeded; only the final TCP dial /// changes. Plain-HTTP requests never take this path: they always dial the /// destination directly. diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 1f73738604..e88573086c 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -25,9 +25,9 @@ //! //! Scope and invariants: //! - Only `http://` proxy URLs are supported. Configuration is fail-closed: -//! any present-but-invalid reserved value — a present-but-empty variable, -//! an unsupported (`https://`, SOCKS) or malformed proxy URL, an unreadable -//! auth file, a malformed credential, or an auth file without the explicit +//! any present-but-invalid driver-supplied setting — an empty argument +//! value, an unsupported (`https://`, SOCKS) or malformed proxy URL, an +//! unreadable auth file, a malformed credential, or an auth file without the explicit //! cleartext-credential acknowledgement — is a fatal startup error rather //! than being silently ignored, so a typo can never quietly downgrade the //! operator's egress boundary to direct dialing or unauthenticated proxy @@ -38,10 +38,10 @@ //! direct-dial path; the corporate proxy only replaces the final TCP dial. //! - CONNECT requests target a validated resolved address by default, so the //! proxy performs no DNS resolution and the tunnel stays bound to the -//! answer that passed SSRF/`allowed_ips` validation. The reserved +//! answer that passed SSRF/`allowed_ips` validation. The //! `--upstream-proxy-connect-by-hostname` opt-in sends the hostname //! instead, for proxies whose ACLs filter on hostnames. -//! - The reserved `NO_PROXY` list decides which destinations bypass the +//! - The operator `NO_PROXY` list decides which destinations bypass the //! corporate proxy and keep dialing directly (cluster-internal services, //! host gateway, etc.). Loopback destinations always bypass the proxy. @@ -248,7 +248,7 @@ impl NoProxy { } } -/// How a validated destination must be dialed, per the reserved `NO_PROXY` +/// How a validated destination must be dialed, per the operator `NO_PROXY` /// contract. Produced by [`UpstreamProxyConfig::decision`]. #[derive(Debug)] pub enum ProxyDecision<'a> { @@ -438,7 +438,7 @@ impl UpstreamProxyConfig { )); } - // Load proxy credentials from the reserved auth file, if configured. + // Load proxy credentials from the configured auth file, if any. // The file is delivered through a root-only secret mount so the // credentials never appear in the environment or container metadata. if let Some(path) = auth_file { @@ -466,7 +466,7 @@ impl UpstreamProxyConfig { } /// How to dial the validated destination `(host, port, resolved)`, - /// honoring the reserved `NO_PROXY` list. + /// honoring the operator `NO_PROXY` list. /// /// Entries may carry a `:port` qualifier that limits them to that /// destination port. IP/CIDR entries also match a hostname through its @@ -889,7 +889,7 @@ mod tests { #[test] fn from_args_maps_cli_arguments_to_config() { // The driver-supplied argv is the authoritative source; the shared - // validation applies exactly as for the reserved-name lookup. + // validation applies to it exactly as in the flag-keyed lookup. let args = UpstreamProxyArgs { https_proxy: Some("http://proxy.corp.com:8080".to_string()), no_proxy: Some("*.svc.cluster.local".to_string()), @@ -910,8 +910,8 @@ mod tests { #[test] fn from_args_enforces_the_auth_file_acknowledgement_pairing() { - // An auth file without the acknowledgement is fatal, exactly as when - // the same pairing arrives via the reserved names. + // An auth file without the acknowledgement is fatal, exactly as in + // the flag-keyed lookup the driver validation mirrors. let args = UpstreamProxyArgs { https_proxy: Some("http://proxy.corp.com:8080".to_string()), proxy_auth_file: Some("/etc/openshell/auth/upstream-proxy".to_string()), @@ -947,10 +947,10 @@ mod tests { #[test] fn present_but_empty_values_are_fatal() { - // A reserved variable the operator did not set is absent, never - // empty: the driver only writes configured values. A present-but - // -blank value is therefore a misconfiguration and must not silently - // mean "no proxy". + // A setting the operator did not configure is absent, never empty: + // the driver only passes configured values. A present-but-blank + // value is therefore a misconfiguration and must not silently mean + // "no proxy". for (name, value) in [ (HTTPS_PROXY, ""), (HTTPS_PROXY, " "), @@ -998,9 +998,9 @@ mod tests { // -- Fail-closed configuration validation -- // - // Present-but-invalid reserved values must be fatal, never silently - // treated as unset: a typo must not downgrade the operator's egress - // boundary to direct dialing or unauthenticated proxy access. + // Present-but-invalid settings must be fatal, never silently treated + // as unset: a typo must not downgrade the operator's egress boundary + // to direct dialing or unauthenticated proxy access. #[test] fn tls_and_socks_proxies_are_fatal() { From 3192ee8cfc89e60c4a2df48ed7e1e50853593a33 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 18 Jul 2026 10:17:35 +0200 Subject: [PATCH 20/28] fix(sandbox): parse bracketed IPv6 authorities in client CONNECT targets parse_target split the CONNECT authority at the first colon, so an IPv6-literal target like [2001:db8::1]:443 always failed port parsing and IPv6-literal clients could never reach policy evaluation; a regression test even locked in that failure. Parse the RFC 3986 bracketed form and return the host bracket-free, matching what DNS resolution, SSRF validation, NO_PROXY matching, and the upstream CONNECT builder expect. Unclosed brackets, a missing or empty port after the bracket, and non-numeric ports are rejected; unbracketed behavior is unchanged. Replaces the failure-locking test with success coverage for bracketed targets and adds malformed-bracket rejection cases. Signed-off-by: Philippe Martin --- .../openshell-supervisor-network/src/proxy.rs | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 14e7ce709c..976d7106a3 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -4948,10 +4948,26 @@ async fn handle_forward_proxy( Ok(()) } +/// Parse a CONNECT authority into `(host, port)`. +/// +/// IPv6 literals use the RFC 3986 bracketed form (`[::1]:443`); the returned +/// host is bracket-free, matching what DNS resolution, SSRF validation, and +/// `NO_PROXY` matching expect. Host content is otherwise passed through +/// unvalidated — policy and resolution decide what it means. fn parse_target(target: &str) -> Result<(String, u16)> { - let (host, port_str) = target - .split_once(':') - .ok_or_else(|| miette::miette!("CONNECT target missing port: {target}"))?; + let (host, port_str) = if let Some(rest) = target.strip_prefix('[') { + let (host, after) = rest + .split_once(']') + .ok_or_else(|| miette::miette!("CONNECT target has unclosed '[': {target}"))?; + let port_str = after + .strip_prefix(':') + .ok_or_else(|| miette::miette!("CONNECT target missing port: {target}"))?; + (host, port_str) + } else { + target + .split_once(':') + .ok_or_else(|| miette::miette!("CONNECT target missing port: {target}"))? + }; let port: u16 = port_str .parse() .map_err(|_| miette::miette!("Invalid port in CONNECT target: {target}"))?; @@ -8337,11 +8353,30 @@ network_policies: } #[test] - fn test_parse_target_ipv6_bracket_notation_fails() { - assert!( - parse_target("[::1]:443").is_err(), - "split_once splits at first colon inside brackets — port parse fails" - ); + fn test_parse_target_ipv6_bracket_notation() { + let (host, port) = parse_target("[::1]:443").unwrap(); + assert_eq!(host, "::1", "brackets are stripped from the parsed host"); + assert_eq!(port, 443); + + let (host, port) = parse_target("[2001:db8::1]:8443").unwrap(); + assert_eq!(host, "2001:db8::1"); + assert_eq!(port, 8443); + } + + #[test] + fn test_parse_target_rejects_malformed_ipv6_brackets() { + for target in [ + // Unclosed bracket. + "[::1:443", + // No port after the bracket. + "[::1]", + "[::1]443", + // Empty or non-numeric port. + "[::1]:", + "[::1]:notaport", + ] { + assert!(parse_target(target).is_err(), "{target} should be rejected"); + } } // -- parse_proxy_uri: hostname parser regression tests -- From 19f236953e57cbfa183d31eba4cbca24d18960e5 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 18 Jul 2026 10:26:02 +0200 Subject: [PATCH 21/28] test(podman): cover proxy-auth secret cleanup across lifecycle failures The per-sandbox proxy-auth credential secret is staged before the container is created and removed on cleanup, but no test proved the cleanup paths actually issue the secret removal. Add Podman-stub tests that drive create_sandbox to a container-create failure and to a start failure, and delete_sandbox for an out-of-band deletion, asserting each path issues the DELETE for the per-sandbox proxy-auth secret so a credential can never outlive the sandbox that owned it. Signed-off-by: Philippe Martin --- crates/openshell-driver-podman/src/driver.rs | 153 ++++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index c30cfe901b..ff1594304b 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -906,7 +906,7 @@ mod tests { }; use std::collections::HashMap; use std::fs; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; fn cdi_devices_config(device_ids: &[&str]) -> prost_types::Struct { prost_types::Struct { @@ -1554,6 +1554,157 @@ mod tests { let _ = fs::remove_file(socket_path); } + /// Write a valid `user:pass` credential to a unique path for proxy-auth + /// secret tests. Caller removes it. + fn write_proxy_auth_file(test_name: &str) -> PathBuf { + let path = crate::test_utils::unique_socket_path(test_name).with_extension("auth"); + fs::write(&path, "user:pass\n").expect("write proxy auth file"); + path + } + + fn proxy_auth_config(socket_path: PathBuf, auth_file: &Path) -> PodmanComputeConfig { + PodmanComputeConfig { + socket_path, + stop_timeout_secs: 10, + proxy_auth_file: Some(auth_file.to_string_lossy().into_owned()), + proxy_auth_allow_insecure: Some(true), + ..PodmanComputeConfig::default() + } + } + + fn plain_sandbox(id: &str, name: &str) -> DriverSandbox { + DriverSandbox { + id: id.to_string(), + name: name.to_string(), + namespace: String::new(), + spec: None, + status: None, + } + } + + fn secret_delete_request(sandbox_id: &str) -> String { + format!( + "DELETE {}", + api_path(&format!( + "/libpod/secrets/{}", + container::proxy_auth_secret_name(sandbox_id) + )) + ) + } + + #[tokio::test] + async fn create_sandbox_removes_proxy_auth_secret_on_container_create_failure() { + // A credential secret is staged before the container is created, so a + // container-create failure must remove it — no credential residue. + let sandbox_id = "sandbox-cc"; + let auth_file = write_proxy_auth_file("create-fail"); + let (socket_path, request_log, handle) = spawn_podman_stub( + "create-container-fail", + vec![ + StubResponse::new(StatusCode::OK, "{}"), // pull supervisor image + StubResponse::new(StatusCode::OK, "{}"), // pull sandbox image + StubResponse::new(StatusCode::CREATED, "{}"), // create volume + StubResponse::new(StatusCode::CREATED, "{}"), // create proxy-auth secret + StubResponse::new(StatusCode::INTERNAL_SERVER_ERROR, r#"{"message":"boom"}"#), // create container + StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove volume + StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove proxy-auth secret + ], + ); + let driver = test_driver_with_config(proxy_auth_config(socket_path.clone(), &auth_file)); + + driver + .create_sandbox(&plain_sandbox(sandbox_id, "demo")) + .await + .expect_err("container create should fail"); + + handle.await.expect("stub task should finish"); + let requests = request_log + .lock() + .expect("request log lock should not be poisoned") + .clone(); + assert!( + requests.contains(&secret_delete_request(sandbox_id)), + "proxy-auth secret must be removed on container-create failure: {requests:?}" + ); + let _ = fs::remove_file(&auth_file); + let _ = fs::remove_file(socket_path); + } + + #[tokio::test] + async fn create_sandbox_removes_proxy_auth_secret_on_start_failure() { + // The container is created but fails to start; the staged credential + // secret must still be removed. + let sandbox_id = "sandbox-sf"; + let auth_file = write_proxy_auth_file("start-fail"); + let (socket_path, request_log, handle) = spawn_podman_stub( + "create-start-fail", + vec![ + StubResponse::new(StatusCode::OK, "{}"), // pull supervisor image + StubResponse::new(StatusCode::OK, "{}"), // pull sandbox image + StubResponse::new(StatusCode::CREATED, "{}"), // create volume + StubResponse::new(StatusCode::CREATED, "{}"), // create proxy-auth secret + StubResponse::new(StatusCode::CREATED, "{}"), // create container + StubResponse::new(StatusCode::INTERNAL_SERVER_ERROR, r#"{"message":"boom"}"#), // start container + StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove container + StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove volume + StubResponse::new(StatusCode::NO_CONTENT, ""), // cleanup: remove proxy-auth secret + ], + ); + let driver = test_driver_with_config(proxy_auth_config(socket_path.clone(), &auth_file)); + + driver + .create_sandbox(&plain_sandbox(sandbox_id, "demo")) + .await + .expect_err("container start should fail"); + + handle.await.expect("stub task should finish"); + let requests = request_log + .lock() + .expect("request log lock should not be poisoned") + .clone(); + assert!( + requests.contains(&secret_delete_request(sandbox_id)), + "proxy-auth secret must be removed on start failure: {requests:?}" + ); + let _ = fs::remove_file(&auth_file); + let _ = fs::remove_file(socket_path); + } + + #[tokio::test] + async fn delete_sandbox_removes_proxy_auth_secret() { + // Deleting a sandbox (here already gone out of band) must remove the + // per-sandbox proxy-auth secret so credentials never outlive it. + let sandbox_id = "sandbox-del"; + let (socket_path, request_log, handle) = spawn_podman_stub( + "delete-proxy-auth", + vec![ + StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), // inspect + StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), // stop + StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), // remove container + StubResponse::new(StatusCode::NO_CONTENT, ""), // remove volume + StubResponse::new(StatusCode::NO_CONTENT, ""), // remove token secret + StubResponse::new(StatusCode::NO_CONTENT, ""), // remove proxy-auth secret + ], + ); + let driver = test_driver(socket_path.clone()); + + driver + .delete_sandbox(sandbox_id, "demo") + .await + .expect("delete should succeed"); + + handle.await.expect("stub task should finish"); + let requests = request_log + .lock() + .expect("request log lock should not be poisoned") + .clone(); + assert!( + requests.contains(&secret_delete_request(sandbox_id)), + "proxy-auth secret must be removed on delete: {requests:?}" + ); + let _ = fs::remove_file(socket_path); + } + #[tokio::test] async fn delete_sandbox_uses_request_id_when_container_label_disagrees() { let sandbox_id = "sandbox-request-id"; From 80e4ac305637adf971b0bfb6f47494059526fffa Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 18 Jul 2026 10:26:59 +0200 Subject: [PATCH 22/28] docs: list corporate proxy keys in the Podman compute-driver overview The Fern Podman driver section enumerated its gateway.toml keys but omitted the corporate egress proxy settings. Add https_proxy, no_proxy, proxy_auth_file, proxy_auth_allow_insecure, and proxy_connect_by_hostname with a pointer to the gateway configuration reference for the full contract. No navigation change: the reference folder already includes the gateway configuration page. Signed-off-by: Philippe Martin --- docs/reference/sandbox-compute-drivers.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index a0167ae72a..8d25b749af 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -182,6 +182,8 @@ For maintainer-level implementation details, refer to the [Podman driver README] Select Podman with `compute_drivers = ["podman"]` in `[openshell.gateway]`. Configure Podman driver values such as `socket_path`, `network_name`, `supervisor_image`, `stop_timeout_secs`, `image_pull_policy`, `grpc_endpoint`, `host_gateway_ip`, `sandbox_ssh_socket_path`, `sandbox_pids_limit`, and `guest_tls_*` in `[openshell.drivers.podman]`. +For proxy-required networks, the Podman driver also accepts the corporate egress proxy keys `https_proxy`, `no_proxy`, `proxy_auth_file`, `proxy_auth_allow_insecure`, and `proxy_connect_by_hostname`. The supervisor chains policy-approved TLS tunnels through the proxy with HTTP CONNECT instead of dialing destinations directly. See the [Gateway Configuration File](./gateway-config) reference for the full contract, including the cleartext-credential acknowledgement and the validated-IP CONNECT behavior. + On macOS with `podman machine`, the driver uses gvproxy's host-loopback IP, `192.168.127.254`, for sandbox host aliases by default. Set `host_gateway_ip` only when your Podman machine uses a non-standard host-loopback address. On Linux, an empty `host_gateway_ip` keeps Podman's `host-gateway` resolver behavior. ### Podman Driver Config Mounts From c8434ad4a6001c2460606d63bc2cacea89d846bb Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 18 Jul 2026 10:32:33 +0200 Subject: [PATCH 23/28] test(sandbox): cover the SSRF-to-TLS composition across the proxy tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing tests exercised validated-IP CONNECT and the upstream-TLS helper independently, but not the full boundary. Add an end-to-end regression that stands up a fake corporate proxy tunneling to a fake TLS server and drives the real path: connect_via_validated CONNECTs to the validated address, the proxy splices the tunnel, and tls_connect_upstream verifies the upstream certificate against the original hostname carried in SNI. It asserts the CONNECT authority is the validated IP and never the hostname, that verification succeeds for the matching hostname, and that a mismatched hostname is rejected — proving a rebinding or split-horizon substitution behind the proxy cannot pass certificate verification. Signed-off-by: Philippe Martin --- .../src/upstream_proxy.rs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index e88573086c..6e035b035b 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -1555,6 +1555,118 @@ mod tests { assert_eq!(err.kind(), ErrorKind::InvalidInput); } + #[tokio::test] + async fn validated_ip_connect_tunnels_to_upstream_tls_verified_by_hostname() { + // End-to-end composition of the SSRF-to-TLS boundary: a validated + // address is CONNECTed to the corporate proxy (never the hostname), + // the proxy tunnels to the destination, and upstream TLS is verified + // against the original hostname carried in SNI. Covers both a + // matching-hostname success and a mismatched-certificate rejection. + use crate::l7::tls; + use std::sync::{Arc, Mutex}; + use tokio::io::copy_bidirectional; + + const SERVER_HOSTNAME: &str = "upstream.example.test"; + + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Trusted CA; the client config trusts it, and the fake upstream + // server presents a leaf for SERVER_HOSTNAME signed by it. + let ca = tls::SandboxCa::generate().unwrap(); + let client_config = tls::build_upstream_client_config(ca.cert_pem()); + let tls_state = Arc::new(tls::ProxyTlsState::new( + tls::CertCache::new(ca), + tls::build_upstream_client_config(""), + )); + + // Fake upstream TLS server: accepts tunneled connections and completes + // a TLS handshake presenting the SERVER_HOSTNAME cert. + let tls_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let tls_addr = tls_listener.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((conn, _)) = tls_listener.accept().await { + let state = tls_state.clone(); + tokio::spawn(async move { + let _ = tls::tls_terminate_client(conn, &state, SERVER_HOSTNAME).await; + }); + } + }); + + // Fake corporate proxy: records each CONNECT request line and splices + // the tunnel to the upstream TLS server. + let proxy_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_addr = proxy_listener.local_addr().unwrap(); + let connect_lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + { + let connect_lines = connect_lines.clone(); + tokio::spawn(async move { + while let Ok((mut client, _)) = proxy_listener.accept().await { + let connect_lines = connect_lines.clone(); + tokio::spawn(async move { + let request = read_request(&mut client).await; + connect_lines + .lock() + .unwrap() + .push(request.lines().next().unwrap_or_default().to_string()); + client + .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + if let Ok(mut upstream) = TcpStream::connect(tls_addr).await { + let _ = copy_bidirectional(&mut client, &mut upstream).await; + } + }); + } + }); + } + + let endpoint = endpoint_for(proxy_addr, None); + + // Success: CONNECT to the validated IP, TLS verified against the + // requested hostname. + let stream = + connect_via_validated(&endpoint, SERVER_HOSTNAME, tls_addr.port(), &[tls_addr]) + .await + .unwrap(); + tls::tls_connect_upstream(stream, SERVER_HOSTNAME, &client_config) + .await + .expect("TLS must verify for the requested hostname"); + + // The proxy saw the validated IP in the CONNECT authority, never the + // hostname — the tunnel is bound to the SSRF-validated address. + { + let lines = connect_lines.lock().unwrap(); + let line = lines.last().expect("proxy received a CONNECT"); + assert!( + line.starts_with(&format!("CONNECT {}:", tls_addr.ip())), + "CONNECT must target the validated IP: {line}" + ); + assert!( + !line.contains(SERVER_HOSTNAME), + "hostname must not appear in the CONNECT authority: {line}" + ); + } + + // Mismatch: the same validated tunnel, but verifying a different + // hostname must fail — the server cert is only valid for + // SERVER_HOSTNAME, so a rebinding/split-horizon substitution cannot + // pass certificate verification. + let stream = connect_via_validated( + &endpoint, + "wrong.example.test", + tls_addr.port(), + &[tls_addr], + ) + .await + .unwrap(); + assert!( + tls::tls_connect_upstream(stream, "wrong.example.test", &client_config) + .await + .is_err(), + "TLS must reject a certificate that does not match the requested hostname" + ); + } + #[tokio::test] async fn connect_via_sends_hostname_on_operator_opt_in() { let (addr, handle) = fake_proxy("HTTP/1.1 200 Connection established\r\n\r\n").await; From 4524ac91d7a6ca281382ad40bec7d4b1e0bed373 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 18 Jul 2026 15:21:01 +0200 Subject: [PATCH 24/28] fix(sandbox,podman): bound proxy-auth reads, reject port 0, fix stale comment Three review findings: - CWE-400: the proxy-auth credential file was read with an unbounded read_to_string on both the driver (sandbox-create) and supervisor (startup) paths, so a huge file or a special file such as /dev/zero could exhaust memory. Add a shared bounded reader in openshell-core that rejects non-regular files, caps the size at 4 KiB, and reads at most that many bytes; the driver runs it via spawn_blocking. Covers oversized, special-file, and missing-path cases on both sides. - Reject an upstream proxy URL with port 0: it passed the explicit-port check and startup validation but is not a connectable TCP port, so every proxied dial would fail later. Add a typed ZeroPort error with shared-validator and Podman-config tests. - Reword a driver-config comment that still described a 'reserved variable' to match the argv transport. Signed-off-by: Philippe Martin --- crates/openshell-core/src/driver_utils.rs | 123 +++++++++++++++++- crates/openshell-driver-podman/src/config.rs | 14 +- crates/openshell-driver-podman/src/driver.rs | 13 +- .../src/upstream_proxy.rs | 21 ++- 4 files changed, 156 insertions(+), 15 deletions(-) diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 7d7393b924..0ea17fd286 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -125,6 +125,11 @@ pub enum UpstreamProxyUrlError { /// silently dialing port 80. #[error("proxy URL must include an explicit proxy port, e.g. http://proxy.corp.com:3128")] MissingPort, + /// The URL specifies port `0`, which is not a connectable TCP port. It + /// would pass startup validation but fail every proxied dial, so it is + /// rejected up front. + #[error("proxy URL port must not be 0")] + ZeroPort, /// The URL embeds `user:pass@` credentials, which would leak into config /// and container metadata. Credentials must come from the proxy auth file. #[error("proxy URL must not embed credentials; supply them via the proxy auth file")] @@ -194,13 +199,14 @@ pub fn parse_upstream_proxy_url(raw: &str) -> Result Result<&str, UpstreamProxyC } } +/// Hard upper bound on the size of a proxy-auth credential file. +/// +/// A `user:pass` credential is tiny; this cap only exists to stop a hostile +/// or misconfigured path (a huge file, or a special file such as +/// `/dev/zero`) from exhausting memory during a bounded read. +pub const MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES: u64 = 4096; + +/// Read a proxy-auth credential file with a hard size bound. +/// +/// Rejects non-regular files (e.g. `/dev/zero`, directories, FIFOs) and +/// files larger than [`MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES`], and reads at +/// most that many bytes, so a hostile or misconfigured path cannot exhaust +/// gateway or supervisor memory. Returns the raw contents; callers pass the +/// result to [`parse_upstream_proxy_credential`]. +/// +/// Shared by the compute driver (at sandbox-create time) and the in-container +/// supervisor so both enforce the same bound. This is a blocking read; async +/// callers should wrap it (e.g. `tokio::task::spawn_blocking`). +/// +/// # Errors +/// +/// Returns a descriptive error (never containing file contents) when the path +/// cannot be opened or stat'd, is not a regular file, or exceeds the size +/// bound. +pub fn read_upstream_proxy_credential_file(path: &str) -> Result { + use std::io::Read as _; + + let file = std::fs::File::open(path) + .map_err(|e| format!("failed to open proxy auth file '{path}': {e}"))?; + let metadata = file + .metadata() + .map_err(|e| format!("failed to stat proxy auth file '{path}': {e}"))?; + if !metadata.is_file() { + return Err(format!("proxy auth file '{path}' is not a regular file")); + } + if metadata.len() > MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES { + return Err(format!( + "proxy auth file '{path}' exceeds the {MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES}-byte limit" + )); + } + // Bound the read even if the file grows between stat and read. + let mut buf = String::new(); + file.take(MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES + 1) + .read_to_string(&mut buf) + .map_err(|e| format!("failed to read proxy auth file '{path}': {e}"))?; + if buf.len() as u64 > MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES { + return Err(format!( + "proxy auth file '{path}' exceeds the {MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES}-byte limit" + )); + } + Ok(buf) +} + /// Return the XDG state path for a driver's sandbox JWT token file. /// /// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. @@ -423,6 +482,19 @@ mod tests { assert_eq!(addr.port, 80); } + #[test] + fn upstream_proxy_url_rejects_zero_port() { + // Port 0 parses as an explicit port but is not connectable; reject it + // up front instead of failing every proxied dial later. + for url in ["http://proxy.corp.com:0", "http://[fd00::1]:0"] { + assert_eq!( + parse_upstream_proxy_url(url), + Err(UpstreamProxyUrlError::ZeroPort), + "{url}" + ); + } + } + #[test] fn upstream_proxy_url_ipv6_host_is_bracket_free() { let addr = parse_upstream_proxy_url("http://[fd00::1]:8080").unwrap(); @@ -528,4 +600,41 @@ mod tests { Err(UpstreamProxyCredentialError::EmptyUser) ); } + + #[test] + fn credential_file_reads_within_the_size_bound() { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "user:pass\n").unwrap(); + let raw = read_upstream_proxy_credential_file(file.path().to_str().unwrap()).unwrap(); + assert_eq!(parse_upstream_proxy_credential(&raw), Ok("user:pass")); + } + + #[test] + fn credential_file_rejects_oversized_files() { + let file = tempfile::NamedTempFile::new().unwrap(); + let huge = vec![b'a'; usize::try_from(MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES + 1).unwrap()]; + std::fs::write(file.path(), &huge).unwrap(); + let err = read_upstream_proxy_credential_file(file.path().to_str().unwrap()).unwrap_err(); + assert!(err.contains("limit"), "{err}"); + } + + #[test] + fn credential_file_rejects_non_regular_files() { + // A directory is a non-regular path; /dev/zero would be rejected the + // same way (not a regular file) without risking an unbounded read. + let dir = tempfile::tempdir().unwrap(); + let err = read_upstream_proxy_credential_file(dir.path().to_str().unwrap()).unwrap_err(); + assert!(err.contains("regular file"), "{err}"); + + if std::path::Path::new("/dev/zero").exists() { + let err = read_upstream_proxy_credential_file("/dev/zero").unwrap_err(); + assert!(err.contains("regular file"), "{err}"); + } + } + + #[test] + fn credential_file_missing_path_is_an_error() { + let err = read_upstream_proxy_credential_file("/nonexistent/proxy-auth").unwrap_err(); + assert!(err.contains("open proxy auth file"), "{err}"); + } } diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index b528b5011f..4411786787 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -269,8 +269,8 @@ impl PodmanComputeConfig { })?; } - // The supervisor treats a present-but-empty reserved variable as a - // fatal misconfiguration, so never accept (and later inject) one. + // The supervisor treats a present-but-empty driver-supplied argument + // as a fatal misconfiguration, so never accept (and later pass) one. if let Some(list) = self.no_proxy.as_deref() { if list.trim().is_empty() { return Err(crate::client::PodmanApiError::InvalidInput( @@ -598,6 +598,16 @@ mod tests { } } + #[test] + fn validate_proxy_config_rejects_zero_port() { + let cfg = PodmanComputeConfig { + https_proxy: Some("http://proxy.corp.com:0".to_string()), + ..PodmanComputeConfig::default() + }; + let err = cfg.validate_proxy_config().unwrap_err(); + assert!(err.to_string().contains("port must not be 0"), "{err}"); + } + #[test] fn validate_proxy_config_rejects_missing_scheme_or_port() { // A scheme-less value (previously normalized to http://) and a diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index ff1594304b..7c02126ce8 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -132,9 +132,16 @@ async fn create_sandbox_proxy_auth_secret( return Ok(None); }; - let raw = tokio::fs::read_to_string(path).await.map_err(|e| { - ComputeDriverError::Message(format!("failed to read proxy_auth_file '{path}': {e}")) - })?; + // Bounded, blocking read shared with the supervisor: rejects non-regular + // files (e.g. /dev/zero) and caps the size so a hostile path cannot + // exhaust gateway memory. + let path_owned = path.to_string(); + let raw = tokio::task::spawn_blocking(move || { + openshell_core::driver_utils::read_upstream_proxy_credential_file(&path_owned) + }) + .await + .map_err(|e| ComputeDriverError::Message(format!("proxy_auth_file read task failed: {e}")))? + .map_err(ComputeDriverError::Message)?; let credential = openshell_core::driver_utils::parse_upstream_proxy_credential(&raw).map_err(|err| { ComputeDriverError::InvalidArgument(format!("proxy_auth_file '{path}': {err}")) diff --git a/crates/openshell-supervisor-network/src/upstream_proxy.rs b/crates/openshell-supervisor-network/src/upstream_proxy.rs index 6e035b035b..50d2eb0f9c 100644 --- a/crates/openshell-supervisor-network/src/upstream_proxy.rs +++ b/crates/openshell-supervisor-network/src/upstream_proxy.rs @@ -442,9 +442,8 @@ impl UpstreamProxyConfig { // The file is delivered through a root-only secret mount so the // credentials never appear in the environment or container metadata. if let Some(path) = auth_file { - let credential = std::fs::read_to_string(&path).map_err(|err| { - format!("failed to read upstream proxy auth file '{path}': {err}") - })?; + let credential = + openshell_core::driver_utils::read_upstream_proxy_credential_file(&path)?; let header = basic_auth_header(&credential).map_err(|err| { format!("invalid credential in upstream proxy auth file '{path}': {err}") })?; @@ -1051,6 +1050,22 @@ mod tests { assert!(err.contains("auth file"), "{err}"); } + #[test] + fn oversized_auth_file_is_fatal() { + // The supervisor uses the same bounded reader as the driver, so an + // oversized credential file is rejected rather than read unbounded. + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), vec![b'a'; 8192]).unwrap(); + let path = file.path().to_str().unwrap().to_string(); + let err = config_from(&[ + (HTTPS_PROXY, "http://proxy.corp.com:8080"), + (PROXY_AUTH_FILE, &path), + (PROXY_AUTH_ALLOW_INSECURE, "true"), + ]) + .unwrap_err(); + assert!(err.contains("limit"), "{err}"); + } + #[test] fn auth_file_without_insecure_acknowledgement_is_fatal() { // Basic auth over the plain-TCP proxy connection is readable on the From 1f9fc5ba360905a33d52fbb045e406807314dd9b Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sat, 18 Jul 2026 16:39:21 +0200 Subject: [PATCH 25/28] fix(sandbox): open proxy-auth file non-blocking to reject FIFOs promptly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_upstream_proxy_credential_file opened the path with a blocking File::open before the regular-file check, so a configured FIFO with no writer would block open() indefinitely — hanging sandbox creation on the driver and supervisor startup. Open with O_NONBLOCK on Unix so the open returns immediately, then reject the non-regular file as before; O_NONBLOCK has no effect on the later read of a regular file. Adds a mkfifo regression asserting the reader returns promptly with a non-regular-file error instead of hanging. Signed-off-by: Philippe Martin --- Cargo.lock | 1 + crates/openshell-core/Cargo.toml | 3 ++ crates/openshell-core/src/driver_utils.rs | 36 +++++++++++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b412467610..4b98ba0736 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3889,6 +3889,7 @@ dependencies = [ "glob", "ipnet", "miette", + "nix", "prost", "prost-types", "protobuf-src", diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 53735a6bb8..4790390674 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -28,6 +28,9 @@ base64 = { workspace = true } chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true } reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots"], optional = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true } + [features] default = ["telemetry"] ## Compile in anonymous telemetry emission support. On by default; disable with diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 0ea17fd286..96b5444609 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -320,8 +320,21 @@ pub const MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES: u64 = 4096; pub fn read_upstream_proxy_credential_file(path: &str) -> Result { use std::io::Read as _; - let file = std::fs::File::open(path) - .map_err(|e| format!("failed to open proxy auth file '{path}': {e}"))?; + // On Unix, open non-blocking so a FIFO with no writer does not hang the + // open() call indefinitely; the regular-file check below then rejects it. + // O_NONBLOCK has no effect on the subsequent read of a regular file. + #[cfg(unix)] + let open_result = { + use std::os::unix::fs::OpenOptionsExt as _; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NONBLOCK) + .open(path) + }; + #[cfg(not(unix))] + let open_result = std::fs::File::open(path); + + let file = open_result.map_err(|e| format!("failed to open proxy auth file '{path}': {e}"))?; let metadata = file .metadata() .map_err(|e| format!("failed to stat proxy auth file '{path}': {e}"))?; @@ -637,4 +650,23 @@ mod tests { let err = read_upstream_proxy_credential_file("/nonexistent/proxy-auth").unwrap_err(); assert!(err.contains("open proxy auth file"), "{err}"); } + + #[cfg(unix)] + #[test] + fn credential_file_rejects_fifo_without_hanging() { + // A FIFO with no writer would block a blocking open() forever. The + // reader opens non-blocking and rejects the non-regular file, so it + // must return promptly even though nothing ever opens the write end. + let dir = tempfile::tempdir().unwrap(); + let fifo = dir.path().join("proxy-auth-fifo"); + nix::unistd::mkfifo(&fifo, nix::sys::stat::Mode::S_IRUSR).unwrap(); + + let start = std::time::Instant::now(); + let err = read_upstream_proxy_credential_file(fifo.to_str().unwrap()).unwrap_err(); + assert!(err.contains("regular file"), "{err}"); + assert!( + start.elapsed() < std::time::Duration::from_secs(5), + "reading a FIFO must not block" + ); + } } From 2b18a75e3f403ba1746c1f2eaad243ecbf9ee237 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Sun, 19 Jul 2026 12:02:56 +0200 Subject: [PATCH 26/28] test(podman): cover corporate proxy egress across driver and supervisor The existing corporate-proxy tests construct config structs or call CONNECT helpers directly, so none of them detect a break in the wiring between layers: gateway TOML deserialization, the Podman argv and secret-mount semantics, supervisor CLI parsing, or policy denial before proxy contact. Add a Podman e2e that drives the whole chain against a fake authenticated forward proxy and asserts that an approved TLS request traverses it with a validated-IP CONNECT, a policy-denied destination is refused with 403 without ever reaching the proxy, credentials arrive through the mounted per-sandbox secret, and deleting the sandbox removes that secret. SupportContainer is a new harness fixture. Unlike ContainerHttpServer it probes readiness with a TCP connect rather than an HTTP GET, so it can host a forward proxy and TLS servers, and it exposes container logs and network IP for assertions. Signed-off-by: Philippe Martin --- e2e/rust/Cargo.toml | 5 + e2e/rust/src/harness/container.rs | 143 ++++++ e2e/rust/tests/podman_corporate_proxy.rs | 585 +++++++++++++++++++++++ 3 files changed, 733 insertions(+) create mode 100644 e2e/rust/tests/podman_corporate_proxy.rs diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 6e8fa70bdf..b5f8e5f634 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -62,6 +62,11 @@ name = "podman_gateway_resume" path = "tests/podman_gateway_resume.rs" required-features = ["e2e-podman"] +[[test]] +name = "podman_corporate_proxy" +path = "tests/podman_corporate_proxy.rs" +required-features = ["e2e-podman"] + [[test]] name = "vm_gateway_resume" path = "tests/vm_gateway_resume.rs" diff --git a/e2e/rust/src/harness/container.rs b/e2e/rust/src/harness/container.rs index e4511e00b9..764ee6d0f6 100644 --- a/e2e/rust/src/harness/container.rs +++ b/e2e/rust/src/harness/container.rs @@ -182,6 +182,149 @@ impl ContainerHttpServer { } } +/// A generic support container running on the shared e2e container network. +/// +/// Unlike [`ContainerHttpServer`], this helper requires the network mode used +/// by the Docker/Podman gateway lanes (`OPENSHELL_E2E_NETWORK_NAME`), probes +/// readiness with a plain TCP connect instead of an HTTP GET (so it can host +/// non-HTTP fixtures such as forward proxies and TLS servers), and exposes the +/// container's logs and network IP for test assertions. +pub struct SupportContainer { + /// Network alias other containers on the e2e network reach this one by. + pub alias: String, + container_id: String, + network: String, + engine: ContainerEngine, +} + +impl SupportContainer { + /// Start a `python3 -c