Skip to content

Commit 0aa4dda

Browse files
authored
fix(sandbox): bypass proxy for localhost traffic (#290)
1 parent 37c9ae7 commit 0aa4dda

5 files changed

Lines changed: 75 additions & 6 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`, `NODE_USE_ENV_PROXY=1` so Node.js `fetch` honors the proxy env
418+
- 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
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-providers.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,9 @@ inherited environment without clearing it. The spawn path also explicitly remove
277277
`NEMOCLAW_SSH_HANDSHAKE_SECRET` so the handshake secret does not leak into the agent
278278
entrypoint process.
279279

280-
After provider env vars, proxy env vars (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, etc.)
281-
are also set when `NetworkMode` is `Proxy`. The child is then launched with namespace
280+
After provider env vars, proxy env vars (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`,
281+
`NO_PROXY=127.0.0.1,localhost,::1`, lowercase variants, etc.) are also set when
282+
`NetworkMode` is `Proxy`. The child is then launched with namespace
282283
isolation, privilege dropping, seccomp, and Landlock restrictions via `pre_exec`.
283284

284285
**2. SSH shell sessions** (`crates/openshell-sandbox/src/ssh.rs`):

architecture/sandbox.md

Lines changed: 1 addition & 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), `NODE_USE_ENV_PROXY=1` (required for Node.js built-in `fetch`/`http` clients to honor proxy env vars)
932+
- 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)
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):

crates/openshell-sandbox/src/child_env.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33

44
use std::path::Path;
55

6-
pub(crate) fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 7] {
6+
const LOCAL_NO_PROXY: &str = "127.0.0.1,localhost,::1";
7+
8+
pub(crate) fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 9] {
79
[
810
("ALL_PROXY", proxy_url.to_owned()),
911
("HTTP_PROXY", proxy_url.to_owned()),
1012
("HTTPS_PROXY", proxy_url.to_owned()),
13+
("NO_PROXY", LOCAL_NO_PROXY.to_owned()),
1114
("http_proxy", proxy_url.to_owned()),
1215
("https_proxy", proxy_url.to_owned()),
16+
("no_proxy", LOCAL_NO_PROXY.to_owned()),
1317
("grpc_proxy", proxy_url.to_owned()),
1418
// Node.js only honors HTTP(S)_PROXY for built-in fetch/http clients when
1519
// proxy support is explicitly enabled at process startup.
@@ -38,7 +42,7 @@ mod tests {
3842
use std::process::Stdio;
3943

4044
#[test]
41-
fn apply_proxy_env_includes_node_proxy_opt_in() {
45+
fn apply_proxy_env_includes_node_proxy_opt_in_and_local_bypass() {
4246
let mut cmd = Command::new("/usr/bin/env");
4347
cmd.stdin(Stdio::null())
4448
.stdout(Stdio::piped())
@@ -52,7 +56,9 @@ mod tests {
5256
let stdout = String::from_utf8(output.stdout).expect("utf8");
5357

5458
assert!(stdout.contains("HTTP_PROXY=http://10.200.0.1:3128"));
59+
assert!(stdout.contains("NO_PROXY=127.0.0.1,localhost,::1"));
5560
assert!(stdout.contains("NODE_USE_ENV_PROXY=1"));
61+
assert!(stdout.contains("no_proxy=127.0.0.1,localhost,::1"));
5662
}
5763

5864
#[test]

e2e/rust/tests/no_proxy.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#![cfg(feature = "e2e")]
5+
6+
use openshell_e2e::harness::sandbox::SandboxGuard;
7+
8+
fn localhost_bypass_script() -> &'static str {
9+
r#"
10+
import json
11+
import os
12+
import threading
13+
import urllib.request
14+
from http.server import BaseHTTPRequestHandler, HTTPServer
15+
16+
expected_no_proxy = '127.0.0.1,localhost,::1'
17+
assert os.environ['HTTP_PROXY'].startswith('http://')
18+
assert os.environ['HTTPS_PROXY'].startswith('http://')
19+
assert os.environ['NO_PROXY'] == expected_no_proxy
20+
assert os.environ['no_proxy'] == expected_no_proxy
21+
22+
class Handler(BaseHTTPRequestHandler):
23+
def log_message(self, format, *args):
24+
pass
25+
26+
def do_GET(self):
27+
self.send_response(200)
28+
self.send_header('Content-Type', 'application/json')
29+
self.end_headers()
30+
self.wfile.write(b'{"message":"hello"}')
31+
32+
server = HTTPServer(('127.0.0.1', 0), Handler)
33+
thread = threading.Thread(target=server.serve_forever, daemon=True)
34+
thread.start()
35+
36+
try:
37+
with urllib.request.urlopen(f'http://127.0.0.1:{server.server_port}', timeout=10) as response:
38+
print(json.dumps({
39+
'no_proxy': os.environ['NO_PROXY'],
40+
'payload': json.loads(response.read().decode()),
41+
}), flush=True)
42+
finally:
43+
server.shutdown()
44+
thread.join(timeout=5)
45+
server.server_close()
46+
"#
47+
}
48+
49+
#[tokio::test]
50+
async fn sandbox_bypasses_proxy_for_localhost_http() {
51+
let guard = SandboxGuard::create(&["python3", "-c", localhost_bypass_script()])
52+
.await
53+
.expect("sandbox create with localhost proxy bypass check");
54+
55+
assert!(
56+
guard.create_output.contains(
57+
r#"{"no_proxy": "127.0.0.1,localhost,::1", "payload": {"message": "hello"}}"#
58+
),
59+
"expected localhost HTTP request to bypass proxy and succeed:\n{}",
60+
guard.create_output
61+
);
62+
}

0 commit comments

Comments
 (0)