fix(sandbox): bypass proxy for localhost traffic (#290)

This commit is contained in:
Piotr Mlocek
2026-03-13 15:30:08 -07:00
committed by GitHub
parent 20c32716ad
commit 50ea7495c1
5 changed files with 75 additions and 6 deletions
+1 -1
View File
@@ -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=<from pty request>`
- 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:
+3 -2
View File
@@ -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`):
+1 -1
View File
@@ -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):
+8 -2
View File
@@ -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]
+62
View File
@@ -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
);
}