From 934aadb92d39fcc35d733821c907ce54ee467370 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 12 Mar 2026 17:24:22 -0700 Subject: [PATCH] fix(sandbox): opt Node clients into proxy env support --- architecture/sandbox-connect.md | 2 +- architecture/sandbox.md | 3 +- crates/navigator-sandbox/src/child_env.rs | 77 +++++++++++++++++++++++ crates/navigator-sandbox/src/lib.rs | 5 +- crates/navigator-sandbox/src/process.rs | 39 +++++------- crates/navigator-sandbox/src/ssh.rs | 17 +++-- 6 files changed, 106 insertions(+), 37 deletions(-) create mode 100644 crates/navigator-sandbox/src/child_env.rs diff --git a/architecture/sandbox-connect.md b/architecture/sandbox-connect.md index 3378273c9..784276f31 100644 --- a/architecture/sandbox-connect.md +++ b/architecture/sandbox-connect.md @@ -415,7 +415,7 @@ Authorization is performed by the gateway (token validation + sandbox readiness 2. Clones the master fd for reading and writing 3. Configures the shell command with environment variables: - `OPENSHELL_SANDBOX=1`, `HOME=/sandbox`, `USER=sandbox`, `TERM=` - - Proxy vars: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `http_proxy`, `https_proxy`, `grpc_proxy` + - Proxy vars: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `http_proxy`, `https_proxy`, `grpc_proxy`, `NODE_USE_ENV_PROXY=1` so Node.js `fetch` honors the proxy env - TLS trust vars: `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` - Provider credential env vars (from the provider registry) 4. Installs a `pre_exec` hook that: diff --git a/architecture/sandbox.md b/architecture/sandbox.md index b286737c3..4de7d25c2 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -929,7 +929,7 @@ Wraps `tokio::process::Child` + PID. Platform-specific `spawn()` methods delegat **Environment setup** (both Linux and non-Linux): - `OPENSHELL_SANDBOX=1` (always set) - Provider credentials (from `GetSandboxProviderEnvironment` RPC) -- Proxy URLs: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` (uppercase for curl/wget), `http_proxy`, `https_proxy`, `grpc_proxy` (lowercase for gRPC C-core) +- Proxy URLs: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` (uppercase for curl/wget), `http_proxy`, `https_proxy`, `grpc_proxy` (lowercase for gRPC C-core), `NODE_USE_ENV_PROXY=1` (required for Node.js built-in `fetch`/`http` clients to honor proxy env vars) - TLS trust store: `NODE_EXTRA_CA_CERTS` (standalone CA cert), `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` (combined bundle) **Pre-exec closure** (runs in child after fork, before exec -- async-signal-safe): @@ -1057,6 +1057,7 @@ This two-phase approach (peek with `WNOWAIT`, then selectively reap) avoids `ECH | `OPENSHELL_SANDBOX` | Always `"1"` -- signals the process is sandboxed | | `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` | Proxy URL (uppercase, for curl/wget) | | `http_proxy` / `https_proxy` / `grpc_proxy` | Proxy URL (lowercase, for gRPC C-core) | +| `NODE_USE_ENV_PROXY` | Set to `1` so Node.js built-in `fetch`/`http` clients honor proxy env vars | | `NODE_EXTRA_CA_CERTS` | Path to sandbox CA cert PEM (Node.js, additive) | | `SSL_CERT_FILE` | Combined CA bundle path (OpenSSL/Python/Go) | | `REQUESTS_CA_BUNDLE` | Combined CA bundle path (Python requests) | diff --git a/crates/navigator-sandbox/src/child_env.rs b/crates/navigator-sandbox/src/child_env.rs new file mode 100644 index 000000000..d6c2329f1 --- /dev/null +++ b/crates/navigator-sandbox/src/child_env.rs @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::path::Path; + +pub(crate) fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 7] { + [ + ("ALL_PROXY", proxy_url.to_owned()), + ("HTTP_PROXY", proxy_url.to_owned()), + ("HTTPS_PROXY", proxy_url.to_owned()), + ("http_proxy", proxy_url.to_owned()), + ("https_proxy", proxy_url.to_owned()), + ("grpc_proxy", proxy_url.to_owned()), + // Node.js only honors HTTP(S)_PROXY for built-in fetch/http clients when + // proxy support is explicitly enabled at process startup. + ("NODE_USE_ENV_PROXY", "1".to_owned()), + ] +} + +pub(crate) fn tls_env_vars( + ca_cert_path: &Path, + combined_bundle_path: &Path, +) -> [(&'static str, String); 4] { + let ca_cert_path = ca_cert_path.display().to_string(); + let combined_bundle_path = combined_bundle_path.display().to_string(); + [ + ("NODE_EXTRA_CA_CERTS", ca_cert_path.clone()), + ("SSL_CERT_FILE", combined_bundle_path.clone()), + ("REQUESTS_CA_BUNDLE", combined_bundle_path.clone()), + ("CURL_CA_BUNDLE", combined_bundle_path), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use std::process::Stdio; + + #[test] + fn apply_proxy_env_includes_node_proxy_opt_in() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + for (key, value) in proxy_env_vars("http://10.200.0.1:3128") { + cmd.env(key, value); + } + + let output = cmd.output().expect("spawn env"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + + assert!(stdout.contains("HTTP_PROXY=http://10.200.0.1:3128")); + assert!(stdout.contains("NODE_USE_ENV_PROXY=1")); + } + + #[test] + fn apply_tls_env_sets_node_and_bundle_paths() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + let ca_cert_path = Path::new("/etc/navigator-tls/navigator-ca.pem"); + let combined_bundle_path = Path::new("/etc/navigator-tls/ca-bundle.pem"); + for (key, value) in tls_env_vars(ca_cert_path, combined_bundle_path) { + cmd.env(key, value); + } + + let output = cmd.output().expect("spawn env"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + + assert!(stdout.contains("NODE_EXTRA_CA_CERTS=/etc/navigator-tls/navigator-ca.pem")); + assert!(stdout.contains("SSL_CERT_FILE=/etc/navigator-tls/ca-bundle.pem")); + } +} diff --git a/crates/navigator-sandbox/src/lib.rs b/crates/navigator-sandbox/src/lib.rs index 39de09aa1..7593ae68b 100644 --- a/crates/navigator-sandbox/src/lib.rs +++ b/crates/navigator-sandbox/src/lib.rs @@ -5,6 +5,7 @@ //! //! This crate provides process sandboxing and monitoring capabilities. +mod child_env; pub mod denial_aggregator; mod grpc_client; mod identity; @@ -339,8 +340,8 @@ pub async fn run_sandbox( // SSH shell processes need both to enforce network policy: // - netns_fd: enter the network namespace via setns() so all traffic // goes through the veth pair (hard enforcement, non-bypassable) - // - proxy_url: set HTTP_PROXY/HTTPS_PROXY/ALL_PROXY env vars so - // cooperative tools (curl, etc.) route through the CONNECT proxy + // - proxy_url: set proxy env vars so cooperative tools route through the + // CONNECT proxy; this also opts Node.js into honoring those vars #[cfg(target_os = "linux")] let ssh_netns_fd = netns.as_ref().and_then(NetworkNamespace::ns_fd); diff --git a/crates/navigator-sandbox/src/process.rs b/crates/navigator-sandbox/src/process.rs index ca4477a41..635bcbcfa 100644 --- a/crates/navigator-sandbox/src/process.rs +++ b/crates/navigator-sandbox/src/process.rs @@ -3,6 +3,7 @@ //! Process management and signal handling. +use crate::child_env; use crate::policy::{NetworkMode, SandboxPolicy}; use crate::sandbox; #[cfg(target_os = "linux")] @@ -135,29 +136,22 @@ impl ProcessHandle { let proxy_url = format!("http://10.200.0.1:{port}"); // Both uppercase and lowercase variants: curl/wget use uppercase, // gRPC C-core (libgrpc) checks lowercase http_proxy/https_proxy. - cmd.env("ALL_PROXY", &proxy_url) - .env("HTTP_PROXY", &proxy_url) - .env("HTTPS_PROXY", &proxy_url) - .env("http_proxy", &proxy_url) - .env("https_proxy", &proxy_url) - .env("grpc_proxy", &proxy_url); + for (key, value) in child_env::proxy_env_vars(&proxy_url) { + cmd.env(key, value); + } } else if let Some(http_addr) = proxy.http_addr { let proxy_url = format!("http://{http_addr}"); - cmd.env("ALL_PROXY", &proxy_url) - .env("HTTP_PROXY", &proxy_url) - .env("HTTPS_PROXY", &proxy_url) - .env("http_proxy", &proxy_url) - .env("https_proxy", &proxy_url) - .env("grpc_proxy", &proxy_url); + for (key, value) in child_env::proxy_env_vars(&proxy_url) { + cmd.env(key, value); + } } } // Set TLS trust store env vars so sandbox processes trust the ephemeral CA if let Some((ca_cert_path, combined_bundle_path)) = ca_paths { - cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path) // Node.js (additive) - .env("SSL_CERT_FILE", combined_bundle_path) // OpenSSL/Python/Go - .env("REQUESTS_CA_BUNDLE", combined_bundle_path) // Python requests - .env("CURL_CA_BUNDLE", combined_bundle_path); // curl/libcurl + for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) { + cmd.env(key, value); + } } // Set up process group for signal handling (non-interactive mode only). @@ -240,18 +234,17 @@ impl ProcessHandle { })?; if let Some(http_addr) = proxy.http_addr { let proxy_url = format!("http://{http_addr}"); - cmd.env("ALL_PROXY", &proxy_url) - .env("HTTP_PROXY", &proxy_url) - .env("HTTPS_PROXY", &proxy_url); + for (key, value) in child_env::proxy_env_vars(&proxy_url) { + cmd.env(key, value); + } } } // Set TLS trust store env vars so sandbox processes trust the ephemeral CA if let Some((ca_cert_path, combined_bundle_path)) = ca_paths { - cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path) - .env("SSL_CERT_FILE", combined_bundle_path) - .env("REQUESTS_CA_BUNDLE", combined_bundle_path) - .env("CURL_CA_BUNDLE", combined_bundle_path); + for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) { + cmd.env(key, value); + } } // Set up process group for signal handling (non-interactive mode only). diff --git a/crates/navigator-sandbox/src/ssh.rs b/crates/navigator-sandbox/src/ssh.rs index 568d09638..619fa6a07 100644 --- a/crates/navigator-sandbox/src/ssh.rs +++ b/crates/navigator-sandbox/src/ssh.rs @@ -3,6 +3,7 @@ //! Embedded SSH server for sandbox access. +use crate::child_env; use crate::policy::SandboxPolicy; use crate::process::drop_privileges; use crate::sandbox; @@ -668,19 +669,15 @@ fn apply_child_env( .env("TERM", term); if let Some(url) = proxy_url { - cmd.env("HTTP_PROXY", url) - .env("HTTPS_PROXY", url) - .env("ALL_PROXY", url) - .env("http_proxy", url) - .env("https_proxy", url) - .env("grpc_proxy", url); + for (key, value) in child_env::proxy_env_vars(url) { + cmd.env(key, value); + } } if let Some((ca_cert_path, combined_bundle_path)) = ca_file_paths { - cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path) - .env("SSL_CERT_FILE", combined_bundle_path) - .env("REQUESTS_CA_BUNDLE", combined_bundle_path) - .env("CURL_CA_BUNDLE", combined_bundle_path); + for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) { + cmd.env(key, value); + } } for (key, value) in provider_env {