Skip to content

Commit a336266

Browse files
authored
fix(sandbox): opt Node clients into proxy env support (#269)
1 parent f192858 commit a336266

6 files changed

Lines changed: 106 additions & 37 deletions

File tree

architecture/sandbox-connect.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ Authorization is performed by the gateway (token validation + sandbox readiness
415415
2. Clones the master fd for reading and writing
416416
3. Configures the shell command with environment variables:
417417
- `OPENSHELL_SANDBOX=1`, `HOME=/sandbox`, `USER=sandbox`, `TERM=<from pty request>`
418-
- Proxy vars: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `http_proxy`, `https_proxy`, `grpc_proxy`
418+
- 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
419419
- TLS trust vars: `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`
420420
- Provider credential env vars (from the provider registry)
421421
4. Installs a `pre_exec` hook that:

architecture/sandbox.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -929,7 +929,7 @@ Wraps `tokio::process::Child` + PID. Platform-specific `spawn()` methods delegat
929929
**Environment setup** (both Linux and non-Linux):
930930
- `OPENSHELL_SANDBOX=1` (always set)
931931
- Provider credentials (from `GetSandboxProviderEnvironment` RPC)
932-
- Proxy URLs: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` (uppercase for curl/wget), `http_proxy`, `https_proxy`, `grpc_proxy` (lowercase for gRPC C-core)
932+
- 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)
933933
- TLS trust store: `NODE_EXTRA_CA_CERTS` (standalone CA cert), `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` (combined bundle)
934934

935935
**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
10571057
| `OPENSHELL_SANDBOX` | Always `"1"` -- signals the process is sandboxed |
10581058
| `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` | Proxy URL (uppercase, for curl/wget) |
10591059
| `http_proxy` / `https_proxy` / `grpc_proxy` | Proxy URL (lowercase, for gRPC C-core) |
1060+
| `NODE_USE_ENV_PROXY` | Set to `1` so Node.js built-in `fetch`/`http` clients honor proxy env vars |
10601061
| `NODE_EXTRA_CA_CERTS` | Path to sandbox CA cert PEM (Node.js, additive) |
10611062
| `SSL_CERT_FILE` | Combined CA bundle path (OpenSSL/Python/Go) |
10621063
| `REQUESTS_CA_BUNDLE` | Combined CA bundle path (Python requests) |
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
use std::path::Path;
5+
6+
pub(crate) fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 7] {
7+
[
8+
("ALL_PROXY", proxy_url.to_owned()),
9+
("HTTP_PROXY", proxy_url.to_owned()),
10+
("HTTPS_PROXY", proxy_url.to_owned()),
11+
("http_proxy", proxy_url.to_owned()),
12+
("https_proxy", proxy_url.to_owned()),
13+
("grpc_proxy", proxy_url.to_owned()),
14+
// Node.js only honors HTTP(S)_PROXY for built-in fetch/http clients when
15+
// proxy support is explicitly enabled at process startup.
16+
("NODE_USE_ENV_PROXY", "1".to_owned()),
17+
]
18+
}
19+
20+
pub(crate) fn tls_env_vars(
21+
ca_cert_path: &Path,
22+
combined_bundle_path: &Path,
23+
) -> [(&'static str, String); 4] {
24+
let ca_cert_path = ca_cert_path.display().to_string();
25+
let combined_bundle_path = combined_bundle_path.display().to_string();
26+
[
27+
("NODE_EXTRA_CA_CERTS", ca_cert_path.clone()),
28+
("SSL_CERT_FILE", combined_bundle_path.clone()),
29+
("REQUESTS_CA_BUNDLE", combined_bundle_path.clone()),
30+
("CURL_CA_BUNDLE", combined_bundle_path),
31+
]
32+
}
33+
34+
#[cfg(test)]
35+
mod tests {
36+
use super::*;
37+
use std::process::Command;
38+
use std::process::Stdio;
39+
40+
#[test]
41+
fn apply_proxy_env_includes_node_proxy_opt_in() {
42+
let mut cmd = Command::new("/usr/bin/env");
43+
cmd.stdin(Stdio::null())
44+
.stdout(Stdio::piped())
45+
.stderr(Stdio::null());
46+
47+
for (key, value) in proxy_env_vars("http://10.200.0.1:3128") {
48+
cmd.env(key, value);
49+
}
50+
51+
let output = cmd.output().expect("spawn env");
52+
let stdout = String::from_utf8(output.stdout).expect("utf8");
53+
54+
assert!(stdout.contains("HTTP_PROXY=http://10.200.0.1:3128"));
55+
assert!(stdout.contains("NODE_USE_ENV_PROXY=1"));
56+
}
57+
58+
#[test]
59+
fn apply_tls_env_sets_node_and_bundle_paths() {
60+
let mut cmd = Command::new("/usr/bin/env");
61+
cmd.stdin(Stdio::null())
62+
.stdout(Stdio::piped())
63+
.stderr(Stdio::null());
64+
65+
let ca_cert_path = Path::new("/etc/navigator-tls/navigator-ca.pem");
66+
let combined_bundle_path = Path::new("/etc/navigator-tls/ca-bundle.pem");
67+
for (key, value) in tls_env_vars(ca_cert_path, combined_bundle_path) {
68+
cmd.env(key, value);
69+
}
70+
71+
let output = cmd.output().expect("spawn env");
72+
let stdout = String::from_utf8(output.stdout).expect("utf8");
73+
74+
assert!(stdout.contains("NODE_EXTRA_CA_CERTS=/etc/navigator-tls/navigator-ca.pem"));
75+
assert!(stdout.contains("SSL_CERT_FILE=/etc/navigator-tls/ca-bundle.pem"));
76+
}
77+
}

crates/navigator-sandbox/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//!
66
//! This crate provides process sandboxing and monitoring capabilities.
77
8+
mod child_env;
89
pub mod denial_aggregator;
910
mod grpc_client;
1011
mod identity;
@@ -339,8 +340,8 @@ pub async fn run_sandbox(
339340
// SSH shell processes need both to enforce network policy:
340341
// - netns_fd: enter the network namespace via setns() so all traffic
341342
// goes through the veth pair (hard enforcement, non-bypassable)
342-
// - proxy_url: set HTTP_PROXY/HTTPS_PROXY/ALL_PROXY env vars so
343-
// cooperative tools (curl, etc.) route through the CONNECT proxy
343+
// - proxy_url: set proxy env vars so cooperative tools route through the
344+
// CONNECT proxy; this also opts Node.js into honoring those vars
344345
#[cfg(target_os = "linux")]
345346
let ssh_netns_fd = netns.as_ref().and_then(NetworkNamespace::ns_fd);
346347

crates/navigator-sandbox/src/process.rs

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
//! Process management and signal handling.
55
6+
use crate::child_env;
67
use crate::policy::{NetworkMode, SandboxPolicy};
78
use crate::sandbox;
89
#[cfg(target_os = "linux")]
@@ -135,29 +136,22 @@ impl ProcessHandle {
135136
let proxy_url = format!("http://10.200.0.1:{port}");
136137
// Both uppercase and lowercase variants: curl/wget use uppercase,
137138
// gRPC C-core (libgrpc) checks lowercase http_proxy/https_proxy.
138-
cmd.env("ALL_PROXY", &proxy_url)
139-
.env("HTTP_PROXY", &proxy_url)
140-
.env("HTTPS_PROXY", &proxy_url)
141-
.env("http_proxy", &proxy_url)
142-
.env("https_proxy", &proxy_url)
143-
.env("grpc_proxy", &proxy_url);
139+
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
140+
cmd.env(key, value);
141+
}
144142
} else if let Some(http_addr) = proxy.http_addr {
145143
let proxy_url = format!("http://{http_addr}");
146-
cmd.env("ALL_PROXY", &proxy_url)
147-
.env("HTTP_PROXY", &proxy_url)
148-
.env("HTTPS_PROXY", &proxy_url)
149-
.env("http_proxy", &proxy_url)
150-
.env("https_proxy", &proxy_url)
151-
.env("grpc_proxy", &proxy_url);
144+
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
145+
cmd.env(key, value);
146+
}
152147
}
153148
}
154149

155150
// Set TLS trust store env vars so sandbox processes trust the ephemeral CA
156151
if let Some((ca_cert_path, combined_bundle_path)) = ca_paths {
157-
cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path) // Node.js (additive)
158-
.env("SSL_CERT_FILE", combined_bundle_path) // OpenSSL/Python/Go
159-
.env("REQUESTS_CA_BUNDLE", combined_bundle_path) // Python requests
160-
.env("CURL_CA_BUNDLE", combined_bundle_path); // curl/libcurl
152+
for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) {
153+
cmd.env(key, value);
154+
}
161155
}
162156

163157
// Set up process group for signal handling (non-interactive mode only).
@@ -240,18 +234,17 @@ impl ProcessHandle {
240234
})?;
241235
if let Some(http_addr) = proxy.http_addr {
242236
let proxy_url = format!("http://{http_addr}");
243-
cmd.env("ALL_PROXY", &proxy_url)
244-
.env("HTTP_PROXY", &proxy_url)
245-
.env("HTTPS_PROXY", &proxy_url);
237+
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
238+
cmd.env(key, value);
239+
}
246240
}
247241
}
248242

249243
// Set TLS trust store env vars so sandbox processes trust the ephemeral CA
250244
if let Some((ca_cert_path, combined_bundle_path)) = ca_paths {
251-
cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path)
252-
.env("SSL_CERT_FILE", combined_bundle_path)
253-
.env("REQUESTS_CA_BUNDLE", combined_bundle_path)
254-
.env("CURL_CA_BUNDLE", combined_bundle_path);
245+
for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) {
246+
cmd.env(key, value);
247+
}
255248
}
256249

257250
// Set up process group for signal handling (non-interactive mode only).

crates/navigator-sandbox/src/ssh.rs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
//! Embedded SSH server for sandbox access.
55
6+
use crate::child_env;
67
use crate::policy::SandboxPolicy;
78
use crate::process::drop_privileges;
89
use crate::sandbox;
@@ -668,19 +669,15 @@ fn apply_child_env(
668669
.env("TERM", term);
669670

670671
if let Some(url) = proxy_url {
671-
cmd.env("HTTP_PROXY", url)
672-
.env("HTTPS_PROXY", url)
673-
.env("ALL_PROXY", url)
674-
.env("http_proxy", url)
675-
.env("https_proxy", url)
676-
.env("grpc_proxy", url);
672+
for (key, value) in child_env::proxy_env_vars(url) {
673+
cmd.env(key, value);
674+
}
677675
}
678676

679677
if let Some((ca_cert_path, combined_bundle_path)) = ca_file_paths {
680-
cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path)
681-
.env("SSL_CERT_FILE", combined_bundle_path)
682-
.env("REQUESTS_CA_BUNDLE", combined_bundle_path)
683-
.env("CURL_CA_BUNDLE", combined_bundle_path);
678+
for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) {
679+
cmd.env(key, value);
680+
}
684681
}
685682

686683
for (key, value) in provider_env {

0 commit comments

Comments
 (0)