mirror of
https://github.com/openai/codex.git
synced 2026-04-25 23:24:55 +00:00
feat(linux-sandbox): implement proxy-only egress via TCP-UDS-TCP bridge (#11293)
## Summary - Implement Linux proxy-only routing in `codex-rs/linux-sandbox` with a two-stage bridge: host namespace `loopback TCP proxy endpoint -> UDS`, then bwrap netns `loopback TCP listener -> host UDS`. - Add hidden `--proxy-route-spec` plumbing for outer-to-inner stage handoff. - Fail closed in proxy mode when no valid loopback proxy endpoints can be routed. - Introduce explicit network seccomp modes: `Restricted` (legacy restricted networking) and `ProxyRouted` (allow INET/INET6 for routed proxy access, deny `AF_UNIX` and `socketpair`). - Enforce that proxy bridge/routing is bwrap-only by validating `--apply-seccomp-then-exec` requires `--use-bwrap-sandbox`. - Keep landlock-only flows unchanged (no proxy bridge behavior outside bwrap). --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
This commit is contained in:
313
codex-rs/linux-sandbox/tests/suite/managed_proxy.rs
Normal file
313
codex-rs/linux-sandbox/tests/suite/managed_proxy.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use codex_core::config::types::ShellEnvironmentPolicy;
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::TcpListener;
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build.";
|
||||
const NETWORK_TIMEOUT_MS: u64 = 4_000;
|
||||
const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[
|
||||
"loopback: Failed RTM_NEWADDR",
|
||||
"loopback: Failed RTM_NEWLINK",
|
||||
"setting up uid map: Permission denied",
|
||||
"No permissions to create a new namespace",
|
||||
"error isolating Linux network namespace for proxy mode",
|
||||
];
|
||||
|
||||
const PROXY_ENV_KEYS: &[&str] = &[
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"FTP_PROXY",
|
||||
"YARN_HTTP_PROXY",
|
||||
"YARN_HTTPS_PROXY",
|
||||
"NPM_CONFIG_HTTP_PROXY",
|
||||
"NPM_CONFIG_HTTPS_PROXY",
|
||||
"NPM_CONFIG_PROXY",
|
||||
"BUNDLE_HTTP_PROXY",
|
||||
"BUNDLE_HTTPS_PROXY",
|
||||
"PIP_PROXY",
|
||||
"DOCKER_HTTP_PROXY",
|
||||
"DOCKER_HTTPS_PROXY",
|
||||
];
|
||||
|
||||
fn create_env_from_core_vars() -> HashMap<String, String> {
|
||||
let policy = ShellEnvironmentPolicy::default();
|
||||
create_env(&policy, None)
|
||||
}
|
||||
|
||||
fn strip_proxy_env(env: &mut HashMap<String, String>) {
|
||||
for key in PROXY_ENV_KEYS {
|
||||
env.remove(*key);
|
||||
let lower = key.to_ascii_lowercase();
|
||||
env.remove(lower.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bwrap_unavailable_output(output: &Output) -> bool {
|
||||
String::from_utf8_lossy(&output.stderr).contains(BWRAP_UNAVAILABLE_ERR)
|
||||
}
|
||||
|
||||
async fn should_skip_bwrap_tests() -> bool {
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
false,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
is_bwrap_unavailable_output(&output)
|
||||
}
|
||||
|
||||
fn is_managed_proxy_permission_error(stderr: &str) -> bool {
|
||||
MANAGED_PROXY_PERMISSION_ERR_SNIPPETS
|
||||
.iter()
|
||||
.any(|snippet| stderr.contains(snippet))
|
||||
}
|
||||
|
||||
async fn managed_proxy_skip_reason() -> Option<String> {
|
||||
if should_skip_bwrap_tests().await {
|
||||
return Some("vendored bwrap was not built in this environment".to_string());
|
||||
}
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
if output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if is_managed_proxy_permission_error(stderr.as_ref()) {
|
||||
return Some(format!(
|
||||
"managed proxy requires kernel namespace privileges unavailable here: {}",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn run_linux_sandbox_direct(
|
||||
command: &[&str],
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
allow_network_for_proxy: bool,
|
||||
env: HashMap<String, String>,
|
||||
timeout_ms: u64,
|
||||
) -> Output {
|
||||
let cwd = match std::env::current_dir() {
|
||||
Ok(cwd) => cwd,
|
||||
Err(err) => panic!("cwd should exist: {err}"),
|
||||
};
|
||||
let policy_json = match serde_json::to_string(sandbox_policy) {
|
||||
Ok(policy_json) => policy_json,
|
||||
Err(err) => panic!("policy should serialize: {err}"),
|
||||
};
|
||||
|
||||
let mut args = vec![
|
||||
"--sandbox-policy-cwd".to_string(),
|
||||
cwd.to_string_lossy().to_string(),
|
||||
"--sandbox-policy".to_string(),
|
||||
policy_json,
|
||||
"--use-bwrap-sandbox".to_string(),
|
||||
];
|
||||
if allow_network_for_proxy {
|
||||
args.push("--allow-network-for-proxy".to_string());
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.extend(command.iter().map(|entry| (*entry).to_string()));
|
||||
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_codex-linux-sandbox"));
|
||||
cmd.args(args)
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.envs(env)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output()).await {
|
||||
Ok(output) => output,
|
||||
Err(err) => panic!("sandbox command should not time out: {err}"),
|
||||
};
|
||||
match output {
|
||||
Ok(output) => output,
|
||||
Err(err) => panic!("sandbox command should execute: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxy_mode_fails_closed_without_proxy_env() {
|
||||
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "true"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(output.status.success(), false);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("managed proxy mode requires proxy environment variables"),
|
||||
"expected fail-closed managed-proxy message, got stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
|
||||
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind proxy listener");
|
||||
let proxy_port = listener
|
||||
.local_addr()
|
||||
.expect("proxy listener local addr")
|
||||
.port();
|
||||
let (request_tx, request_rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept proxy connection");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.expect("set read timeout");
|
||||
let mut buf = [0_u8; 4096];
|
||||
let read = stream.read(&mut buf).expect("read proxy request");
|
||||
let request = String::from_utf8_lossy(&buf[..read]).to_string();
|
||||
request_tx.send(request).expect("send proxy request");
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
|
||||
.expect("write proxy response");
|
||||
});
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert(
|
||||
"HTTP_PROXY".to_string(),
|
||||
format!("http://127.0.0.1:{proxy_port}"),
|
||||
);
|
||||
|
||||
let routed_output = run_linux_sandbox_direct(
|
||||
&[
|
||||
"bash",
|
||||
"-c",
|
||||
"proxy=\"${HTTP_PROXY#*://}\"; host=\"${proxy%%:*}\"; port=\"${proxy##*:}\"; exec 3<>/dev/tcp/${host}/${port}; printf 'GET http://example.com/ HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n' >&3; IFS= read -r line <&3; printf '%s\\n' \"$line\"",
|
||||
],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env.clone(),
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
routed_output.status.success(),
|
||||
true,
|
||||
"expected routed command to execute successfully; status={:?}; stdout={}; stderr={}",
|
||||
routed_output.status.code(),
|
||||
String::from_utf8_lossy(&routed_output.stdout),
|
||||
String::from_utf8_lossy(&routed_output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&routed_output.stdout);
|
||||
assert!(
|
||||
stdout.contains("HTTP/1.1 200 OK"),
|
||||
"expected bridge-routed proxy response, got stdout: {stdout}"
|
||||
);
|
||||
|
||||
let request = request_rx
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.expect("expected proxy request");
|
||||
assert!(
|
||||
request.contains("GET http://example.com/ HTTP/1.1"),
|
||||
"expected HTTP proxy absolute-form request, got request: {request}"
|
||||
);
|
||||
|
||||
let direct_egress_output = run_linux_sandbox_direct(
|
||||
&["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(direct_egress_output.status.success(), false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() {
|
||||
if let Some(skip_reason) = managed_proxy_skip_reason().await {
|
||||
eprintln!("skipping managed proxy test: {skip_reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let python_available = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg("command -v python3 >/dev/null")
|
||||
.status()
|
||||
.await
|
||||
.expect("python3 probe should execute")
|
||||
.success();
|
||||
if !python_available {
|
||||
eprintln!("skipping managed proxy AF_UNIX test: python3 is unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut env = create_env_from_core_vars();
|
||||
strip_proxy_env(&mut env);
|
||||
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
|
||||
|
||||
let output = run_linux_sandbox_direct(
|
||||
&[
|
||||
"python3",
|
||||
"-c",
|
||||
"import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n",
|
||||
],
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
true,
|
||||
env,
|
||||
NETWORK_TIMEOUT_MS,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(0),
|
||||
"expected AF_UNIX creation to be denied cleanly for user command; status={:?}; stdout={}; stderr={}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod landlock;
|
||||
mod managed_proxy;
|
||||
|
||||
Reference in New Issue
Block a user