From 50ea7495c18e845710be923c9d054e4cf7efbf64 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 13 Mar 2026 15:30:08 -0700 Subject: [PATCH] fix(sandbox): bypass proxy for localhost traffic (#290) --- architecture/sandbox-connect.md | 2 +- architecture/sandbox-providers.md | 5 +- architecture/sandbox.md | 2 +- crates/openshell-sandbox/src/child_env.rs | 10 +++- e2e/rust/tests/no_proxy.rs | 62 +++++++++++++++++++++++ 5 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 e2e/rust/tests/no_proxy.rs diff --git a/architecture/sandbox-connect.md b/architecture/sandbox-connect.md index 7c7ba33f6..daa033d92 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`, `NODE_USE_ENV_PROXY=1` so Node.js `fetch` honors the proxy env + - Proxy vars: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY=127.0.0.1,localhost,::1`, `http_proxy`, `https_proxy`, `grpc_proxy`, `no_proxy=127.0.0.1,localhost,::1`, `NODE_USE_ENV_PROXY=1` so Node.js `fetch` honors the proxy env while localhost stays direct - 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-providers.md b/architecture/sandbox-providers.md index 220b775fb..d5ca19850 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -277,8 +277,9 @@ inherited environment without clearing it. The spawn path also explicitly remove `NEMOCLAW_SSH_HANDSHAKE_SECRET` so the handshake secret does not leak into the agent entrypoint process. -After provider env vars, proxy env vars (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, etc.) -are also set when `NetworkMode` is `Proxy`. The child is then launched with namespace +After provider env vars, proxy env vars (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, +`NO_PROXY=127.0.0.1,localhost,::1`, lowercase variants, etc.) are also set when +`NetworkMode` is `Proxy`. The child is then launched with namespace isolation, privilege dropping, seccomp, and Landlock restrictions via `pre_exec`. **2. SSH shell sessions** (`crates/openshell-sandbox/src/ssh.rs`): diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 1bd062ae6..62b2a5543 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), `NODE_USE_ENV_PROXY=1` (required for Node.js built-in `fetch`/`http` clients to honor proxy env vars) +- Proxy URLs: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` (uppercase for curl/wget), `NO_PROXY=127.0.0.1,localhost,::1` for localhost bypass, `http_proxy`, `https_proxy`, `grpc_proxy` (lowercase for gRPC C-core), `no_proxy=127.0.0.1,localhost,::1`, `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): diff --git a/crates/openshell-sandbox/src/child_env.rs b/crates/openshell-sandbox/src/child_env.rs index fb846ffc0..ebd47e225 100644 --- a/crates/openshell-sandbox/src/child_env.rs +++ b/crates/openshell-sandbox/src/child_env.rs @@ -3,13 +3,17 @@ use std::path::Path; -pub(crate) fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 7] { +const LOCAL_NO_PROXY: &str = "127.0.0.1,localhost,::1"; + +pub(crate) fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 9] { [ ("ALL_PROXY", proxy_url.to_owned()), ("HTTP_PROXY", proxy_url.to_owned()), ("HTTPS_PROXY", proxy_url.to_owned()), + ("NO_PROXY", LOCAL_NO_PROXY.to_owned()), ("http_proxy", proxy_url.to_owned()), ("https_proxy", proxy_url.to_owned()), + ("no_proxy", LOCAL_NO_PROXY.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. @@ -38,7 +42,7 @@ mod tests { use std::process::Stdio; #[test] - fn apply_proxy_env_includes_node_proxy_opt_in() { + fn apply_proxy_env_includes_node_proxy_opt_in_and_local_bypass() { let mut cmd = Command::new("/usr/bin/env"); cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -52,7 +56,9 @@ mod tests { let stdout = String::from_utf8(output.stdout).expect("utf8"); assert!(stdout.contains("HTTP_PROXY=http://10.200.0.1:3128")); + assert!(stdout.contains("NO_PROXY=127.0.0.1,localhost,::1")); assert!(stdout.contains("NODE_USE_ENV_PROXY=1")); + assert!(stdout.contains("no_proxy=127.0.0.1,localhost,::1")); } #[test] diff --git a/e2e/rust/tests/no_proxy.rs b/e2e/rust/tests/no_proxy.rs new file mode 100644 index 000000000..c05c51327 --- /dev/null +++ b/e2e/rust/tests/no_proxy.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +use openshell_e2e::harness::sandbox::SandboxGuard; + +fn localhost_bypass_script() -> &'static str { + r#" +import json +import os +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, HTTPServer + +expected_no_proxy = '127.0.0.1,localhost,::1' +assert os.environ['HTTP_PROXY'].startswith('http://') +assert os.environ['HTTPS_PROXY'].startswith('http://') +assert os.environ['NO_PROXY'] == expected_no_proxy +assert os.environ['no_proxy'] == expected_no_proxy + +class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass + + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(b'{"message":"hello"}') + +server = HTTPServer(('127.0.0.1', 0), Handler) +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() + +try: + with urllib.request.urlopen(f'http://127.0.0.1:{server.server_port}', timeout=10) as response: + print(json.dumps({ + 'no_proxy': os.environ['NO_PROXY'], + 'payload': json.loads(response.read().decode()), + }), flush=True) +finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() +"# +} + +#[tokio::test] +async fn sandbox_bypasses_proxy_for_localhost_http() { + let guard = SandboxGuard::create(&["python3", "-c", localhost_bypass_script()]) + .await + .expect("sandbox create with localhost proxy bypass check"); + + assert!( + guard.create_output.contains( + r#"{"no_proxy": "127.0.0.1,localhost,::1", "payload": {"message": "hello"}}"# + ), + "expected localhost HTTP request to bypass proxy and succeed:\n{}", + guard.create_output + ); +}