Add opt-in transparent TCP routing for Linux sandboxes (#61372)

## Why

Native TCP clients need to reach managed network destinations without implementing proxy support themselves.

## What changed

- Add `--transparent-tcp` to the Linux sandbox command, using a bundled TCP stack, private TUN interface, and DNS stub to route native IPv4 and IPv6 connections through the attributed managed SOCKS proxy.
- Preserve original hostnames for proxy policy checks and cancel pending approval connections when their native sockets close.
- Expose `--linux-sandbox-transparent-tcp` and `--tcp-upstream-proxy` on `codex exec-server` for transparent routing and a mandatory HTTP CONNECT next hop without direct fallback.
- Require private network and PID namespaces, private procfs, system `bwrap`, and access to `/dev/net/tun`; reject incompatible launcher options and untrusted proxy ports.

## Testing

Add integration coverage for native hostname and IPv6 connections, SOCKS attribution, pending connection cancellation, policy denials, and filesystem and host-loopback isolation. Extend launcher and executor tests for option validation and managed-policy propagation.

GitOrigin-RevId: 96bd7726e5369efceb65320face56aa85ae3deb8
This commit is contained in:
viyatb-oai
2026-11-13 20:24:37 +00:00
committed by copyberry
parent 9e48702b3a
commit 7194b21776
40 changed files with 1637 additions and 188 deletions
+4
View File
@@ -537,6 +537,10 @@ crate.annotation(
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
managed_tcp_test_bwrap = use_repo_rule("//codex-rs/linux-sandbox:managed_tcp_test_bwrap.bzl", "managed_tcp_test_bwrap")
managed_tcp_test_bwrap(name = "managed_tcp_test_bubblewrap_noble_amd64")
# Cargo keeps vendored OpenSSL for musl; Bazel already supplies its own OpenSSL.
# Replace this crate's generated build, not Cargo's feature resolution.
http_archive(
+4
View File
File diff suppressed because one or more lines are too long
+44
View File
@@ -4055,6 +4055,7 @@ dependencies = [
name = "codex-linux-sandbox"
version = "0.0.0"
dependencies = [
"async-lock",
"clap",
"codex-core",
"codex-install-context",
@@ -4065,17 +4066,20 @@ dependencies = [
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"etherparse",
"hickory-proto 0.26.3",
"ignore",
"indexmap 2.14.0",
"landlock",
"libc",
"pretty_assertions",
"rand 0.9.3",
"rustix 1.1.4",
"seccompiler",
"serde",
"serde_json",
"sha2 0.10.9",
"smoltcp",
"socks5-protocol",
"tempfile",
"test-case",
@@ -7455,6 +7459,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "etherparse"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17304d06addb3283cdc4bd528e42dd95e73c8ee2d6492ffce415e93660885449"
dependencies = [
"arrayvec",
]
[[package]]
name = "euclid"
version = "0.22.14"
@@ -9120,6 +9133,16 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "heapless"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
dependencies = [
"hash32 0.3.1",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -10560,6 +10583,12 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
[[package]]
name = "managed"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
[[package]]
name = "maplit"
version = "1.0.2"
@@ -14797,6 +14826,21 @@ dependencies = [
"serde_core",
]
[[package]]
name = "smoltcp"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f"
dependencies = [
"bitflags 1.3.2",
"byteorder",
"cfg-if",
"defmt 1.1.1",
"heapless 0.9.3",
"libc",
"managed",
]
[[package]]
name = "socket2"
version = "0.5.10"
+19 -3
View File
@@ -101,6 +101,7 @@ pub async fn run_command_under_landlock(
loader_overrides: LoaderOverrides,
) -> anyhow::Result<()> {
let LandlockCommand {
transparent_tcp,
sandbox_state,
permissions_profile,
config_profile: _,
@@ -124,7 +125,7 @@ pub async fn run_command_under_landlock(
command,
config_overrides,
codex_linux_sandbox_exe,
SandboxType::Landlock,
SandboxType::Landlock { transparent_tcp },
/*log_denials*/ false,
&[],
)
@@ -170,7 +171,9 @@ pub async fn run_command_under_windows_sandbox(
enum SandboxType {
#[cfg(target_os = "macos")]
Seatbelt,
Landlock,
Landlock {
transparent_tcp: bool,
},
Windows,
}
@@ -304,12 +307,21 @@ async fn run_command_under_sandbox(
config.effective_local_windows_sandbox_type()
};
let transparent_tcp = matches!(
sandbox_type,
SandboxType::Landlock {
transparent_tcp: true
}
);
let sandbox = match permission_profile.enforcement() {
SandboxEnforcement::Disabled | SandboxEnforcement::External if transparent_tcp => {
anyhow::bail!("--transparent-tcp requires a managed Linux sandbox");
}
SandboxEnforcement::Disabled | SandboxEnforcement::External => SandboxBackend::None,
SandboxEnforcement::Managed => match sandbox_type {
#[cfg(target_os = "macos")]
SandboxType::Seatbelt => SandboxBackend::MacosSeatbelt,
SandboxType::Landlock => SandboxBackend::LinuxSeccomp,
SandboxType::Landlock { .. } => SandboxBackend::LinuxSeccomp,
#[cfg(windows)]
SandboxType::Windows if windows_sandbox_type == WindowsMxc => WindowsMxc,
#[cfg(windows)]
@@ -386,6 +398,10 @@ async fn run_command_under_sandbox(
let manager = SandboxManager::for_inherited_terminal(allow_unix_sockets);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let manager = SandboxManager::new();
if transparent_tcp && network.is_none() {
anyhow::bail!("--transparent-tcp requires a managed network proxy");
}
let manager = manager.with_linux_sandbox_transparent_tcp(transparent_tcp);
let workspace_roots = sandbox_state.is_none().then(|| {
config
.permissions
+4
View File
@@ -109,6 +109,10 @@ fn parse_absolute_path(raw: &str) -> Result<AbsolutePathBuf, String> {
#[derive(Debug, Parser)]
pub struct LandlockCommand {
/// Route outbound TCP through the managed SOCKS proxy.
#[arg(long)]
pub transparent_tcp: bool,
#[command(flatten)]
pub sandbox_state: SandboxStateArgs,
@@ -39,6 +39,13 @@ Options:
[default: isolate]
--linux-sandbox-transparent-tcp
Route managed Linux sandbox TCP through the SOCKS proxy
--tcp-upstream-proxy <HOST:PORT>
Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS
listener
--proxy-private-ips-via-upstream
Allow permitted private IP destinations to use the configured upstream
proxy. If no valid upstream proxy applies to the request protocol,
+3 -1
View File
@@ -187,7 +187,9 @@ confidence-threshold = 0.8
exceptions = [
# Each entry is the crate and version constraint, and its specific allow
# list
#{ allow = ["Zlib"], crate = "adler32" },
# The bundled Linux TCP stack and its storage library are both 0BSD.
{ allow = ["0BSD"], crate = "managed@0.8.0" },
{ allow = ["0BSD"], crate = "smoltcp@0.14.0" },
]
# Some crates don't have (easily) machine readable licensing information,
+10
View File
@@ -41,6 +41,14 @@ pub struct ExecServerCommand {
#[arg(long, value_name = "MODE", default_value = "isolate", global = true)]
linux_sandbox_pid_namespace: codex_sandboxing::LinuxSandboxPidNamespace,
/// Route managed Linux sandbox TCP through the SOCKS proxy.
#[arg(long, global = true)]
linux_sandbox_transparent_tcp: bool,
/// Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS listener.
#[arg(long, value_name = "HOST:PORT", global = true)]
tcp_upstream_proxy: Option<codex_exec_server::TcpUpstreamProxy>,
/// Allow permitted private IP destinations to use the configured upstream proxy.
/// If no valid upstream proxy applies to the request protocol, connect directly.
/// Loopback stays local. This flag does not require upstream routing.
@@ -203,7 +211,9 @@ impl ExecServerCommand {
arg0_paths.codex_linux_sandbox_exe.clone(),
)?
.with_linux_sandbox_pid_namespace(self.linux_sandbox_pid_namespace)
.with_linux_sandbox_transparent_tcp(self.linux_sandbox_transparent_tcp)
.with_proxy_private_ips_via_upstream(self.proxy_private_ips_via_upstream);
runtime_paths.tcp_upstream_proxy = self.tcp_upstream_proxy.take();
// Config loading below owns error handling (non-strict stdio can proceed
// without config). Never retain other startup flags in the executor.
if let Ok(overrides) = root_config_overrides.parse_overrides() {
@@ -1,6 +1,6 @@
---
source: exec-server-cli/src/exec_server_args_tests.rs
assertion_line: 318
assertion_line: 338
expression: help
---
[EXPERIMENTAL] Run the standalone exec-server service
@@ -33,6 +33,13 @@ Options:
[default: isolate]
--linux-sandbox-transparent-tcp
Route managed Linux sandbox TCP through the SOCKS proxy
--tcp-upstream-proxy <HOST:PORT>
Require this HTTP CONNECT proxy for raw TCP from the managed SOCKS
listener
--proxy-private-ips-via-upstream
Allow permitted private IP destinations to use the configured upstream
proxy. If no valid upstream proxy applies to the request protocol,
+13
View File
@@ -537,6 +537,19 @@ Destination policy still applies to every route. Enforcing mandatory upstream ro
would require a separate fail-closed mode. A connection failure after selecting an
upstream proxy is returned as an error, without retrying directly.
### Transparent TCP
```sh
codex exec-server --linux-sandbox-transparent-tcp --tcp-upstream-proxy proxy:8088
```
The executor chooses the mandatory plaintext HTTP CONNECT next hop for opaque SOCKS traffic.
HTTP clients retain their existing proxy configuration. A failed CONNECT never retries directly.
The upstream resolves hostnames; named tunnels are refused if local binding is disabled or
an IP deny/review rule requires local resolution. Private IP literals additionally require
`--proxy-private-ips-via-upstream`; loopback, link-local and the upstream itself are rejected.
The upstream must enforce policy on the address it dials. See the [Linux host requirements](../linux-sandbox/README.md#transparent-tcp).
## Example session
Initialize:
+1
View File
@@ -110,6 +110,7 @@ pub use codex_file_system::WalkOptions;
pub use codex_file_system::WalkOutcome;
pub use codex_file_system::WindowsSandboxSelection;
pub use codex_file_system::WriteFileOptions;
pub use codex_network_proxy::TcpUpstreamProxy;
pub use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR;
pub use environment::CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR;
pub use environment::CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR;
+22 -6
View File
@@ -135,7 +135,7 @@ pub(crate) async fn prepare_exec_request_with_telemetry(
prepare_managed_network(
params,
credential_brokers,
runtime_paths.is_some_and(|paths| paths.proxy_private_ips_via_upstream),
runtime_paths,
env,
network_policy_decider,
network_policy_audit_observer,
@@ -182,7 +182,8 @@ pub(crate) async fn prepare_exec_request_with_telemetry(
});
runtime_readable_paths.extend(managed_mitm_ca_trust_bundle_path);
let sandbox_manager = SandboxManager::new()
.with_linux_sandbox_pid_namespace(runtime_paths.linux_sandbox_pid_namespace);
.with_linux_sandbox_pid_namespace(runtime_paths.linux_sandbox_pid_namespace)
.with_linux_sandbox_transparent_tcp(runtime_paths.linux_sandbox_transparent_tcp);
#[cfg(target_os = "macos")]
let sandbox_manager = sandbox_manager
.with_allowed_symlinked_codex_home(runtime_paths.allowed_symlinked_codex_home.clone());
@@ -304,7 +305,7 @@ pub(crate) async fn prepare_exec_request_with_telemetry(
async fn prepare_managed_network(
params: &ExecParams,
credential_brokers: &RemoteCredentialBrokerCache,
proxy_private_ips_via_upstream: bool,
runtime_paths: Option<&ExecServerRuntimeOptions>,
env: HashMap<String, String>,
network_policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
network_policy_audit_observer: Option<NetworkPolicyAuditObserver>,
@@ -318,8 +319,18 @@ async fn prepare_managed_network(
),
JSONRPCErrorError,
> {
let without_executor_proxy = |env| {
if (params.enforce_managed_network || params.managed_network.is_some())
&& runtime_paths.is_some_and(|paths| paths.tcp_upstream_proxy.is_some())
{
return Err(invalid_params(
"a mandatory TCP upstream requires an executor-local managed proxy".to_string(),
));
}
Ok((env, params.managed_network.clone(), None, None))
};
let Some(launch) = &params.network_proxy else {
return Ok((env, params.managed_network.clone(), None, None));
return without_executor_proxy(env);
};
#[cfg(windows)]
let routing = match params
@@ -331,7 +342,7 @@ async fn prepare_managed_network(
// Native Windows commands have no sandbox token SID for shared ingress.
let mut native_env = env;
codex_network_proxy::strip_managed_proxy_env(&mut native_env);
return Ok((native_env, params.managed_network.clone(), None, None));
return without_executor_proxy(native_env);
}
Some(WindowsSandboxSelection::Mxc) => ManagedProxyRouting::DedicatedListeners,
Some(
@@ -358,7 +369,12 @@ async fn prepare_managed_network(
))
})?
.credential_broker;
state.set_proxy_private_ips_via_upstream(proxy_private_ips_via_upstream);
if let Some(paths) = runtime_paths {
state.set_proxy_private_ips_via_upstream(paths.proxy_private_ips_via_upstream);
if let Some(proxy) = &paths.tcp_upstream_proxy {
state.set_tcp_upstream_proxy(proxy.clone());
}
}
if let Some(observer) = network_policy_audit_observer {
state.set_policy_audit_observer(observer);
}
@@ -500,6 +500,26 @@ async fn native_request_preserves_native_launch_fields() {
assert_eq!(prepared.cwd, cwd);
assert_eq!(prepared.env, env);
assert_eq!(prepared.arg0, params.arg0);
let mut legacy = params;
legacy.enforce_managed_network = true;
let mut runtime = ExecServerRuntimeOptions::new(
std::env::current_exe().unwrap(),
/*codex_linux_sandbox_exe*/ None,
)
.unwrap();
runtime.tcp_upstream_proxy = Some("127.0.0.1:8088".parse().unwrap());
let error = prepare_exec_request(
&legacy,
env,
Some(&runtime),
/*network_policy_decider*/ None,
/*network_policy_audit_observer*/ None,
)
.await
.err()
.expect("legacy enforcement must require the mandatory upstream");
assert!(error.message.contains("executor-local managed proxy"));
}
#[test_case(false, None, &["-c", "true"]; "unmasked")]
@@ -15,8 +15,12 @@ pub struct ExecServerRuntimeOptions {
pub codex_linux_sandbox_exe: Option<AbsolutePathBuf>,
/// Trusted startup policy; requests and repository config cannot change PID isolation.
pub linux_sandbox_pid_namespace: LinuxSandboxPidNamespace,
/// Enable transparent TCP for managed Linux command launches.
pub linux_sandbox_transparent_tcp: bool,
/// Trusted startup routing; request policy still controls destination access.
pub proxy_private_ips_via_upstream: bool,
/// Mandatory next hop for opaque SOCKS TCP when set by provisioning.
pub tcp_upstream_proxy: Option<codex_network_proxy::TcpUpstreamProxy>,
/// Only the sandbox preference from CLI flags, never other startup config or secrets.
pub prefer_mxc: Option<bool>,
/// User-config opt-out of writable-root symlink checks beneath this host's home.
@@ -44,7 +48,9 @@ impl ExecServerRuntimeOptions {
) -> std::io::Result<Self> {
Ok(Self {
linux_sandbox_pid_namespace: LinuxSandboxPidNamespace::default(),
linux_sandbox_transparent_tcp: false,
proxy_private_ips_via_upstream: false,
tcp_upstream_proxy: None,
prefer_mxc: None,
codex_self_exe: absolute_path(codex_self_exe)?,
codex_linux_sandbox_exe: codex_linux_sandbox_exe.map(absolute_path).transpose()?,
@@ -59,6 +65,11 @@ impl ExecServerRuntimeOptions {
self
}
pub fn with_linux_sandbox_transparent_tcp(mut self, enabled: bool) -> Self {
self.linux_sandbox_transparent_tcp = enabled;
self
}
/// Routes permitted private IPs through the executor's configured upstream proxy.
pub fn with_proxy_private_ips_via_upstream(mut self, enabled: bool) -> Self {
self.proxy_private_ips_via_upstream = enabled;
+18
View File
@@ -10,4 +10,22 @@ codex_rust_crate(
"@platforms//os:linux": ["//codex-rs/bwrap:bwrap-sha256-env"],
"//conditions:default": [],
}),
test_data_extra = [":managed-tcp-test-data"],
)
config_setting(
name = "linux-x86_64",
constraint_values = [
"@platforms//cpu:x86_64",
"@platforms//os:linux",
],
)
filegroup(
name = "managed-tcp-test-data",
testonly = True,
srcs = select({
":linux-x86_64": ["@managed_tcp_test_bubblewrap_noble_amd64//:usr/bin/bwrap"],
"//conditions:default": [],
}),
)
+8 -4
View File
@@ -17,6 +17,7 @@ doctest = false
workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
async-lock = "3"
clap = { workspace = true, features = ["derive"] }
codex-install-context = { workspace = true }
codex-network-proxy = { workspace = true }
@@ -25,24 +26,26 @@ codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
etherparse = { version = "=0.21.0", default-features = false }
hickory-proto = "=0.26.3"
ignore = { workspace = true }
indexmap = { workspace = true }
landlock = { workspace = true }
rand = { workspace = true }
libc = { workspace = true }
rustix = { workspace = true }
rustix = { workspace = true, features = ["process", "thread"] }
seccompiler = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
smoltcp = { version = "=0.14.0", default-features = false, features = ["std", "phy-tuntap_interface", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp"] }
socks5-protocol = { version = "=0.3.5", features = ["sync"] }
url = { workspace = true }
[target.'cfg(target_os = "linux")'.dev-dependencies]
codex-core = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
hickory-proto = "=0.26.3"
pretty_assertions = { workspace = true }
socks5-protocol = { version = "=0.3.5", features = ["sync"] }
tempfile = { workspace = true }
test-case = "3.3.1"
tokio = { workspace = true, features = [
@@ -51,4 +54,5 @@ tokio = { workspace = true, features = [
"process",
"rt-multi-thread",
"signal",
"sync",
] }
+25 -2
View File
@@ -13,7 +13,29 @@ seccomp filters; it also retains the legacy Landlock filesystem implementation.
`src/linux_run_main.rs` coordinates these stages through `LinuxSandboxCommand`.
End-to-end coverage lives in `tests/suite/sandbox.rs` and the other suite modules.
On Linux, Codex prefers the first `bwrap` found on `PATH`
### Transparent TCP
```sh
codex sandbox --transparent-tcp --permission-profile db -- psql -h pg.example
```
The Rust sandbox helper includes the TCP stack. Codex starts it automatically.
The Linux host needs distro `bwrap` on `PATH`, user and network namespaces, IPv6,
and read/write access to `/dev/net/tun`.
Use full managed SOCKS5 mode for native database ports and an isolated PID namespace.
For executor-wide capture and HTTP CONNECT routing, see the [exec-server setup](../exec-server/README.md#transparent-tcp).
The private DNS stub maps up to 8,192 names to stable addresses in `198.18.0.0/15`
and `fdc0:de00::/96`. The relay sends the original name and port to the attributed proxy;
literal addresses, including entries from a hosts file, are checked as IPs. Unknown
synthetic addresses are rejected. Loopback stays local. Each bridge permits 128 concurrent
connections. When the TCP stack is full, clients retry their opening SYN. The relay identifies each
native socket by both endpoints and its inode in the private namespace TCP tables. Closing that
socket cancels its pending approval connection, including when other clients use the same
destination. A client that shuts down only its write side can still receive a reply. General UDP,
QUIC, ICMP, legacy Landlock, host PID inheritance, and procfs fallback are outside this mode.
With transparent TCP off, Codex prefers the first `bwrap` found on `PATH`
outside the current working directory whenever it is available. If `bwrap` is
present but too old to support
`--argv0`, the helper keeps using system bubblewrap and switches to a
@@ -100,7 +122,8 @@ commands that would enter the bubblewrap path.
sockets unless `dangerously_allow_all_unix_sockets` is granted. Unix socket
pairs remain allowed for communication between related processes.
- When bubblewrap is active, it mounts a fresh `/proc` via `--proc /proc` by default.
If that mount is denied, it retains the inherited `/proc` and still creates a
Transparent TCP requires this mount and aborts the launch if it fails.
In other modes, if that mount is denied, it retains the inherited `/proc` and still creates a
PID namespace, preserving the existing fallback. `--no-proc` also retains the
inherited `/proc` without disabling PID isolation. In these cases, process IDs
inside the sandbox can differ from those exposed by `/proc`. Default invocations
@@ -0,0 +1,13 @@
"""Expose the Ubuntu 24.04 bubblewrap executable as a Bazel test fixture."""
def _extract(ctx):
ctx.download_and_extract(
url = "https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/bubblewrap_0.9.0-1ubuntu0.3_amd64.deb",
sha256 = "2461f1beee9cb04c8942739fe1a2b37e7b7c2a3d518f0779dc75f9245baa3094",
type = "deb",
)
ctx.extract("data.tar.zst")
ctx.file("BUILD.bazel", 'exports_files(["usr/bin/bwrap"])')
return ctx.repo_metadata(reproducible = True)
managed_tcp_test_bwrap = repository_rule(implementation = _extract)
+4 -2
View File
@@ -45,7 +45,7 @@ use ignore::overrides::OverrideBuilder;
///
/// These are intentionally system-level paths only (plus Nix store roots) so
/// `include_platform_defaults` does not silently widen access to user data.
const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
pub(super) const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
"/bin",
"/sbin",
"/usr",
@@ -107,11 +107,13 @@ pub(crate) enum BwrapNetworkMode {
/// Bubblewrap enforces this by unsharing the network namespace. The
/// proxy-routing bridge is established by the helper process after startup.
ProxyOnly,
/// The transparent TCP setup already isolated this command's network namespace.
PreparedProxy,
}
impl BwrapNetworkMode {
fn should_unshare_network(self) -> bool {
!matches!(self, Self::FullAccess)
matches!(self, Self::Isolated | Self::ProxyOnly)
}
}
+4
View File
@@ -135,6 +135,7 @@ fn translate_legacy_bwrap_fd_mounts(argv: &mut Vec<String>) -> Result<(), String
pub(crate) fn initialize_bwrap_launcher(
file_system_policy: &FileSystemSandboxPolicy,
sandbox_policy_cwd: &Path,
require_system: bool,
) {
LAUNCHER.get_or_init(|| {
if let Some(path) = find_system_bwrap_in_path(file_system_policy, sandbox_policy_cwd)
@@ -143,6 +144,9 @@ pub(crate) fn initialize_bwrap_launcher(
return BubblewrapLauncher::System(launcher);
}
if require_system {
panic!("transparent TCP requires a supported distro bubblewrap on PATH");
}
match bundled_bwrap::launcher() {
Some(launcher) => BubblewrapLauncher::Bundled(launcher),
None => BubblewrapLauncher::Unavailable,
+6 -2
View File
@@ -19,9 +19,9 @@ mod fd_mount;
mod launcher;
#[cfg(target_os = "linux")]
mod linux_run_main;
#[cfg(all(target_os = "linux", test))]
#[cfg(target_os = "linux")]
mod managed_tcp;
#[cfg(all(target_os = "linux", test))]
#[cfg(target_os = "linux")]
mod managed_tcp_dns;
#[cfg(target_os = "linux")]
mod proxy_lifecycle;
@@ -30,6 +30,10 @@ mod proxy_routing;
#[cfg(target_os = "linux")]
mod seccomp;
#[cfg(target_os = "linux")]
mod tcp_namespace;
#[cfg(target_os = "linux")]
mod tcp_net_setup;
#[cfg(target_os = "linux")]
mod wslg;
#[cfg(target_os = "linux")]
+86 -37
View File
@@ -22,8 +22,10 @@ use crate::bwrap::create_bwrap_command_args;
use crate::launcher::exec_bwrap;
use crate::launcher::initialize_bwrap_launcher;
use crate::launcher::preferred_bwrap_supports_argv0;
use crate::managed_tcp::Relay;
use crate::proxy_routing::activate_proxy_routes_in_netns;
use crate::proxy_routing::prepare_host_proxy_route_spec;
use crate::seccomp::ProxyRouting;
use crate::seccomp::apply_sandbox_policies_to_current_thread;
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_protocol::error::Result as CodexResult;
@@ -41,7 +43,6 @@ static PENDING_FORWARDED_SIGNAL: AtomicI32 = AtomicI32::new(0);
const FORWARDED_SIGNALS: &[libc::c_int] =
&[libc::SIGHUP, libc::SIGINT, libc::SIGQUIT, libc::SIGTERM];
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
const SYNTHETIC_MOUNT_MARKER_SYNTHETIC: &[u8] = b"synthetic\n";
const SYNTHETIC_MOUNT_MARKER_EXISTING: &[u8] = b"existing\n";
@@ -105,6 +106,14 @@ pub struct LinuxSandboxCommand {
#[arg(long = "proxy-route-spec", hide = true)]
pub proxy_route_spec: Option<String>,
/// Transparently route outbound TCP through the managed SOCKS proxy.
#[arg(long)]
pub transparent_tcp: bool,
/// Private TUN and resolver handoff from the trusted outer helper.
#[arg(long, hide = true, value_delimiter = ',')]
transparent_tcp_fd: Vec<std::os::fd::RawFd>,
/// Inherited fallback mounts that must be authenticated before sandboxed code runs.
#[arg(long = "verify-fd-mount", hide = true)]
pub verify_fd_mounts: Vec<String>,
@@ -141,12 +150,27 @@ pub fn run_main() -> ! {
apply_seccomp_then_exec,
managed_network,
proxy_route_spec,
transparent_tcp,
transparent_tcp_fd,
verify_fd_mounts,
no_proc,
inherit_pid_namespace,
command,
} = LinuxSandboxCommand::parse();
let allow_network_for_proxy = managed_network.is_some();
let routing = if transparent_tcp {
ProxyRouting::TransparentTcp
} else {
ProxyRouting::Explicit
};
if transparent_tcp
&& (!allow_network_for_proxy || use_legacy_landlock || inherit_pid_namespace || no_proc)
{
panic!("transparent TCP requires managed networking and private bubblewrap namespaces");
}
if transparent_tcp_fd.is_empty() == (transparent_tcp && apply_seccomp_then_exec) {
panic!("invalid transparent TCP namespace handoff");
}
if command.is_empty() {
panic!("No command specified to execute.");
@@ -169,50 +193,38 @@ pub fn run_main() -> ! {
panic!("failed to verify descriptor-backed bubblewrap mount: {err}");
}
let mut capability_header = [LINUX_CAPABILITY_VERSION_3, 0];
let mut capability_sets = [[0_u32; 3]; 2];
// SAFETY: capability ABI version 3 uses a [version, pid] header and
// two [effective, permitted, inheritable] capability-set entries.
let result = unsafe {
libc::syscall(
libc::SYS_capget,
capability_header.as_mut_ptr(),
capability_sets.as_mut_ptr(),
)
};
if result < 0 {
panic!(
"failed to verify Linux sandbox capabilities: {}",
std::io::Error::last_os_error()
);
}
if capability_sets
.into_iter()
.any(|[effective, permitted, _]| effective != 0 || permitted != 0)
{
let caps = rustix::thread::capabilities(None)
.unwrap_or_else(|err| panic!("failed to verify Linux sandbox capabilities: {err}"));
if !caps.effective.is_empty() || !caps.permitted.is_empty() {
panic!("Linux sandbox retained effective or permitted capabilities");
}
if allow_network_for_proxy {
let socks_bridge = if allow_network_for_proxy {
let spec = proxy_route_spec
.as_deref()
.unwrap_or_else(|| panic!("managed proxy mode requires --proxy-route-spec"));
if let Err(err) = activate_proxy_routes_in_netns(spec) {
panic!("error activating Linux proxy routing bridge: {err}");
}
}
let proxy_routing_active = allow_network_for_proxy;
activate_proxy_routes_in_netns(spec)
.unwrap_or_else(|err| panic!("error activating Linux proxy routing bridge: {err}"))
} else {
None
};
if let Err(e) = apply_sandbox_policies_to_current_thread(
&file_system_sandbox_policy,
network_sandbox_policy,
&sandbox_policy_cwd,
/*apply_landlock_fs*/ false,
managed_network.as_ref(),
proxy_routing_active,
allow_network_for_proxy.then_some(routing),
) {
panic!("error applying Linux sandbox restrictions: {e:?}");
}
// Keep the TUN and resolver descriptors in the reaper. Start networking
// threads only after the single-threaded workload fork.
let transport = transparent_tcp.then(|| {
Relay::prepare(transparent_tcp_fd, socks_bridge)
.unwrap_or_else(|err| panic!("failed to prepare transparent TCP: {err}"))
});
let signal_mask = ForwardedSignalMask::block();
let command_pid = unsafe { libc::fork() };
if command_pid < 0 {
@@ -221,12 +233,24 @@ pub fn run_main() -> ! {
}
if command_pid == 0 {
if let Some((relay, mut reader)) = transport {
drop(relay);
reader
.read_exact(&mut [0])
.unwrap_or_else(|err| panic!("transparent TCP relay failed to start: {err}"));
}
reset_forwarded_signal_handlers_to_default();
signal_mask.restore();
exec_or_panic(command);
}
let signal_forwarders = install_bwrap_signal_forwarders(command_pid);
if let Some((relay, reader)) = transport {
drop(reader);
relay
.serve()
.unwrap_or_else(|err| panic!("failed to start transparent TCP relay: {err}"));
}
signal_mask.restore();
loop {
let mut status = 0;
@@ -255,7 +279,7 @@ pub fn run_main() -> ! {
&sandbox_policy_cwd,
/*apply_landlock_fs*/ false,
managed_network.as_ref(),
/*proxy_routing_active*/ false,
/*proxy_routing*/ None,
) {
panic!("error applying Linux sandbox restrictions: {e:?}");
}
@@ -267,10 +291,11 @@ pub fn run_main() -> ! {
// sandboxed environment to apply seccomp. This path never falls back
// to legacy Landlock on failure.
let (proxy_route_spec, proxy_controls) = if let Some(context) = managed_network.as_ref() {
// Older explicit-proxy callers may omit the port list.
let ports =
(!context.loopback_ports.is_empty()).then_some(context.loopback_ports.as_slice());
let (proxy_route_spec, controls) = prepare_host_proxy_route_spec(ports)
// Older explicit-proxy callers may omit ports. The new transparent
// switch always requires the executor's trusted ingress port list.
let ports = (transparent_tcp || !context.loopback_ports.is_empty())
.then_some(context.loopback_ports.as_slice());
let (proxy_route_spec, controls) = prepare_host_proxy_route_spec(ports, routing)
.unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}"));
(Some(proxy_route_spec), controls)
} else {
@@ -317,6 +342,7 @@ pub fn run_main() -> ! {
options,
inner,
proxy_controls,
transparent_tcp,
);
}
@@ -327,7 +353,7 @@ pub fn run_main() -> ! {
&sandbox_policy_cwd,
/*apply_landlock_fs*/ true,
managed_network.as_ref(),
/*proxy_routing_active*/ false,
/*proxy_routing*/ None,
) {
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
}
@@ -399,13 +425,21 @@ fn run_bwrap_with_proc_fallback(
mut options: BwrapOptions,
inner: Vec<String>,
proxy_controls: Vec<File>,
transparent_tcp: bool,
) -> ! {
let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd);
// Select before the preflight, whose synthetic policy omits writable roots.
initialize_bwrap_launcher(file_system_sandbox_policy, sandbox_policy_cwd);
initialize_bwrap_launcher(
file_system_sandbox_policy,
sandbox_policy_cwd,
transparent_tcp,
);
if options.mount_proc
// Transparent TCP must mount procfs for the private PID namespace. Let the
// real bwrap launch fail if it cannot, since a root bind can expose host procfs.
if !transparent_tcp
&& options.mount_proc
&& !preflight_proc_mount_support(options)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err))
{
@@ -414,6 +448,12 @@ fn run_bwrap_with_proc_fallback(
options.mount_proc = false;
}
let tcp_files = transparent_tcp.then(|| {
let files = crate::tcp_namespace::create()
.unwrap_or_else(|err| panic!("failed to create private TCP namespace: {err}"));
options.network_mode = BwrapNetworkMode::PreparedProxy;
files
});
let mut bwrap_args = build_bwrap_argv(
inner,
file_system_sandbox_policy,
@@ -422,6 +462,15 @@ fn run_bwrap_with_proc_fallback(
options,
)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err));
if let Some(files) = tcp_files {
crate::tcp_net_setup::prepare(
files,
&mut bwrap_args,
file_system_sandbox_policy,
sandbox_policy_cwd,
)
.unwrap_or_else(|err| panic!("failed to prepare transparent TCP namespace: {err}"));
}
bwrap_args.preserved_files.extend(proxy_controls);
apply_inner_command_argv0(&mut bwrap_args.args);
run_or_exec_bwrap(bwrap_args);
+92 -40
View File
@@ -1,13 +1,10 @@
//! Translate a TUN worker’s TCP destination to the original hostname or literal IP
//! for the managed SOCKS5 bridge.
//! Run the bundled TCP stack in the command's private network namespace.
use crate::managed_tcp_dns::Names;
use crate::proxy_routing::proxy_bidirectional;
use rustix::net::sockopt;
use socks5_protocol::Address;
use socks5_protocol::AuthMethod;
use socks5_protocol::AuthRequest;
use socks5_protocol::AuthResponse;
use socks5_protocol::Command as SocksCommand;
use socks5_protocol::CommandReply;
use socks5_protocol::CommandRequest;
use socks5_protocol::CommandResponse;
@@ -16,53 +13,102 @@ use socks5_protocol::sync::FromIO;
use std::io;
use std::io::Write;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::net::TcpStream;
use std::net::UdpSocket;
use std::os::fd::OwnedFd;
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
#[path = "managed_tcp_pending.rs"]
mod pending;
#[path = "managed_tcp_stack.rs"]
mod stack;
const TIMEOUT: Duration = Duration::from_secs(10);
fn relay(
mut client: TcpStream,
pub(super) struct Relay {
tun: OwnedFd,
ready: std::io::PipeWriter,
proxy: SocketAddr,
names: &Mutex<Names>,
) -> socks5_protocol::Result<()> {
client.set_read_timeout(Some(TIMEOUT))?;
client.set_write_timeout(Some(TIMEOUT))?;
Version::read_from(&mut client)?;
let offered = AuthRequest::read_from(&mut client)?;
if !offered.0.contains(&AuthMethod::Noauth) {
return Err(io::Error::other("unsupported SOCKS greeting").into());
}
Version::V5.write_to(&mut client)?;
AuthResponse::new(AuthMethod::Noauth).write_to(&mut client)?;
dns_udp: UdpSocket,
dns_tcp: TcpListener,
}
let upstream = match connect(&mut client, proxy, names) {
Ok(stream) => stream,
Err(err) => {
let denied = CommandResponse::reply_error(CommandReply::ConnectionNotAllowedByRuleset);
let _ = write_frame(&mut client, &denied);
return Err(err);
impl Relay {
pub(super) fn prepare(
fds: Vec<std::os::fd::RawFd>,
proxy: Option<SocketAddr>,
) -> io::Result<(Self, std::io::PipeReader)> {
let proxy =
proxy.ok_or_else(|| io::Error::other("transparent TCP requires managed SOCKS5"))?;
let [tun, udp, tcp] = crate::tcp_net_setup::receive(fds)?;
let (reader, ready) = std::io::pipe()?;
Ok((
Self {
tun,
ready,
proxy,
dns_udp: udp.into(),
dns_tcp: tcp.into(),
},
reader,
))
}
pub(super) fn serve(mut self) -> io::Result<()> {
let names = Arc::new(Mutex::new(Names::default()));
crate::managed_tcp_dns::serve(Arc::clone(&names), self.dns_udp, self.dns_tcp)?;
let (started, readiness) = std::sync::mpsc::sync_channel(1);
thread::Builder::new().spawn(move || {
// This is the namespace reaper. Losing the data path must end the workload.
if let Ok(Err(error)) =
std::panic::catch_unwind(|| stack::run(self.tun, self.proxy, names, started))
{
eprintln!("transparent TCP stack exited: {error}");
}
std::process::exit(1);
})?;
// The workload is blocked on the pipe. Complete a native handshake through
// the TUN and check that its socket identity is visible before releasing it.
// This reserved address is never issued by DNS or forwarded upstream.
let probe_target = SocketAddr::from(([198, 18, 0, 0], 9));
let probe = TcpStream::connect_timeout(&probe_target, TIMEOUT)?;
let source = readiness
.recv_timeout(TIMEOUT)
.map_err(io::Error::other)??;
if source != probe.local_addr()? {
return Err(io::Error::other("unexpected readiness source"));
}
};
write_frame(&mut client, &CommandResponse::success(Address::default()))?;
client.set_read_timeout(None)?;
client.set_write_timeout(None)?;
upstream.set_read_timeout(None)?;
upstream.set_write_timeout(None)?;
Ok(proxy_bidirectional(upstream, client)?)
self.ready.write_all(&[1])
}
}
pub(super) fn accept_with_backoff(listener: &TcpListener) -> io::Result<TcpStream> {
loop {
let error = match listener.accept() {
Ok((stream, _)) => return Ok(stream),
Err(error) => error,
};
match error.raw_os_error() {
Some(libc::EINTR | libc::ECONNABORTED) => continue,
Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM) => {
thread::sleep(Duration::from_millis(100))
}
_ => return Err(error),
}
}
}
fn connect(
client: &mut impl io::Read,
source: SocketAddr,
target: SocketAddr,
proxy: SocketAddr,
names: &Mutex<Names>,
) -> socks5_protocol::Result<TcpStream> {
let request = CommandRequest::read_from(client)?;
let (SocksCommand::Connect, Address::SocketAddr(target)) = (request.command, request.address)
else {
return Err(io::Error::other("expected TCP CONNECT to a TUN destination IP").into());
};
let destination = match names
.lock()
.map_err(|_| io::Error::other("DNS name table is unavailable"))?
@@ -72,6 +118,7 @@ fn connect(
None => Address::SocketAddr(target),
};
let owner = pending::Owner::capture(source, target)?;
let mut upstream = TcpStream::connect_timeout(&proxy, TIMEOUT)?;
upstream.set_read_timeout(Some(TIMEOUT))?;
upstream.set_write_timeout(Some(TIMEOUT))?;
@@ -84,18 +131,23 @@ fn connect(
write_frame(&mut upstream, &CommandRequest::connect(destination))?;
// The trusted proxy owns the approval deadline. The relay's handshake
// timeout applies to framing and negotiation, not the human decision.
upstream.set_read_timeout(None)?;
if let Err(error) = owner.wait_for_reply(&upstream) {
// An abort wakes both copies in the host bridge while approval is outstanding.
let _ = sockopt::set_socket_linger(&upstream, Some(Duration::ZERO));
return Err(error.into());
}
upstream.set_read_timeout(Some(TIMEOUT))?;
let reply = CommandResponse::read_from(&mut upstream)?;
if reply.reply != CommandReply::Succeeded
|| matches!(reply.address, Address::Domain(ref name, _) if name.is_empty())
{
return Err(io::Error::other("invalid managed SOCKS5 success reply").into());
}
upstream.set_nonblocking(true)?;
Ok(upstream)
}
// Buffer the codec's field-by-field output before calling write_all. A denial
// reply is best effort; the authorization decision does not depend on its delivery.
// Emit the codec's field-by-field CONNECT frame in one write.
fn write_frame(stream: &mut TcpStream, message: &impl FromIO) -> socks5_protocol::Result<()> {
let mut bytes = Vec::new();
message.write_to(&mut bytes)?;
@@ -16,9 +16,18 @@ use hickory_proto::serialize::binary::BinDecoder;
use hickory_proto::serialize::binary::BinEncodable;
use indexmap::IndexSet;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use std::net::TcpListener;
use std::net::TcpStream;
use std::net::UdpSocket;
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
use std::time::Duration;
const MAX_NAMES: usize = 8192;
const V4_BASE: u32 = u32::from_be_bytes([198, 18, 0, 0]);
@@ -112,6 +121,57 @@ impl Names {
}
}
pub(super) fn serve(names: Arc<Mutex<Names>>, udp: UdpSocket, tcp: TcpListener) -> io::Result<()> {
let udp_names = Arc::clone(&names);
thread::Builder::new().spawn(move || {
let mut buf = [0; 4096];
while let Ok((n, peer)) = udp.recv_from(&mut buf) {
let reply = udp_names
.lock()
.ok()
.and_then(|mut table| table.answer(&buf[..n]));
if let Some(reply) = reply {
let _ = udp.send_to(&reply, peer);
}
}
})?;
// Fixed workers bound concurrent DNS-over-TCP exchanges. Some resolvers
// pipeline A and AAAA queries on the same connection.
for _ in 0..4 {
let listener = tcp.try_clone()?;
let names = Arc::clone(&names);
thread::Builder::new().spawn(move || {
while let Ok(stream) = crate::managed_tcp::accept_with_backoff(&listener) {
let _ = answer_tcp(stream, &names);
}
})?;
}
Ok(())
}
fn answer_tcp(mut stream: TcpStream, names: &Mutex<Names>) -> io::Result<()> {
let timeout = Some(Duration::from_secs(2));
stream.set_read_timeout(timeout)?;
stream.set_write_timeout(timeout)?;
let mut buf = [0; 4096];
for _ in 0..32 {
let mut size = [0; 2];
stream.read_exact(&mut size)?;
let query = buf
.get_mut(..usize::from(u16::from_be_bytes(size)))
.ok_or_else(|| io::Error::other("oversized DNS query"))?;
stream.read_exact(query)?;
let reply = names
.lock()
.map_err(|_| io::Error::other("DNS name table is unavailable"))?
.answer(query)
.ok_or_else(|| io::Error::other("invalid DNS query"))?;
stream.write_all(&(reply.len() as u16).to_be_bytes())?;
stream.write_all(&reply)?;
}
Ok(())
}
#[cfg(test)]
#[path = "managed_tcp_tests.rs"]
mod tests;
@@ -0,0 +1,140 @@
//! Pin the native socket inode before asking for approval. Linux keeps the inode
//! for a live reader after shutdown(SHUT_WR), and clears it on the last close.
use std::collections::HashSet;
use std::fs::File;
use std::io;
use std::io::BufRead;
use std::io::BufReader;
use std::net::IpAddr;
use std::net::Ipv6Addr;
use std::net::SocketAddr;
use std::net::TcpStream;
use std::sync::Mutex;
use std::time::Duration;
use std::time::Instant;
const INTERVAL: Duration = Duration::from_millis(500);
const TCP4: &str = "/proc/self/net/tcp";
const TCP6: &str = "/proc/self/net/tcp6";
pub(super) struct Owner {
inode: u64,
captured: Instant,
table: &'static str,
}
fn find_inode(
table: &str,
mut matches: impl FnMut(&str, &str, u64) -> bool,
) -> io::Result<Option<u64>> {
let mut reader = BufReader::new(File::open(table)?);
let mut line = String::new();
reader.read_line(&mut line)?;
if !line.contains("local_address") || !line.contains("inode") {
return Err(io::Error::other("missing native TCP socket table"));
}
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
return Ok(None);
}
let mut fields = line.split_ascii_whitespace().skip(1);
let (Some(local), Some(remote), Some(inode)) =
(fields.next(), fields.next(), fields.nth(6))
else {
return Err(io::Error::other("unexpected native TCP socket record"));
};
let inode = inode.parse::<u64>().map_err(io::Error::other)?;
if inode != 0 && matches(local, remote, inode) {
return Ok(Some(inode));
}
}
}
fn v6_key(addr: SocketAddr) -> String {
let ip: Ipv6Addr = match addr.ip() {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
// procfs renders each 32-bit word in native byte order.
let address: String = ip
.octets()
.as_chunks::<4>()
.0
.iter()
.map(|word| format!("{:08X}", u32::from_ne_bytes(*word)))
.collect();
format!("{address}:{:04X}", addr.port())
}
impl Owner {
pub(super) fn capture(source: SocketAddr, target: SocketAddr) -> io::Result<Self> {
let lookup = |table, local: String, remote: String| {
let inode = find_inode(table, |from, to, _| from == local && to == remote)?;
Ok::<_, io::Error>(inode.map(|inode| Self {
inode,
table,
captured: Instant::now(),
}))
};
if let (SocketAddr::V4(src), SocketAddr::V4(dst)) = (source, target) {
let key = |addr: std::net::SocketAddrV4| {
format!(
"{:08X}:{:04X}",
u32::from_ne_bytes(addr.ip().octets()),
addr.port()
)
};
if let Some(owner) = lookup(TCP4, key(src), key(dst))? {
return Ok(owner);
}
}
lookup(TCP6, v6_key(source), v6_key(target))?.ok_or(io::ErrorKind::ConnectionAborted.into())
}
fn still_owned(&self) -> io::Result<bool> {
// Only slow approvals need polling. Share one bounded snapshot per family.
type Snapshot = Mutex<Option<(Instant, Option<HashSet<u64>>)>>;
static V4: Snapshot = Mutex::new(None);
static V6: Snapshot = Mutex::new(None);
let cache = if self.table == TCP4 { &V4 } else { &V6 };
let mut cache = cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if cache
.as_ref()
.is_none_or(|(sampled, _)| *sampled < self.captured || sampled.elapsed() >= INTERVAL)
{
let sampled = Instant::now();
let mut inodes = HashSet::new();
let overflow = find_inode(self.table, |_, _, inode| {
inodes.insert(inode);
inodes.len() > 16_384
})?;
*cache = Some((sampled, overflow.is_none().then_some(inodes)));
}
if let Some((_, Some(inodes))) = &*cache {
return Ok(inodes.contains(&self.inode));
}
drop(cache);
Ok(find_inode(self.table, |_, _, inode| inode == self.inode)?.is_some())
}
pub(super) fn wait_for_reply(&self, stream: &TcpStream) -> io::Result<()> {
stream.set_read_timeout(Some(INTERVAL))?;
loop {
match stream.peek(&mut [0]) {
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) => {}
result => return result.map(|_| ()),
}
if !self.still_owned()? {
return Err(io::ErrorKind::ConnectionAborted.into());
}
}
}
}
@@ -1,72 +1,38 @@
#![cfg(test)]
//! Verify rejected TUN requests cannot open an upstream connection.
//! A denied managed CONNECT never yields a stream for the TCP stack.
use super::*;
use pretty_assertions::assert_eq;
use std::io::Read;
use std::io::Write;
use std::net::Ipv4Addr;
use std::net::TcpListener;
use std::thread;
#[test]
fn invalid_tun_requests_never_open_upstream() {
let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let addr = proxy.local_addr().unwrap();
for request in [
[5, 3, 0, 1, 127, 0, 0, 1, 0, 53],
[5, 2, 0, 1, 127, 0, 0, 1, 0, 80],
[5, 1, 1, 1, 127, 0, 0, 1, 0, 80],
[5, 1, 0, 3, 3, b'd', b'b', b'x', 0, 80],
[5, 1, 0, 1, 198, 18, 0, 99, 1, 187],
] {
assert!(connect(&mut request.as_slice(), addr, &Mutex::new(Names::default())).is_err());
}
proxy.set_nonblocking(true).unwrap();
assert_eq!(
proxy.accept().unwrap_err().kind(),
io::ErrorKind::WouldBlock
);
}
#[test]
fn denied_socks_connect_never_forwards_pipelined_payload() {
fn denied_socks_connect_never_returns_an_upstream() {
let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let proxy_addr = proxy.local_addr().unwrap();
let native_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let target = native_listener.local_addr().unwrap();
let native = TcpStream::connect(target).unwrap();
let source = native.local_addr().unwrap();
let task =
thread::spawn(move || connect(source, target, proxy_addr, &Mutex::new(Names::default())));
rustix::net::sockopt::set_socket_timeout(
&proxy,
rustix::net::sockopt::Timeout::Recv,
Some(TIMEOUT),
)
.unwrap();
let proxy_addr = proxy.local_addr().unwrap();
let incoming = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let mut client = TcpStream::connect(incoming.local_addr().unwrap()).unwrap();
let (server, _) = incoming.accept().unwrap();
let task = thread::spawn(move || relay(server, proxy_addr, &Mutex::new(Names::default())));
// A malicious client can send application data before either handshake completes.
let connect = [5, 1, 0, 1, 192, 0, 2, 7, 0x15, 0x38];
client.write_all(&[5, 2, 2, 0]).unwrap();
client.write_all(&connect).unwrap();
client.write_all(b"private marker").unwrap();
client.shutdown(std::net::Shutdown::Write).unwrap();
let (mut upstream, _) = proxy.accept().unwrap();
upstream.set_read_timeout(Some(TIMEOUT)).unwrap();
let mut greeting = [0; 3];
upstream.read_exact(&mut greeting).unwrap();
assert_eq!(greeting, [5, 1, 0]);
upstream.write_all(&[5, 0]).unwrap();
let mut request = [0; 10];
upstream.read_exact(&mut request).unwrap();
assert_eq!(request, connect);
let request = CommandRequest::read_from(&mut upstream).unwrap();
assert!(matches!(request.address, Address::SocketAddr(address) if address == target));
upstream.write_all(&[5, 2, 0, 1, 0, 0, 0, 0, 0, 0]).unwrap();
let mut response = [0; 12]; // greeting and refusal sent to the client
client.read_exact(&mut response).unwrap();
assert_eq!(&response[..4], &[5, 0, 5, 2]);
let mut byte = [0];
assert_eq!(upstream.read(&mut byte).unwrap(), 0);
assert_eq!(upstream.read(&mut [0]).unwrap(), 0);
assert!(task.join().unwrap().is_err());
}
@@ -0,0 +1,344 @@
//! Drive a bounded set of smoltcp sockets on the TUN inherited from namespace setup.
//! The kernel still owns the native client sockets. Only the managed SOCKS bridge
//! can supply an upstream stream, and only after it has approved the destination.
use super::Names;
use etherparse::IpNumber;
use etherparse::IpSlice;
use etherparse::Ipv6ExtensionSlice;
use etherparse::TcpSlice;
use smoltcp::iface::Config;
use smoltcp::iface::Interface;
use smoltcp::iface::PollResult;
use smoltcp::iface::SocketHandle;
use smoltcp::iface::SocketSet;
use smoltcp::phy::Device;
use smoltcp::phy::DeviceCapabilities;
use smoltcp::phy::Medium;
use smoltcp::phy::RxToken;
use smoltcp::phy::TunTapInterface;
use smoltcp::socket::tcp::Socket;
use smoltcp::socket::tcp::SocketBuffer;
use smoltcp::socket::tcp::State;
use smoltcp::time::Instant;
use smoltcp::wire::HardwareAddress;
use smoltcp::wire::IpCidr;
use std::collections::HashMap;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use std::net::Shutdown;
use std::net::SocketAddr;
use std::net::TcpStream;
use std::os::fd::AsRawFd;
use std::os::fd::IntoRawFd;
use std::os::fd::OwnedFd;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::mpsc;
use std::thread;
const MTU: usize = 1500;
const MAX_FLOWS: usize = 128;
const WINDOW: usize = 32 * 1024;
struct Packet<'a>(&'a [u8]);
impl RxToken for Packet<'_> {
fn consume<R, F: FnOnce(&[u8]) -> R>(self, f: F) -> R {
f(self.0)
}
}
// Give smoltcp exactly the frame admitted below. Its ordinary TUN device
// owns transmission, including backpressure and MTU handling.
struct Ingress<'a> {
device: &'a mut TunTapInterface,
packet: Option<&'a [u8]>,
}
impl Device for Ingress<'_> {
type RxToken<'a>
= Packet<'a>
where
Self: 'a;
type TxToken<'a>
= <TunTapInterface as Device>::TxToken<'a>
where
Self: 'a;
fn receive(&mut self, now: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
Some((Packet(self.packet.take()?), self.device.transmit(now)?))
}
fn transmit(&mut self, now: Instant) -> Option<Self::TxToken<'_>> {
self.device.transmit(now)
}
fn capabilities(&self) -> DeviceCapabilities {
self.device.capabilities()
}
}
type Endpoints = (SocketAddr, SocketAddr);
// Allocate a listener only for an opening SYN. smoltcp validates the complete
// packet, tracks sequence numbers, and owns retransmission and flow control.
fn tcp_endpoints(packet: &[u8]) -> Option<(Endpoints, bool)> {
let ip = IpSlice::from_slice(packet).ok()?;
if ip.is_fragmenting_payload() || ip.payload_ip_number() != IpNumber::TCP {
return None;
}
// Match the extensions supported by the stack before allocating a listener.
let supported = match &ip {
IpSlice::Ipv4(v4) => v4.extensions().auth.is_none(),
IpSlice::Ipv6(v6) => v6.extensions().clone().into_iter().all(|ext| {
matches!(ext, Ipv6ExtensionSlice::HopByHop(header) if header.next_header() == IpNumber::TCP)
}),
};
if !supported {
return None;
}
let tcp = TcpSlice::from_slice(ip.payload().payload).ok()?;
let endpoints = (
SocketAddr::new(ip.source_addr(), tcp.source_port()),
SocketAddr::new(ip.destination_addr(), tcp.destination_port()),
);
Some((
endpoints,
tcp.syn() && !tcp.ack() && !tcp.rst() && !tcp.fin() && tcp.destination_port() != 0,
))
}
enum Session {
Handshake,
Pending(mpsc::Receiver<TcpStream>),
Open {
upstream: TcpStream,
write_closed: bool,
read_closed: bool,
},
Done,
}
impl Session {
fn pump(&mut self, socket: &mut Socket<'_>, poll: &mut Vec<libc::pollfd>) -> io::Result<()> {
if let Self::Pending(result) = self {
match result.try_recv() {
Ok(upstream) => {
*self = Self::Open {
upstream,
write_closed: false,
read_closed: false,
}
}
Err(mpsc::TryRecvError::Empty) => return Ok(()),
Err(mpsc::TryRecvError::Disconnected) => {
return Err(io::ErrorKind::ConnectionAborted.into());
}
}
}
let Self::Open {
upstream,
write_closed,
read_closed,
} = self
else {
return Ok(());
};
if socket.can_recv() {
socket
.recv(|bytes| transfer(upstream.write(bytes)))
.map_err(io::Error::other)??;
}
if !socket.may_recv() && !*write_closed {
upstream.shutdown(Shutdown::Write)?;
*write_closed = true;
}
if !*read_closed && socket.can_send() {
let n = socket
.send(|space| transfer(upstream.read(space)))
.map_err(io::Error::other)??;
if n == Some(0) {
socket.close();
*read_closed = true;
}
}
let mut events = 0;
if !*read_closed && socket.can_send() {
events |= libc::POLLIN;
}
if socket.can_recv() {
events |= libc::POLLOUT;
}
if events != 0 {
poll.push(libc::pollfd {
fd: upstream.as_raw_fd(),
events,
revents: 0,
});
}
Ok(())
}
}
fn transfer(result: io::Result<usize>) -> (usize, io::Result<Option<usize>>) {
match result {
Ok(n) => (n, Ok(Some(n))),
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) =>
{
(0, Ok(None))
}
Err(e) => (0, Err(e)),
}
}
fn interface(tun: &mut TunTapInterface) -> io::Result<Interface> {
let mut config = Config::new(HardwareAddress::Ip);
config.random_seed = rand::random();
let mut iface = Interface::new(config, tun, Instant::ZERO);
let v4 = Ipv4Addr::new(192, 0, 2, 2);
let v6 = Ipv6Addr::LOCALHOST;
let mut addresses_fit = true;
iface.update_ip_addrs(|ips| {
for ip in [IpAddr::V4(v4), IpAddr::V6(v6)] {
addresses_fit &= ips
.push(IpCidr::new(ip.into(), if ip.is_ipv4() { 32 } else { 128 }))
.is_ok();
}
});
if !addresses_fit {
return Err(io::Error::other("insufficient TCP interface addresses"));
}
iface
.routes_mut()
.add_default_ipv4_route(v4)
.map_err(io::Error::other)?;
iface
.routes_mut()
.add_default_ipv6_route(v6)
.map_err(io::Error::other)?;
iface.set_any_ip(true);
Ok(iface)
}
pub(super) fn run(
fd: OwnedFd,
proxy: SocketAddr,
names: Arc<Mutex<Names>>,
started: mpsc::SyncSender<io::Result<SocketAddr>>,
) -> io::Result<()> {
use rustix::fs::OFlags;
use rustix::fs::fcntl_getfl;
use rustix::fs::fcntl_setfl;
fcntl_setfl(&fd, fcntl_getfl(&fd)? | OFlags::NONBLOCK)?;
// from_fd takes ownership of the descriptor opened in the private namespace.
let mut tun = TunTapInterface::from_fd(fd.into_raw_fd(), Medium::Ip, MTU)?;
let mut iface = interface(&mut tun)?;
let clock = std::time::Instant::now();
let mut started = Some(started);
let mut sockets = SocketSet::new(Vec::new());
let mut flows: HashMap<Endpoints, (SocketHandle, Session)> = HashMap::new();
let permits = Arc::new(async_lock::Semaphore::new(MAX_FLOWS));
let mut poll = Vec::with_capacity(MAX_FLOWS + 1);
loop {
let now = Instant::from_micros(clock.elapsed().as_micros() as i64);
iface.poll_maintenance(now);
if let Some((rx, _)) = tun.receive(now) {
rx.consume(|packet| -> io::Result<()> {
if packet.is_empty() {
return Err(io::ErrorKind::UnexpectedEof.into());
}
let Some((key, syn)) = tcp_endpoints(packet) else {
return Ok(());
};
if syn && !flows.contains_key(&key) {
if flows.len() == MAX_FLOWS {
return Ok(()); // The kernel retries when a slot opens.
}
let buffer = || SocketBuffer::new(vec![0; WINDOW]);
let mut native = Socket::new(buffer(), buffer());
native.set_timeout(Some(super::TIMEOUT.into()));
native.listen(key.1).map_err(io::Error::other)?;
flows.insert(key, (sockets.add(native), Session::Handshake));
}
iface.poll_ingress_single(
now,
&mut Ingress {
device: &mut tun,
packet: Some(packet),
},
&mut sockets,
);
Ok(())
})?;
}
iface.poll_egress(now, &mut tun, &mut sockets);
poll.clear();
poll.push(libc::pollfd {
fd: tun.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
});
let mut pending = false;
flows.retain(|&(src, dst), (handle, session)| {
let native = sockets.get_mut::<Socket>(*handle);
if !native.is_active() {
sockets.remove(*handle);
return false;
}
if matches!(session, Session::Handshake)
&& matches!(native.state(), State::Established | State::CloseWait)
{
native.set_timeout(None);
if let Some(started) = started.take() {
let result = if dst == SocketAddr::from(([198, 18, 0, 0], 9)) {
super::pending::Owner::capture(src, dst).map(|_| src)
} else {
Err(io::Error::other("unexpected readiness destination"))
};
let _ = started.send(result);
native.close();
*session = Session::Done;
return true;
}
let Some(permit) = permits.try_acquire_arc() else {
pending = true;
return true;
};
let (reply, result) = mpsc::sync_channel(1);
let names = Arc::clone(&names);
let _ = thread::Builder::new().spawn(move || {
let _permit = permit;
if let Ok(upstream) = super::connect(src, dst, proxy, &names) {
let _ = reply.send(upstream);
}
});
*session = Session::Pending(result);
}
if session.pump(native, &mut poll).is_err() {
native.abort();
*session = Session::Done;
}
pending |= matches!(session, Session::Pending(_));
true
});
while iface.poll_egress(now, &mut tun, &mut sockets) == PollResult::SocketStateChanged {}
let delay = iface
.poll_delay(now, &sockets)
.map(|d| d.total_millis().min(i32::MAX as u64) as i32);
let timeout = if pending {
delay.unwrap_or(25).min(25)
} else {
delay.unwrap_or(-1)
};
// SAFETY: all descriptors in poll are owned by tun or live entries in flows.
let result = unsafe { libc::poll(poll.as_mut_ptr(), poll.len() as _, timeout) };
if result < 0 && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted {
return Err(io::Error::last_os_error());
}
}
}
+33 -9
View File
@@ -3,6 +3,8 @@ use crate::proxy_lifecycle::harden_bridge_process;
use crate::proxy_lifecycle::move_fd_above_stdio;
use crate::proxy_lifecycle::receive_listener;
use crate::proxy_lifecycle::send_listener;
use crate::seccomp::ProxyRouting;
use async_lock::Semaphore;
use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
use codex_network_proxy::write_attribution_frame;
use serde::Deserialize;
@@ -26,7 +28,6 @@ use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
use tokio::sync::Semaphore;
use url::Url;
const HOST_BRIDGE_READY: u8 = 1;
@@ -58,6 +59,7 @@ struct ProxyRoutePlan {
pub(crate) fn prepare_host_proxy_route_spec(
port_allowlist: Option<&[u16]>,
routing: ProxyRouting,
) -> io::Result<(String, Vec<File>)> {
let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect());
// SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or
@@ -96,6 +98,7 @@ pub(crate) fn prepare_host_proxy_route_spec(
}
let control = spawn_host_bridge(
route.endpoint,
routing,
attribution_token.as_deref(),
&mut control_by_endpoint,
)?;
@@ -128,7 +131,9 @@ fn extract_attribution_token_and_plan(
(attribution_token, plan)
}
pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> {
pub(crate) fn activate_proxy_routes_in_netns(
serialized_spec: &str,
) -> io::Result<Option<SocketAddr>> {
let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?;
if spec.routes.is_empty() {
@@ -179,6 +184,7 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
local_port_by_control.insert(route.control_fd, local_port);
}
let mut socks_bridge = None;
for route in spec.routes {
let Some(local_port) = local_port_by_control.get(&route.control_fd) else {
return Err(io::Error::other("missing proxy listener port"));
@@ -195,6 +201,11 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
format!("could not rewrite proxy URL for env key {}", route.env_key),
));
};
if route.env_key == "ALL_PROXY"
&& Url::parse(&rewritten).is_ok_and(|url| matches!(url.scheme(), "socks5h" | "socks5"))
{
socks_bridge = Some(SocketAddr::from((Ipv4Addr::LOCALHOST, *local_port)));
}
// SAFETY: this helper process is single-threaded at this point, and
// env mutation happens before execing the user command.
unsafe {
@@ -202,7 +213,7 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
}
}
Ok(())
Ok(socks_bridge)
}
fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
@@ -320,6 +331,7 @@ fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option<String> {
fn spawn_host_bridge(
endpoint: SocketAddr,
routing: ProxyRouting,
attribution_token: Option<&str>,
inherited_controls: &mut BTreeMap<SocketAddr, File>,
) -> io::Result<File> {
@@ -338,7 +350,13 @@ fn spawn_host_bridge(
// A worker must not retain another route's inner bootstrap endpoint.
// Drop this child's inherited owners; the parent's copies are unaffected by fork.
inherited_controls.clear();
let result = run_host_bridge(endpoint, host_control, attribution_token, parent_pid);
let result = run_host_bridge(
endpoint,
routing,
host_control,
attribution_token,
parent_pid,
);
if result.is_err() {
unsafe { libc::_exit(1) };
}
@@ -365,9 +383,14 @@ impl ConnectionSlots {
Self(Arc::new(Semaphore::new(MAX_CONNECTIONS)))
}
pub(super) fn spawn(&self, serve: impl FnOnce() + Send + 'static) {
let Ok(permit) = Arc::clone(&self.0).try_acquire_owned() else {
return;
pub(super) fn spawn(&self, routing: ProxyRouting, serve: impl FnOnce() + Send + 'static) {
// Wait in the accept loop, before allocating another thread or upstream socket.
let permit = match routing {
ProxyRouting::TransparentTcp => self.0.acquire_arc_blocking(),
ProxyRouting::Explicit => match self.0.try_acquire_arc() {
Some(permit) => permit,
None => return,
},
};
let _ = std::thread::Builder::new().spawn(move || {
let _permit = permit;
@@ -378,6 +401,7 @@ impl ConnectionSlots {
fn run_host_bridge(
endpoint: SocketAddr,
routing: ProxyRouting,
mut control: UnixStream,
attribution_token: Option<&str>,
parent_pid: libc::pid_t,
@@ -396,7 +420,7 @@ fn run_host_bridge(
loop {
let (client_stream, _) = listener.accept()?;
let attribution_token = attribution_token.clone();
slots.spawn(move || {
slots.spawn(routing, move || {
let mut tcp_stream = match TcpStream::connect(endpoint) {
Ok(stream) => stream,
Err(_) => return,
@@ -431,7 +455,7 @@ fn bind_local_loopback_listener() -> io::Result<TcpListener> {
}
}
fn ensure_loopback_interface_up() -> io::Result<()> {
pub(super) fn ensure_loopback_interface_up() -> io::Result<()> {
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
if fd < 0 {
return Err(io::Error::last_os_error());
@@ -11,6 +11,8 @@ use super::parse_loopback_proxy_endpoint;
use super::plan_proxy_routes;
use super::proxy_bidirectional;
use super::rewrite_proxy_env_value;
use crate::seccomp::ProxyRouting;
use async_lock::Semaphore;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::io::Read;
@@ -22,7 +24,6 @@ use std::net::TcpListener;
use std::net::TcpStream;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
#[tokio::test]
async fn a_reset_on_either_side_releases_the_bridge_permit() {
@@ -41,7 +42,7 @@ async fn a_reset_on_either_side_releases_the_bridge_permit() {
(idle_socket, reset_socket)
};
let (done, result) = tokio::sync::oneshot::channel();
slots.spawn(move || {
slots.spawn(ProxyRouting::Explicit, move || {
let _ = done.send(proxy_bidirectional(upstream, client));
});
rustix::net::sockopt::set_socket_linger(&reset_peer, Some(Duration::ZERO)).unwrap();
@@ -49,8 +50,7 @@ async fn a_reset_on_either_side_releases_the_bridge_permit() {
let _permit = tokio::time::timeout(Duration::from_secs(5), slots.0.acquire())
.await
.expect("bridge must release its permit while the other peer is still open")
.unwrap();
.expect("bridge must release its permit while the other peer is still open");
assert!(result.await.unwrap().is_err());
drop(idle_peer);
}
+43 -11
View File
@@ -48,12 +48,12 @@ pub(crate) fn apply_sandbox_policies_to_current_thread(
cwd: &Path,
apply_landlock_fs: bool,
managed_network: Option<&ManagedNetworkSandboxContext>,
proxy_routing_active: bool,
proxy_routing: Option<ProxyRouting>,
) -> Result<()> {
let network_seccomp_mode = network_seccomp_mode(
network_sandbox_policy,
managed_network.is_some(),
proxy_routing_active,
proxy_routing,
)
.or_else(|| {
// VM sockets can reach host services outside the filesystem sandbox.
@@ -100,10 +100,16 @@ pub(crate) fn apply_sandbox_policies_to_current_thread(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NetworkSeccompMode {
Restricted,
ProxyRouted,
ProxyRouted(ProxyRouting),
VmSocketRestricted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProxyRouting {
Explicit,
TransparentTcp,
}
fn should_install_network_seccomp(
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
@@ -116,12 +122,12 @@ fn should_install_network_seccomp(
fn network_seccomp_mode(
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
proxy_routed_network: bool,
proxy_routed_network: Option<ProxyRouting>,
) -> Option<NetworkSeccompMode> {
if !should_install_network_seccomp(network_sandbox_policy, allow_network_for_proxy) {
None
} else if proxy_routed_network {
Some(NetworkSeccompMode::ProxyRouted)
} else if let Some(routing) = proxy_routed_network {
Some(NetworkSeccompMode::ProxyRouted(routing))
} else {
Some(NetworkSeccompMode::Restricted)
}
@@ -267,11 +273,11 @@ fn install_network_seccomp_filter_on_current_thread(
add_syscall_rules(&mut rules, libc::SYS_socket, vec![unix_only_rule.clone()]);
add_syscall_rules(&mut rules, libc::SYS_socketpair, vec![unix_only_rule]);
}
NetworkSeccompMode::ProxyRouted => {
NetworkSeccompMode::ProxyRouted(routing) => {
// In proxy-routed mode we allow IP sockets in the isolated
// namespace (used to reach the local TCP bridge). Standalone Unix
// sockets require an explicit managed-policy grant; all other
// socket families remain denied.
// sockets require an explicit managed-policy grant. Transparent TCP
// also permits route enumeration in its private network namespace.
let mut denied_socket_conditions = vec![
SeccompCondition::new(
0,
@@ -294,14 +300,40 @@ fn install_network_seccomp_filter_on_current_thread(
libc::AF_UNIX as u64,
)?);
}
let deny_non_ip_socket = SeccompRule::new(denied_socket_conditions)?;
let mut socket_rules = Vec::new();
if routing == ProxyRouting::TransparentTcp {
// Native clients use getifaddrs to enumerate their interfaces. Route
// mutation requires CAP_NET_ADMIN in the parent user namespace that
// owns the TUN. The workload has no capabilities there.
denied_socket_conditions.push(SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_NETLINK as u64,
)?);
socket_rules.push(SeccompRule::new(vec![
SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_NETLINK as u64,
)?,
SeccompCondition::new(
2,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::NETLINK_ROUTE as u64,
)?,
])?);
}
socket_rules.push(SeccompRule::new(denied_socket_conditions)?);
let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_UNIX as u64,
)?])?;
add_syscall_rules(&mut rules, libc::SYS_socket, vec![deny_non_ip_socket]);
add_syscall_rules(&mut rules, libc::SYS_socket, socket_rules);
add_syscall_rules(
&mut rules,
libc::SYS_socketpair,
+17 -9
View File
@@ -3,6 +3,7 @@
#![cfg(test)]
use super::NetworkSeccompMode;
use super::ProxyRouting;
use super::install_network_seccomp_filter_on_current_thread;
use super::network_seccomp_mode;
use super::set_no_new_privs;
@@ -17,7 +18,8 @@ use std::collections::BTreeMap;
use test_case::test_case;
#[test_case(NetworkSeccompMode::Restricted, libc::EPERM; "restricted")]
#[test_case(NetworkSeccompMode::ProxyRouted, libc::EPERM; "proxy_routed")]
#[test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit), libc::EPERM; "explicit_proxy")]
#[test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp), libc::EPERM; "transparent_tcp")]
#[test_case(NetworkSeccompMode::VmSocketRestricted, libc::ENOSYS; "vm_socket_only")]
fn request_key_respects_network_policy(mode: NetworkSeccompMode, expected_errno: i32) {
// Filters apply only to this thread, leaving the test runner unrestricted.
@@ -103,9 +105,9 @@ fn managed_proxy_routes_use_proxy_routed_seccomp_mode() {
network_seccomp_mode(
NetworkSandboxPolicy::Enabled,
/*allow_network_for_proxy*/ true,
/*proxy_routed_network*/ true,
Some(ProxyRouting::Explicit),
),
Some(NetworkSeccompMode::ProxyRouted)
Some(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit))
);
}
@@ -115,7 +117,7 @@ fn restricted_network_without_proxy_routing_uses_restricted_mode() {
network_seccomp_mode(
NetworkSandboxPolicy::Restricted,
/*allow_network_for_proxy*/ false,
/*proxy_routed_network*/ false,
/*proxy_routed_network*/ None,
),
Some(NetworkSeccompMode::Restricted)
);
@@ -127,7 +129,7 @@ fn full_network_without_managed_proxy_skips_network_seccomp_mode() {
network_seccomp_mode(
NetworkSandboxPolicy::Enabled,
/*allow_network_for_proxy*/ false,
/*proxy_routed_network*/ false,
/*proxy_routed_network*/ None,
),
None
);
@@ -149,7 +151,8 @@ fn syscall_result(nr: i64, arg0: i64) -> std::result::Result<i64, i32> {
#[cfg(target_arch = "x86_64")]
#[test_case::test_case(NetworkSeccompMode::Restricted; "restricted")]
#[test_case::test_case(NetworkSeccompMode::ProxyRouted; "proxy_routed")]
#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit); "explicit_proxy")]
#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp); "transparent_tcp")]
#[test_case::test_case(NetworkSeccompMode::VmSocketRestricted; "vm_socket_restricted")]
fn x32_restricted_syscalls_are_denied(mode: NetworkSeccompMode) {
std::thread::spawn(move || {
@@ -207,7 +210,8 @@ fn x32_restricted_syscalls_are_denied(mode: NetworkSeccompMode) {
#[cfg(target_arch = "x86_64")]
#[test_case::test_case(NetworkSeccompMode::Restricted; "restricted")]
#[test_case::test_case(NetworkSeccompMode::ProxyRouted; "proxy_routed")]
#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::Explicit); "explicit_proxy")]
#[test_case::test_case(NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp); "transparent_tcp")]
#[test_case::test_case(NetworkSeccompMode::VmSocketRestricted; "vm_socket_restricted")]
fn x32_preserves_allowed_calls_and_socket_conditions(mode: NetworkSeccompMode) {
std::thread::spawn(move || {
@@ -215,13 +219,17 @@ fn x32_preserves_allowed_calls_and_socket_conditions(mode: NetworkSeccompMode) {
NetworkSeccompMode::Restricted | NetworkSeccompMode::VmSocketRestricted => {
libc::AF_UNIX
}
NetworkSeccompMode::ProxyRouted => libc::AF_INET,
NetworkSeccompMode::ProxyRouted(_) => libc::AF_INET,
};
let allowed = [
let mut allowed = vec![
(libc::SYS_getpid, 0),
(libc::SYS_socket, i64::from(socket_domain)),
(libc::SYS_socketpair, i64::from(libc::AF_UNIX)),
];
if mode == NetworkSeccompMode::ProxyRouted(ProxyRouting::TransparentTcp) {
// syscall_result supplies protocol zero (NETLINK_ROUTE).
allowed.push((libc::SYS_socket, i64::from(libc::AF_NETLINK)));
}
let probes = allowed
.into_iter()
.flat_map(|(nr, arg0)| [(nr, arg0), (nr | 0x4000_0000, arg0)])
+133
View File
@@ -0,0 +1,133 @@
//! Create a private user and network namespace before invoking stock bubblewrap.
//! Only the trusted launcher configures the TUN and the resolver sockets.
use rustix::thread::CapabilitySet;
use rustix::thread::CapabilitySets;
use rustix::thread::UnshareFlags;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
use std::io;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use std::net::TcpListener;
use std::net::UdpSocket;
use std::os::fd::AsRawFd;
use std::os::fd::OwnedFd;
use std::os::fd::RawFd;
use std::os::unix::fs::OpenOptionsExt;
// linux/ipv6_route.h. libc's in6_rtmsg fields are private.
#[repr(C)]
struct Ipv6Route {
dst: libc::in6_addr,
src: libc::in6_addr,
gateway: libc::in6_addr,
kind: u32,
dst_len: u16,
src_len: u16,
metric: u32,
info: libc::c_ulong,
flags: u32,
ifindex: libc::c_int,
}
const _: () = assert!(std::mem::size_of::<Ipv6Route>() == std::mem::size_of::<libc::in6_rtmsg>());
pub(super) fn create() -> io::Result<[File; 3]> {
let uid = rustix::process::geteuid().as_raw();
let gid = rustix::process::getegid().as_raw();
// SAFETY: this launcher is single-threaded and keeps its file descriptor table.
unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWUSER | UnshareFlags::NEWNET)? };
fs::write("/proc/self/uid_map", format!("{uid} {uid} 1\n"))?;
fs::write("/proc/self/setgroups", "deny")?;
fs::write("/proc/self/gid_map", format!("{gid} {gid} 1\n"))?;
let tun = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOFOLLOW)
.open("/dev/net/tun")?;
crate::proxy_routing::ensure_loopback_interface_up()?;
// SAFETY: these Linux ioctl records consist of integers, byte arrays and
// nullable pointers. Start with zeroed padding and set the active union
// member to the type required by each specific ioctl before invoking it.
unsafe {
let name = c"codextun";
let mut iface: libc::ifreq = std::mem::zeroed();
std::ptr::copy_nonoverlapping(
name.as_ptr(),
iface.ifr_name.as_mut_ptr(),
name.to_bytes_with_nul().len(),
);
iface.ifr_ifru.ifru_flags = (libc::IFF_TUN | libc::IFF_NO_PI) as i16;
ioctl(tun.as_raw_fd(), libc::TUNSETIFF, &mut iface)?;
let v4 = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
let v6 = UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0))?;
ioctl(v4.as_raw_fd(), libc::SIOCGIFFLAGS as _, &mut iface)?;
iface.ifr_ifru.ifru_flags |= libc::IFF_UP as i16;
ioctl(v4.as_raw_fd(), libc::SIOCSIFFLAGS as _, &mut iface)?;
let index = libc::if_nametoindex(name.as_ptr()) as i32;
if index == 0 {
return Err(io::Error::last_os_error());
}
let mut addr: libc::sockaddr_in = std::mem::zeroed();
addr.sin_family = libc::AF_INET as _;
addr.sin_addr.s_addr = u32::from_ne_bytes([192, 0, 2, 1]);
iface.ifr_ifru.ifru_addr = std::mem::transmute::<libc::sockaddr_in, libc::sockaddr>(addr);
ioctl(v4.as_raw_fd(), libc::SIOCSIFADDR as _, &mut iface)?;
addr.sin_addr.s_addr = u32::MAX;
iface.ifr_ifru.ifru_netmask =
std::mem::transmute::<libc::sockaddr_in, libc::sockaddr>(addr);
ioctl(v4.as_raw_fd(), libc::SIOCSIFNETMASK as _, &mut iface)?;
let mut addr6: libc::in6_ifreq = std::mem::zeroed();
addr6.ifr6_addr.s6_addr = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1).octets();
addr6.ifr6_prefixlen = 128;
addr6.ifr6_ifindex = index;
ioctl(v6.as_raw_fd(), libc::SIOCSIFADDR as _, &mut addr6)?;
let mut route: libc::rtentry = std::mem::zeroed();
route.rt_dst.sa_family = libc::AF_INET as _;
route.rt_genmask.sa_family = libc::AF_INET as _;
route.rt_gateway.sa_family = libc::AF_INET as _;
route.rt_flags = libc::RTF_UP;
route.rt_dev = iface.ifr_name.as_mut_ptr();
ioctl(v4.as_raw_fd(), libc::SIOCADDRT as _, &mut route)?;
let mut route6: Ipv6Route = std::mem::zeroed();
route6.ifindex = index;
route6.flags = u32::from(libc::RTF_UP);
route6.metric = 1;
ioctl(v6.as_raw_fd(), libc::SIOCADDRT as _, &mut route6)?;
}
let dns = (Ipv4Addr::new(127, 0, 0, 53), 53);
let udp = UdpSocket::bind(dns)?;
let tcp = TcpListener::bind(dns)?;
// Linux 5.12+ requires SETFCAP to map parent UID 0 into bwrap's nested
// namespace. The inner stage verifies that bwrap dropped this final capability.
let keep = if uid == 0 {
CapabilitySet::SETFCAP
} else {
CapabilitySet::empty()
};
rustix::thread::set_no_new_privs(true)?;
rustix::thread::set_capabilities(
None,
CapabilitySets {
effective: keep,
permitted: keep,
inheritable: CapabilitySet::empty(),
},
)?;
let [tun, udp, tcp] = [OwnedFd::from(tun), udp.into(), tcp.into()];
Ok([
File::from(crate::proxy_lifecycle::move_fd_above_stdio(tun)?),
File::from(crate::proxy_lifecycle::move_fd_above_stdio(udp)?),
File::from(crate::proxy_lifecycle::move_fd_above_stdio(tcp)?),
])
}
// SAFETY: the caller pairs each request number with its exact Linux ABI record.
unsafe fn ioctl<T>(fd: RawFd, request: libc::Ioctl, record: &mut T) -> io::Result<()> {
if unsafe { libc::ioctl(fd, request, record) } < 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Pass the private network descriptors and mount the command-local resolver. The inner stage
//! takes ownership of all three received descriptors before any workload exec.
use crate::bwrap::BwrapArgs;
use crate::proxy_lifecycle::move_fd_above_stdio;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use std::fs::File;
use std::io;
use std::io::Seek;
use std::io::Write;
use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd;
use std::os::fd::OwnedFd;
use std::path::Path;
pub(super) fn prepare(
files: [File; 3],
bwrap: &mut BwrapArgs,
policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> io::Result<()> {
let path = Path::new("/etc/resolv.conf");
// Bind onto the existing target so ordinary systemd-resolved symlinks work.
// Reject policies masking either the logical name or the resolved location.
let target = path.canonicalize()?;
if !resolver_readable(policy, cwd, path, &target)? || !target.is_file() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"transparent TCP requires readable /etc/resolv.conf",
));
}
let fd = rustix::fs::memfd_create("codex-resolver", rustix::fs::MemfdFlags::CLOEXEC)?;
let mut resolv = File::from(move_fd_above_stdio(fd)?);
resolv.write_all(b"nameserver 127.0.0.53\n")?;
resolv.rewind()?;
let numbers = files
.each_ref()
.map(|f| f.as_raw_fd().to_string())
.join(",");
let separator = bwrap
.args
.iter()
.position(|arg| arg == "--")
.ok_or_else(|| io::Error::other("missing inner command"))?;
bwrap.args.splice(
separator + 2..separator + 2,
[
"--transparent-tcp".into(),
"--transparent-tcp-fd".into(),
numbers,
],
);
bwrap.args.splice(
separator..separator,
[
"--perms".into(),
"0444".into(),
"--ro-bind-data".into(),
resolv.as_raw_fd().to_string(),
target
.to_str()
.ok_or_else(|| io::Error::other("non-UTF-8 resolver path"))?
.into(),
],
);
bwrap.preserved_files.push(resolv);
bwrap.preserved_files.extend(files);
Ok(())
}
fn resolver_readable(
policy: &FileSystemSandboxPolicy,
cwd: &Path,
logical: &Path,
target: &Path,
) -> io::Result<bool> {
let denied = codex_protocol::permissions::ReadDenyMatcher::try_new_for_local_paths(policy, cwd)
.map_err(io::Error::other)?;
Ok([logical, target].into_iter().all(|path| {
!denied
.as_ref()
.is_some_and(|matcher| matcher.is_local_path_read_denied(path))
&& (policy.can_read_local_path_with_cwd(path, cwd)
|| (policy.include_platform_defaults()
&& crate::bwrap::LINUX_PLATFORM_DEFAULT_READ_ROOTS
.iter()
.any(|root| path.starts_with(root))))
}))
}
pub(super) fn receive(fds: Vec<std::os::fd::RawFd>) -> io::Result<[OwnedFd; 3]> {
let invalid = || io::Error::other("invalid transparent TCP descriptors");
let fds: [i32; 3] = fds.try_into().map_err(|_| invalid())?;
for (i, &fd) in fds.iter().enumerate() {
if fd <= 2 || fds[..i].contains(&fd) {
return Err(invalid());
}
if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
return Err(io::Error::last_os_error());
}
}
// SAFETY: the launcher handed off three distinct, live descriptors above stdio.
Ok(fds.map(|fd| unsafe { OwnedFd::from_raw_fd(fd) }))
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSpecialPath;
#[test]
fn minimal_resolver_mount_respects_explicit_denials_and_symlink_targets() {
let mut policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
FileSystemAccessMode::Read,
)]);
let path = Path::new("/etc/resolv.conf");
let cwd = Path::new("/");
assert!(resolver_readable(&policy, cwd, path, path).unwrap());
assert!(!resolver_readable(&policy, cwd, path, Path::new("/private/resolv.conf")).unwrap());
policy.entries.push(FileSystemSandboxEntry::new(
codex_utils_absolute_path::AbsolutePathBuf::try_from(path)
.unwrap()
.into(),
FileSystemAccessMode::Deny,
));
assert!(!resolver_readable(&policy, cwd, path, path).unwrap());
}
}
@@ -37,6 +37,8 @@ use tempfile::NamedTempFile;
use tokio::process::Command;
use url::Url;
#[path = "managed_tcp.rs"]
mod managed_tcp;
#[path = "managed_proxy_unix_sockets_tests.rs"]
mod unix_sockets;
@@ -89,7 +91,10 @@ fn is_bwrap_unavailable_output(output: &Output) -> bool {
}
async fn should_skip_bwrap_tests() -> bool {
let mut env = create_env_from_core_vars();
should_skip_bwrap_tests_with_env(create_env_from_core_vars()).await
}
async fn should_skip_bwrap_tests_with_env(mut env: HashMap<String, String>) -> bool {
strip_proxy_env(&mut env);
let output = run_linux_sandbox_direct(
@@ -112,11 +117,14 @@ fn is_managed_proxy_permission_error(stderr: &str) -> bool {
}
async fn managed_proxy_skip_reason() -> Option<String> {
if should_skip_bwrap_tests().await {
managed_proxy_skip_reason_with_env(create_env_from_core_vars()).await
}
async fn managed_proxy_skip_reason_with_env(mut env: HashMap<String, String>) -> Option<String> {
if should_skip_bwrap_tests_with_env(env.clone()).await {
return Some("bubblewrap is unavailable 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());
@@ -163,6 +171,22 @@ fn linux_sandbox_command(
permission_profile: &PermissionProfile,
allow_network_for_proxy: bool,
env: HashMap<String, String>,
) -> Command {
linux_sandbox_command_with_options(
command,
permission_profile,
allow_network_for_proxy.then_some("{}"),
env,
&[],
)
}
fn linux_sandbox_command_with_options(
command: &[&str],
permission_profile: &PermissionProfile,
managed_network: Option<&str>,
env: HashMap<String, String>,
options: &[&str],
) -> Command {
let cwd = std::env::current_dir().expect("current directory should exist");
let permission_profile_json =
@@ -174,10 +198,11 @@ fn linux_sandbox_command(
"--permission-profile".to_string(),
permission_profile_json,
];
if allow_network_for_proxy {
if let Some(context) = managed_network {
args.push("--managed-network".to_string());
args.push("{}".to_string());
args.push(context.to_string());
}
args.extend(options.iter().map(|arg| (*arg).to_string()));
args.push("--".to_string());
args.extend(command.iter().map(|entry| (*entry).to_string()));
@@ -0,0 +1,213 @@
//! Exercise native name resolution and ordinary TCP through the two-stage Linux launcher.
use super::*;
use codex_utils_cargo_bin::runfiles;
use pretty_assertions::assert_eq;
use socks5_protocol::CommandRequest;
use socks5_protocol::sync::FromIO;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
#[tokio::test]
async fn transparent_tcp_rejects_incompatible_options_and_untrusted_proxy_ports() {
let incompatible =
"transparent TCP requires managed networking and private bubblewrap namespaces";
let untrusted = "outside the managed sandbox context";
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
env.insert("ALL_PROXY".into(), "socks5h://127.0.0.1:32101".into());
for (context, extra, reason) in [
(None, None, incompatible),
(Some("{}"), Some("--use-legacy-landlock"), incompatible),
(Some("{}"), Some("--inherit-pid-namespace"), incompatible),
(Some("{}"), Some("--no-proc"), incompatible),
(Some("{}"), None, untrusted),
(Some(r#"{"loopbackPorts":[32102]}"#), None, untrusted),
] {
let mut options = vec!["--transparent-tcp"];
options.extend(extra);
let output = linux_sandbox_command_with_options(
&["true"],
&PermissionProfile::read_only(),
context,
env.clone(),
&options,
)
.output()
.await
.unwrap();
assert!(!output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr).contains(reason),
"{output:?}"
);
}
}
#[tokio::test]
async fn managed_tcp_native_client_uses_attributed_socks_and_preserves_restrictions() {
// Container device policies can expose the node but deny opening it.
if let Err(err) = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/net/tun")
{
if matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied
) || err.raw_os_error() == Some(libc::ENODEV)
{
eprintln!("skipping transparent TCP integration test: /dev/net/tun: {err}");
return;
}
panic!("failed to open /dev/net/tun: {err}");
}
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
// Keep distro bwrap private to this child. Other tests exercise bundled discovery.
// Stage outside the runfiles cwd, which the production launcher rejects for executables.
let _bwrap_dir = if cfg!(target_arch = "x86_64") && option_env!("BAZEL_PACKAGE").is_some() {
let bin = tempfile::tempdir().unwrap();
let files = runfiles::Runfiles::create().expect("managed TCP test runfiles");
let bwrap = runfiles::rlocation!(
files,
"managed_tcp_test_bubblewrap_noble_amd64/usr/bin/bwrap"
)
.expect("Ubuntu bubblewrap test fixture");
codex_utils_cargo_bin::copy_executable(&bwrap, &bin.path().join("bwrap")).unwrap();
let path = env.get("PATH").map(String::as_str).unwrap_or_default();
env.insert("PATH".into(), format!("{path}:{}", bin.path().display()));
Some(bin)
} else {
None
};
if let Some(reason) = managed_proxy_skip_reason_with_env(env.clone()).await {
eprintln!("skipping managed TCP sandbox test: {reason}");
return;
}
let proxy = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.unwrap();
let proxy_addr = proxy.local_addr().unwrap();
let host_only = TcpListener::bind((Ipv4Addr::new(127, 0, 0, 2), 0)).unwrap();
let host_port = host_only.local_addr().unwrap().port().to_string();
let read_only = NamedTempFile::new_in(std::env::current_dir().unwrap()).unwrap();
env.insert("ALL_PROXY".into(), format!("socks5h://{proxy_addr}"));
env.insert(
PROXY_ATTRIBUTION_TOKEN_ENV_KEY.into(),
"native-client".into(),
);
let serve = |mut stdin: tokio::process::ChildStdin| async move {
let mut kept = None;
for (host, allow) in [
("pg.example:5432", true),
("pg.example:5432", true),
("[2001:db8::93]:5432", true),
("v6.example:5432", true),
("blocked.example:5432", false),
] {
let (mut stream, _) = proxy.accept().await.unwrap();
let mut preface = Vec::new();
write_attribution_frame(&mut preface, "native-client").unwrap();
preface.extend_from_slice(&[5, 1, 0]);
let mut buf = vec![0; preface.len()];
stream.read_exact(&mut buf).await.unwrap();
assert_eq!(buf, preface);
stream.write_all(&[5, 0]).await.unwrap();
let mut expected = Vec::new();
CommandRequest::connect(host.parse().unwrap())
.write_to(&mut expected)
.unwrap();
let mut request = vec![0; expected.len()];
stream.read_exact(&mut request).await.unwrap();
assert_eq!(request, expected);
if host == "pg.example:5432" {
let Some(first) = kept.take() else {
stdin.write_all(b"open\n").await.unwrap();
kept = Some(stream);
continue;
};
stdin.write_all(b"close\n").await.unwrap();
assert_eq!(stream.read(&mut [0]).await.unwrap(), 0);
stream = first;
}
let status = if allow { 0 } else { 2 };
stream
.write_all(&[5, status, 0, 1, 0, 0, 0, 0, 0, 0])
.await
.unwrap();
if allow {
let mut payload = [0; 4];
stream.read_exact(&mut payload).await.unwrap();
assert_eq!(&payload, b"ping");
stream.write_all(b"pong\n").await.unwrap();
}
}
};
let script = r#"
unset ALL_PROXY all_proxy
if command -v ip >/dev/null; then
ip link show codextun >/dev/null
if ip link set codextun down 2>/dev/null; then exit 15; fi
fi
exec 3<>/dev/tcp/pg.example/5432
printf ping >&3
read -r open
exec 4<>/dev/tcp/pg.example/5432
printf queued >&4
read -r release
exec 4>&-
IFS= read -r reply <&3 && test "$reply" = pong || exit 11
exec 3>&-
exec 3<>/dev/tcp/2001:db8::93/5432
printf ping >&3
IFS= read -r reply <&3 && test "$reply" = pong || exit 11
exec 3>&-
# Force an AAAA lookup so IPv4 fallback cannot satisfy the hostname check.
python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as client:
client.connect(("v6.example", 5432))
client.sendall(b"ping")
assert client.makefile("rb").readline() == b"pong\n"
PY
if { exec 4<>/dev/tcp/blocked.example/5432; } 2>/dev/null; then
if IFS= read -r -t 2 reply <&4; then exit 12; fi
fi
if (exec 5<>/dev/tcp/127.0.0.2/"$1") 2>/dev/null; then exit 13; fi
if (exec 5>>"$2") 2>/dev/null; then exit 14; fi
"#;
let mut command = linux_sandbox_command_with_options(
&[
"bash",
"-ec",
script,
"probe",
&host_port,
read_only.path().to_str().unwrap(),
],
&PermissionProfile::read_only(),
Some(&format!(r#"{{"loopbackPorts":[{}]}}"#, proxy_addr.port())),
env,
&["--transparent-tcp"],
);
let mut child = command
.stdin(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let peer = tokio::spawn(serve(child.stdin.take().unwrap()));
let output = tokio::time::timeout(SANDBOX_TIMEOUT, child.wait_with_output())
.await
.unwrap()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
tokio::time::timeout(OPERATION_TIMEOUT, peer)
.await
.unwrap()
.unwrap();
}
+8 -1
View File
@@ -61,7 +61,10 @@ fn policy_ctx(
#[tokio::test]
async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() {
let app_state = Arc::new(network_proxy_state_for_policy({
let mut network = NetworkProxyConfig::default();
let mut network = NetworkProxyConfig {
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
network.set_allowed_domains(vec!["example.com".to_string()]);
network
}));
@@ -211,6 +214,7 @@ async fn mitm_policy_allows_matching_hooked_write_in_full_mode() {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Full,
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
@@ -392,6 +396,7 @@ async fn mitm_policy_blocks_encoded_path_traversal_for_repository_allowlist() {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Full,
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
network.set_allowed_domains(vec!["github.com".to_string()]);
@@ -460,6 +465,7 @@ async fn mitm_policy_blocks_matching_hooked_write_in_limited_mode() {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Limited,
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
@@ -509,6 +515,7 @@ async fn mitm_policy_records_hook_denials_in_full_mode() {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Full,
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
+8
View File
@@ -2528,6 +2528,14 @@ mod tests {
#[tokio::test]
async fn remote_launch_config_carries_execution_scope() -> Result<()> {
let dir = tempfile::tempdir()?;
let ca = crate::certs::ManagedMitmCa::for_test(dir.path())?;
crate::certs::TEST_MITM_CA
.scope(ca, check_remote_launch_config_execution_scope())
.await
}
async fn check_remote_launch_config_execution_scope() -> Result<()> {
#[cfg(target_os = "windows")]
let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap();
let mut config = NetworkProxyConfig {
+5
View File
@@ -1008,6 +1008,7 @@ mod tests {
enabled: true,
mode: NetworkMode::Limited,
mitm: true,
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
settings.set_allowed_domains(vec!["example.com".to_string()]);
@@ -1076,6 +1077,7 @@ mod tests {
mode: NetworkMode::Full,
mitm: true,
credential_broker: true,
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
settings.set_allowed_domains(vec!["api.openai.com".to_string()]);
@@ -1104,6 +1106,7 @@ mod tests {
let mut settings = NetworkProxyConfig {
enabled: true,
mode: NetworkMode::Limited,
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
settings.set_allowed_domains(vec!["example.com".to_string()]);
@@ -1139,6 +1142,7 @@ mod tests {
},
..MitmHookConfig::default()
}],
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
settings.set_allowed_domains(vec!["api.github.com".to_string()]);
@@ -1171,6 +1175,7 @@ mod tests {
},
..MitmHookConfig::default()
}],
allow_local_binding: Some(true),
..NetworkProxyConfig::default()
};
settings.set_allowed_domains(vec!["api.github.com".to_string()]);
+10
View File
@@ -280,6 +280,7 @@ impl std::error::Error for SandboxTransformError {
#[derive(Clone, Default)]
pub struct SandboxManager {
linux_sandbox_pid_namespace: LinuxSandboxPidNamespace,
linux_sandbox_transparent_tcp: bool,
operation: SandboxOperation,
#[cfg(target_os = "macos")]
allowed_symlinked_codex_home: Option<AbsolutePathBuf>,
@@ -318,6 +319,12 @@ impl SandboxManager {
self
}
/// Route managed Linux command traffic through the transparent TCP adapter.
pub fn with_linux_sandbox_transparent_tcp(mut self, enabled: bool) -> Self {
self.linux_sandbox_transparent_tcp = enabled;
self
}
/// Allows otherwise-authorized writable roots beneath the opted-in user home
/// to follow symlinks, including targets outside that home.
#[cfg(target_os = "macos")]
@@ -663,6 +670,9 @@ impl SandboxManager {
if self.linux_sandbox_pid_namespace == LinuxSandboxPidNamespace::Inherit {
args.insert(0, "--inherit-pid-namespace".to_owned());
}
if self.linux_sandbox_transparent_tcp && managed_network.is_some() {
args.insert(0, "--transparent-tcp".to_owned());
}
exec.command = std::iter::once(program).chain(args).collect();
// Without bwrap --argv0, the Linux helper uses its own argv[0] as the inner executable.
let arg0 = if let Some(path) = exe.to_str()
+17 -5
View File
@@ -593,7 +593,15 @@ fn inherited_terminal_preserves_native_linux_helper_and_uses_its_dispatch_name()
#[cfg(unix)]
#[tokio::test]
async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Result<()> {
async fn linux_helper_uses_effective_managed_policy() -> anyhow::Result<()> {
for transparent_tcp in [false, true] {
check_linux_managed_policy(transparent_tcp).await?;
}
Ok(())
}
#[cfg(unix)]
async fn check_linux_managed_policy(transparent_tcp: bool) -> anyhow::Result<()> {
use codex_network_proxy::ConfigReloader;
use codex_network_proxy::ConfigReloaderFuture;
use codex_network_proxy::ConfigState;
@@ -647,7 +655,7 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu
let denied = ManagedNetworkSandboxContext::default();
let cwd = AbsolutePathBuf::current_dir()?;
let cwd_uri = PathUri::from_abs_path(&cwd);
let manager = SandboxManager::new();
let manager = SandboxManager::new().with_linux_sandbox_transparent_tcp(transparent_tcp);
for (label, context, live_proxy, enforce_managed_network, expected) in [
(
"missing policy stays managed with restrictive defaults",
@@ -707,9 +715,13 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})?;
let transported_context = prepared
.into_command_request()
.command
let command = prepared.into_command_request().command;
assert_eq!(
command.iter().any(|arg| arg == "--transparent-tcp"),
transparent_tcp && expected.is_some(),
"{label}"
);
let transported_context = command
.windows(2)
.find(|args| args[0] == "--managed-network")
.map(|args| serde_json::from_str::<ManagedNetworkSandboxContext>(&args[1]))