diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 2f89781ba1..5dba5c34ba 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1478,6 +1478,7 @@ "hermit-abi_0.5.2": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"rustc-dep-of-std\":[\"core\",\"alloc\"]}}", "hex_0.4.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "hickory-proto_0.25.2": "{\"dependencies\":[{\"name\":\"async-trait\",\"req\":\"^0.1.43\"},{\"default_features\":false,\"features\":[\"prebuilt-nasm\"],\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.12.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.50\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"data-encoding\",\"req\":\"^2.2.0\"},{\"name\":\"enum-as-inner\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-channel\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3.5\"},{\"features\":[\"stream\"],\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.7\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.9\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"alloc\",\"compiled_data\"],\"name\":\"idna\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"ipnet\",\"req\":\"^2.3.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.44\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"log\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"alloc\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"std\"],\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"features\":[\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.23\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.10\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1.1\"},{\"features\":[\"io-util\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"rt\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"default_features\":false,\"features\":[\"early-data\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"wasm-bindgen-crate\",\"optional\":true,\"package\":\"wasm-bindgen\",\"req\":\"^0.2.58\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__dnssec\":[\"dep:bitflags\",\"dep:rustls-pki-types\",\"dep:time\",\"std\"],\"__h3\":[\"dep:h3\",\"dep:h3-quinn\",\"dep:http\",\"std\"],\"__https\":[\"dep:bytes\",\"dep:h2\",\"dep:http\",\"std\"],\"__quic\":[\"dep:bytes\",\"dep:pin-project-lite\",\"dep:quinn\",\"std\"],\"__tls\":[\"dep:bytes\",\"dep:rustls\",\"dep:tokio-rustls\",\"std\",\"tokio\"],\"backtrace\":[\"dep:backtrace\",\"std\"],\"default\":[\"std\",\"tokio\"],\"dnssec-aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/ring-io\",\"__dnssec\"],\"dnssec-ring\":[\"dep:ring\",\"__dnssec\"],\"h3-aws-lc-rs\":[\"quic-aws-lc-rs\",\"__h3\"],\"h3-ring\":[\"quic-ring\",\"__h3\"],\"https-aws-lc-rs\":[\"tls-aws-lc-rs\",\"__https\"],\"https-ring\":[\"tls-ring\",\"__https\"],\"mdns\":[\"socket2/all\",\"std\"],\"no-std-rand\":[\"once_cell/critical-section\",\"dep:critical-section\"],\"quic-aws-lc-rs\":[\"quinn/rustls-aws-lc-rs\",\"tls-aws-lc-rs\",\"__quic\"],\"quic-ring\":[\"quinn/rustls-ring\",\"tls-ring\",\"__quic\"],\"rustls-platform-verifier\":[\"dep:rustls-platform-verifier\",\"std\"],\"serde\":[\"dep:serde\",\"std\",\"url/serde\"],\"std\":[\"data-encoding/std\",\"futures-channel/std\",\"futures-io/std\",\"futures-util/std\",\"ipnet/std\",\"rand/std\",\"rand/thread_rng\",\"ring?/std\",\"thiserror/std\",\"tracing-subscriber/env-filter\",\"tracing-subscriber/fmt\",\"tracing-subscriber/std\",\"tracing/std\",\"url/std\"],\"testing\":[\"std\"],\"text-parsing\":[\"std\"],\"tls-aws-lc-rs\":[\"tokio-rustls/aws-lc-rs\",\"__tls\"],\"tls-ring\":[\"tokio-rustls/ring\",\"__tls\"],\"tokio\":[\"dep:tokio\",\"std\",\"tokio/net\",\"tokio/rt\",\"tokio/time\",\"tokio/rt-multi-thread\"],\"wasm-bindgen\":[\"dep:wasm-bindgen-crate\",\"dep:js-sys\"]}}", + "hickory-proto_0.26.3": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"prebuilt-nasm\"],\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.12.3\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"data-encoding\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"compiled_data\"],\"name\":\"idna\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"ipnet\",\"req\":\"^2.11.0\"},{\"name\":\"jni\",\"req\":\"^0.22.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.44\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"prefix-trie\",\"optional\":true,\"req\":\"^0.8.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.10.0\"},{\"features\":[\"std\"],\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.10\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1.1\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"wasm-bindgen-crate\",\"optional\":true,\"package\":\"wasm-bindgen\",\"req\":\"^0.2.107\"}],\"features\":{\"__dnssec\":[\"dep:bitflags\",\"dep:rustls-pki-types\",\"dep:time\",\"std\"],\"access-control\":[\"std\",\"dep:prefix-trie\"],\"default\":[\"std\"],\"dnssec-aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/ring-io\",\"__dnssec\"],\"dnssec-ring\":[\"dep:ring\",\"__dnssec\"],\"mdns\":[],\"no-std-rand\":[\"once_cell/critical-section\",\"dep:critical-section\"],\"serde\":[\"dep:serde\",\"std\",\"url/serde\"],\"std\":[\"data-encoding/std\",\"ipnet/std\",\"rand/std\",\"rand/thread_rng\",\"ring?/std\",\"thiserror/std\",\"tracing/std\",\"url/std\"],\"testing\":[\"std\"],\"wasm-bindgen\":[\"dep:wasm-bindgen-crate\",\"dep:js-sys\"]}}", "hickory-resolver_0.25.2": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.50\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"hickory-proto\",\"req\":\"^0.25\"},{\"name\":\"ipconfig\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(windows)\"},{\"features\":[\"sync\"],\"name\":\"moka\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"log\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"system\"],\"name\":\"resolv-conf\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.23\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smallvec\",\"req\":\"^1.6\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.14\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"std\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__dnssec\":[],\"__h3\":[\"__quic\"],\"__https\":[\"__tls\"],\"__quic\":[\"dep:quinn\",\"__tls\"],\"__tls\":[\"dep:rustls\",\"dep:tokio-rustls\",\"tokio\"],\"backtrace\":[\"dep:backtrace\",\"hickory-proto/backtrace\"],\"default\":[\"system-config\",\"tokio\"],\"dnssec-aws-lc-rs\":[\"hickory-proto/dnssec-aws-lc-rs\",\"__dnssec\"],\"dnssec-ring\":[\"hickory-proto/dnssec-ring\",\"__dnssec\"],\"h3-aws-lc-rs\":[\"hickory-proto/h3-aws-lc-rs\",\"__h3\"],\"h3-ring\":[\"hickory-proto/h3-ring\",\"__h3\"],\"https-aws-lc-rs\":[\"hickory-proto/https-aws-lc-rs\",\"__https\"],\"https-ring\":[\"hickory-proto/https-ring\",\"__https\"],\"quic-aws-lc-rs\":[\"hickory-proto/quic-aws-lc-rs\",\"__quic\",\"quinn/rustls-aws-lc-rs\"],\"quic-ring\":[\"hickory-proto/quic-ring\",\"__quic\",\"quinn/rustls-ring\"],\"rustls-platform-verifier\":[\"hickory-proto/rustls-platform-verifier\"],\"serde\":[\"dep:serde\",\"hickory-proto/serde\"],\"system-config\":[\"dep:ipconfig\",\"dep:resolv-conf\"],\"tls-aws-lc-rs\":[\"hickory-proto/tls-aws-lc-rs\",\"__tls\"],\"tls-ring\":[\"hickory-proto/tls-ring\",\"__tls\"],\"tokio\":[\"dep:tokio\",\"tokio/rt\",\"hickory-proto/tokio\"],\"webpki-roots\":[\"dep:webpki-roots\",\"hickory-proto/webpki-roots\"]}}", "hkdf_0.12.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"std\":[\"hmac/std\"]}}", "hkdf_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hmac\",\"req\":\"^0.13\"},{\"name\":\"kdf\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"}],\"features\":{}}", @@ -2039,6 +2040,7 @@ "smol_str_0.3.5": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.4.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"borsh?/std\"]}}", "socket2_0.5.10": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", "socket2_0.6.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.172\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", + "socks5-protocol_0.3.5": "{\"dependencies\":[{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"sync\"],\"doc\":[\"tokio/rt-multi-thread\",\"tokio/macros\"],\"sync\":[]}}", "sonora-aec3_0.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"name\":\"sonora-common-audio\",\"req\":\"^0.2.0\"},{\"name\":\"sonora-fft\",\"req\":\"^0.2.0\"},{\"name\":\"sonora-simd\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.4\"}],\"features\":{}}", "sonora-agc2_0.2.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"bytemuck\",\"req\":\"^1\"},{\"features\":[\"debug\"],\"name\":\"derive_more\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"name\":\"sonora-common-audio\",\"req\":\"^0.2.0\"},{\"name\":\"sonora-fft\",\"req\":\"^0.2.0\"},{\"name\":\"sonora-simd\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{}}", "sonora-common-audio_0.2.0": "{\"dependencies\":[{\"features\":[\"debug\"],\"name\":\"derive_more\",\"req\":\"^2\"},{\"name\":\"sonora-simd\",\"req\":\"^0.2.0\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 481a421dee..cd86ddb88f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4033,7 +4033,9 @@ dependencies = [ "codex-uds", "codex-utils-absolute-path", "codex-utils-cargo-bin", + "hickory-proto 0.26.3", "ignore", + "indexmap 2.14.0", "landlock", "libc", "pretty_assertions", @@ -4042,6 +4044,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "socks5-protocol", "tempfile", "test-case", "tokio", @@ -9087,6 +9090,25 @@ dependencies = [ "url", ] +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "hickory-resolver" version = "0.25.2" @@ -9095,7 +9117,7 @@ checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", - "hickory-proto", + "hickory-proto 0.25.2", "ipconfig", "moka", "once_cell", @@ -14722,6 +14744,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks5-protocol" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b4925e3858bd0a1ac69668653234b4bebb0d4cd32a111c31057837d3d99117" +dependencies = [ + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "sonora" version = "0.2.0" diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 75653c00d4..b3b20cc7c1 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -26,6 +26,7 @@ codex-sandboxing = { workspace = true } codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } ignore = { workspace = true } +indexmap = { workspace = true } landlock = { workspace = true } libc = { workspace = true } rustix = { workspace = true } @@ -33,12 +34,15 @@ seccompiler = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } +tokio = { workspace = true, 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 = [ diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 246044e6d0..b1ad51a378 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -19,6 +19,10 @@ mod fd_mount; mod launcher; #[cfg(target_os = "linux")] mod linux_run_main; +#[cfg(all(target_os = "linux", test))] +mod managed_tcp; +#[cfg(all(target_os = "linux", test))] +mod managed_tcp_dns; #[cfg(target_os = "linux")] mod proxy_lifecycle; #[cfg(target_os = "linux")] diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 2a1982d6bc..feae1d36d0 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -266,8 +266,11 @@ pub fn run_main() -> ! { // Outer stage: bubblewrap first, then re-enter this binary in the // sandboxed environment to apply seccomp. This path never falls back // to legacy Landlock on failure. - let (proxy_route_spec, proxy_controls) = if allow_network_for_proxy { - let (proxy_route_spec, controls) = prepare_host_proxy_route_spec() + 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) .unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}")); (Some(proxy_route_spec), controls) } else { diff --git a/codex-rs/linux-sandbox/src/managed_tcp.rs b/codex-rs/linux-sandbox/src/managed_tcp.rs new file mode 100644 index 0000000000..ddb79fba20 --- /dev/null +++ b/codex-rs/linux-sandbox/src/managed_tcp.rs @@ -0,0 +1,107 @@ +//! Translate a TUN worker’s TCP destination to the original hostname or literal IP +//! for the managed SOCKS5 bridge. + +use crate::managed_tcp_dns::Names; +use crate::proxy_routing::proxy_bidirectional; +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; +use socks5_protocol::Version; +use socks5_protocol::sync::FromIO; +use std::io; +use std::io::Write; +use std::net::SocketAddr; +use std::net::TcpStream; +use std::sync::Mutex; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(10); + +fn relay( + mut client: TcpStream, + proxy: SocketAddr, + names: &Mutex, +) -> 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)?; + + 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); + } + }; + 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)?) +} + +fn connect( + client: &mut impl io::Read, + proxy: SocketAddr, + names: &Mutex, +) -> socks5_protocol::Result { + 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"))? + .lookup(target.ip())? + { + Some(host) => Address::Domain(host, target.port()), + None => Address::SocketAddr(target), + }; + + let mut upstream = TcpStream::connect_timeout(&proxy, TIMEOUT)?; + upstream.set_read_timeout(Some(TIMEOUT))?; + upstream.set_write_timeout(Some(TIMEOUT))?; + Version::V5.write_to(&mut upstream)?; + AuthRequest::new([AuthMethod::Noauth]).write_to(&mut upstream)?; + Version::read_from(&mut upstream)?; + if AuthResponse::read_from(&mut upstream)?.method() != AuthMethod::Noauth { + return Err(io::Error::other("managed SOCKS5 handshake failed").into()); + } + 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)?; + 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()); + } + 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. +fn write_frame(stream: &mut TcpStream, message: &impl FromIO) -> socks5_protocol::Result<()> { + let mut bytes = Vec::new(); + message.write_to(&mut bytes)?; + Ok(stream.write_all(&bytes)?) +} + +#[cfg(test)] +#[path = "managed_tcp_relay_tests.rs"] +mod tests; diff --git a/codex-rs/linux-sandbox/src/managed_tcp_dns.rs b/codex-rs/linux-sandbox/src/managed_tcp_dns.rs new file mode 100644 index 0000000000..64c6aaa318 --- /dev/null +++ b/codex-rs/linux-sandbox/src/managed_tcp_dns.rs @@ -0,0 +1,117 @@ +//! Assign stable per-command addresses to names. Addresses never change owners +//! while the command runs, including after the client's DNS cache expires. + +use hickory_proto::op::Header; +use hickory_proto::op::Message; +use hickory_proto::op::MessageType; +use hickory_proto::op::OpCode; +use hickory_proto::op::Query; +use hickory_proto::op::ResponseCode; +use hickory_proto::rr::DNSClass; +use hickory_proto::rr::RData; +use hickory_proto::rr::Record; +use hickory_proto::rr::RecordType; +use hickory_proto::serialize::binary::BinDecodable; +use hickory_proto::serialize::binary::BinDecoder; +use hickory_proto::serialize::binary::BinEncodable; +use indexmap::IndexSet; +use std::io; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; + +const MAX_NAMES: usize = 8192; +const V4_BASE: u32 = u32::from_be_bytes([198, 18, 0, 0]); +const V6_BASE: u128 = 0xfdc0_de00_0000_0000_0000_0000_0000_0000; + +#[derive(Default)] +pub(super) struct Names { + // Append only: both synthetic address families encode the same one-based index. + names: IndexSet, +} + +impl Names { + pub(super) fn lookup(&self, ip: IpAddr) -> io::Result> { + let id = match ip.to_canonical() { + IpAddr::V4(ip) if u32::from(ip) & 0xfffe_0000 == V4_BASE => u32::from(ip) - V4_BASE, + IpAddr::V6(ip) if u128::from(ip) >> 32 == V6_BASE >> 32 => u128::from(ip) as u32, + _ => return Ok(None), + }; + let name = id + .checked_sub(1) + .and_then(|index| self.names.get_index(index as usize)); + name.cloned() + .map(Some) + .ok_or_else(|| io::Error::other("unassigned synthetic address")) + } + + fn id(&mut self, host: String) -> Option { + let index = match self.names.get_index_of(&host) { + Some(index) => index, + None if self.names.len() < MAX_NAMES => self.names.insert_full(host).0, + None => return None, + }; + Some(index as u32 + 1) + } + + fn answer(&mut self, packet: &[u8]) -> Option> { + if packet.len() > 4096 { + return None; + } + // Decode one question only. EDNS and other trailing bytes never go to a resolver + // or into the reply, and untrusted section counts never size an allocation. + let mut decoder = BinDecoder::new(packet); + let header = Header::read(&mut decoder).ok()?; + if header.message_type != MessageType::Query + || header.op_code != OpCode::Query + || header.counts.queries != 1 + || header.counts.answers != 0 + || header.counts.authorities != 0 + { + return None; + } + let start = decoder.index(); + let query = Query::read(&mut decoder).ok()?; + // The first question has no earlier name to reference. Reject compression + // and labels that would require escapes when converted to a SOCKS hostname. + if query.to_bytes().ok()? != decoder.slice_from(start).ok()? + || !query + .name + .iter() + .flatten() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + return None; + } + + let mut reply = Message::response(header.id, OpCode::Query); + reply.metadata.recursion_desired = header.recursion_desired; + reply.metadata.recursion_available = true; + if query.query_class == DNSClass::IN + && matches!(query.query_type, RecordType::A | RecordType::AAAA) + && !query.name.is_root() + { + let host = query + .name + .to_ascii() + .trim_end_matches('.') + .to_ascii_lowercase(); + if let Some(id) = self.id(host) { + let ip = if query.query_type == RecordType::A { + IpAddr::V4(Ipv4Addr::from(V4_BASE + id)) + } else { + IpAddr::V6(Ipv6Addr::from(V6_BASE + u128::from(id))) + }; + reply.add_answer(Record::from_rdata(query.name.clone(), 60, RData::from(ip))); + } else { + reply.metadata.response_code = ResponseCode::ServFail; + } + } + reply.add_query(query); + reply.to_vec().ok() + } +} + +#[cfg(test)] +#[path = "managed_tcp_tests.rs"] +mod tests; diff --git a/codex-rs/linux-sandbox/src/managed_tcp_relay_tests.rs b/codex-rs/linux-sandbox/src/managed_tcp_relay_tests.rs new file mode 100644 index 0000000000..5e37274acb --- /dev/null +++ b/codex-rs/linux-sandbox/src/managed_tcp_relay_tests.rs @@ -0,0 +1,72 @@ +#![cfg(test)] + +//! Verify rejected TUN requests cannot open an upstream connection. + +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() { + let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + 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); + 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!(task.join().unwrap().is_err()); +} diff --git a/codex-rs/linux-sandbox/src/managed_tcp_tests.rs b/codex-rs/linux-sandbox/src/managed_tcp_tests.rs new file mode 100644 index 0000000000..5f85df839e --- /dev/null +++ b/codex-rs/linux-sandbox/src/managed_tcp_tests.rs @@ -0,0 +1,80 @@ +#![cfg(test)] + +//! Check DNS identity across address families, cache exhaustion and literal-address requests. + +use super::*; +use pretty_assertions::assert_eq; + +fn query(name: &str, kind: RecordType) -> Vec { + let mut packet = Message::new(/*id*/ 1, MessageType::Query, OpCode::Query); + packet.add_query(Query::query(name.parse().unwrap(), kind)); + packet.to_vec().unwrap() +} + +#[test] +fn both_families_retain_the_original_name_and_literal_ips_are_separate() { + let mut names = Names::default(); + let a = names.answer(&query("Db.Example", RecordType::A)).unwrap(); + let aaaa = names + .answer(&query("db.example", RecordType::AAAA)) + .unwrap(); + let ip4 = Ipv4Addr::from(<[u8; 4]>::try_from(&a[a.len() - 4..]).unwrap()); + let ip6 = Ipv6Addr::from(<[u8; 16]>::try_from(&aaaa[aaaa.len() - 16..]).unwrap()); + for address in [ + IpAddr::V4(ip4), + IpAddr::V6(ip6), + IpAddr::V6(ip4.to_ipv6_mapped()), + ] { + assert_eq!( + names.lookup(address).unwrap().as_deref(), + Some("db.example") + ); + } + assert_eq!(names.lookup("203.0.113.19".parse().unwrap()).unwrap(), None); + assert_eq!(names.lookup("2001:db8::19".parse().unwrap()).unwrap(), None); +} + +#[test] +fn capacity_exhaustion_never_reassigns_an_existing_address() { + let mut names = Names::default(); + let first = names + .answer(&query("first.example", RecordType::A)) + .unwrap(); + for n in 1..MAX_NAMES { + names + .answer(&query(&format!("n{n}.example"), RecordType::A)) + .unwrap(); + } + assert_eq!( + names.answer(&query("new.example", RecordType::A)).unwrap()[3] & 15, + 2 + ); + assert_eq!( + names + .answer(&query("first.example", RecordType::A)) + .unwrap(), + first + ); +} + +#[test] +fn malformed_and_unsupported_questions_cannot_allocate_an_alias() { + let mut names = Names::default(); + assert!(names.answer(&[0; 11]).is_none()); + let mut packet = query("valid.example", RecordType::A); + packet[12] = 0xc0; + assert!(names.answer(&packet).is_none()); + let mut counts = query("valid.example", RecordType::A); + counts[6..12].fill(0xff); + assert!(names.answer(&counts).is_none()); + let mut header_pointer = query("valid.example", RecordType::A); + header_pointer.splice(12..27, [0xc0, 0x00]); + assert!(names.answer(&header_pointer).is_none()); + assert_eq!( + names + .answer(&query("valid.example", RecordType::MX)) + .unwrap()[7], + 0 + ); + assert!(names.names.is_empty()); +} diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index 94f3527bb5..908d682d8c 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -23,28 +23,12 @@ use std::os::fd::AsRawFd; use std::os::fd::FromRawFd; use std::os::fd::OwnedFd; 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 PROXY_ENV_KEYS: &[&str] = &[ - "HTTP_PROXY", - "HTTPS_PROXY", - "WS_PROXY", - "WSS_PROXY", - "ALL_PROXY", - "FTP_PROXY", - "YARN_HTTP_PROXY", - "YARN_HTTPS_PROXY", - "NPM_CONFIG_HTTP_PROXY", - "NPM_CONFIG_HTTPS_PROXY", - "NPM_CONFIG_PROXY", - "BUNDLE_HTTP_PROXY", - "BUNDLE_HTTPS_PROXY", - "PIP_PROXY", - "DOCKER_HTTP_PROXY", - "DOCKER_HTTPS_PROXY", -]; - const HOST_BRIDGE_READY: u8 = 1; const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo"; const HANDOFF_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); @@ -72,7 +56,9 @@ struct ProxyRoutePlan { has_proxy_config: bool, } -pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<(String, Vec)> { +pub(crate) fn prepare_host_proxy_route_spec( + port_allowlist: Option<&[u16]>, +) -> io::Result<(String, Vec)> { 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 // executes the user command. @@ -89,6 +75,20 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<(String, Vec)> return Err(io::Error::new(io::ErrorKind::InvalidInput, message)); } + // Validate the entire plan before forking any host-side workers or sending + // attribution. An empty enforced list grants no routes. + if let Some(ports) = port_allowlist + && plan + .routes + .iter() + .any(|route| !ports.contains(&route.endpoint.port())) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "proxy endpoint is outside the managed sandbox context", + )); + } + let mut control_by_endpoint = BTreeMap::new(); for route in &plan.routes { if control_by_endpoint.contains_key(&route.endpoint) { @@ -237,8 +237,12 @@ fn plan_proxy_routes(env: &HashMap) -> ProxyRoutePlan { } fn is_proxy_env_key(key: &str) -> bool { - let upper = key.to_ascii_uppercase(); - PROXY_ENV_KEYS.contains(&upper.as_str()) + // Only exact key spellings overwritten by managed setup may nominate a host + // socket. A caller-controlled mixed-case alias can otherwise create a second bridge. + codex_network_proxy::PROXY_ENV_KEYS.contains(&key) + && codex_network_proxy::PROXY_URL_ENV_KEYS + .iter() + .any(|url_key| key.eq_ignore_ascii_case(url_key)) } fn parse_loopback_proxy_endpoint(proxy_url: &str) -> Option { @@ -352,6 +356,26 @@ fn spawn_host_bridge( Ok(File::from(OwnedFd::from(control))) } +/// Each connection worker holds a permit until it exits. +pub(super) struct ConnectionSlots(Arc); + +impl ConnectionSlots { + pub(super) fn new() -> Self { + const MAX_CONNECTIONS: usize = 128; + 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; + }; + let _ = std::thread::Builder::new().spawn(move || { + let _permit = permit; + serve(); + }); + } +} + fn run_host_bridge( endpoint: SocketAddr, mut control: UnixStream, @@ -368,10 +392,11 @@ fn run_host_bridge( drop(control); let attribution_token = attribution_token.map(str::to_owned); + let slots = ConnectionSlots::new(); loop { let (client_stream, _) = listener.accept()?; let attribution_token = attribution_token.clone(); - std::thread::spawn(move || { + slots.spawn(move || { let mut tcp_stream = match TcpStream::connect(endpoint) { Ok(stream) => stream, Err(_) => return, @@ -468,22 +493,38 @@ fn ensure_loopback_interface_up() -> io::Result<()> { close_fd(fd) } -fn proxy_bidirectional(mut upstream: TcpStream, mut client: TcpStream) -> io::Result<()> { - let mut upstream_reader = upstream.try_clone()?; - let mut client_writer = client.try_clone()?; - let upstream_to_client = std::thread::spawn(move || { - let result = std::io::copy(&mut upstream_reader, &mut client_writer); - let _ = client_writer.shutdown(Shutdown::Write); - result - }); - let client_to_upstream = std::io::copy(&mut client, &mut upstream); - let _ = upstream.shutdown(Shutdown::Write); - let upstream_to_client = upstream_to_client - .join() - .map_err(|_| io::Error::other("bridge thread panicked"))?; - upstream_to_client?; - client_to_upstream?; - Ok(()) +pub(super) fn proxy_bidirectional(upstream: TcpStream, client: TcpStream) -> io::Result<()> { + let (upload_done, upload) = mpsc::channel(); + let (download_done, download) = mpsc::channel(); + std::thread::scope(|scope| { + let response = std::thread::Builder::new().spawn_scoped(scope, || { + copy_and_shutdown(&upstream, &client, download_done, upload) + })?; + let request = copy_and_shutdown(&client, &upstream, upload_done, download); + response + .join() + .map_err(|_| io::Error::other("bridge thread panicked"))??; + request + }) +} + +fn copy_and_shutdown( + mut source: &TcpStream, + mut destination: &TcpStream, + done: mpsc::Sender<()>, + peer: mpsc::Receiver<()>, +) -> io::Result<()> { + let result = io::copy(&mut source, &mut destination); + let _ = destination.shutdown(Shutdown::Write); + let _ = done.send(()); + // Let the other direction flush queued bytes. A stalled peer must still release the permit. + if result.is_err() + && peer.recv_timeout(Duration::from_secs(1)) == Err(mpsc::RecvTimeoutError::Timeout) + { + let _ = source.shutdown(Shutdown::Both); + let _ = destination.shutdown(Shutdown::Both); + } + result.map(|_| ()) } #[cfg(test)] diff --git a/codex-rs/linux-sandbox/src/proxy_routing_tests.rs b/codex-rs/linux-sandbox/src/proxy_routing_tests.rs index 527efab4b6..336024365d 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing_tests.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing_tests.rs @@ -1,5 +1,6 @@ -//! Verify proxy route planning, attribution handling, and URL rewriting. +//! Verify proxy route planning, attribution handling, URL rewriting, and bridge cleanup. +use super::ConnectionSlots; use super::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use super::ProxyRouteEntry; use super::ProxyRouteSpec; @@ -8,17 +9,81 @@ use super::extract_attribution_token_and_plan; use super::is_proxy_env_key; use super::parse_loopback_proxy_endpoint; use super::plan_proxy_routes; +use super::proxy_bidirectional; use super::rewrite_proxy_env_value; use pretty_assertions::assert_eq; use std::collections::HashMap; +use std::io::Read; +use std::io::Write; +use std::net::Ipv4Addr; +use std::net::Shutdown; use std::net::SocketAddr; +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() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let connect = || { + let peer = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + (peer, listener.accept().unwrap().0) + }; + for reset_upstream in [false, true] { + let (reset_peer, reset_socket) = connect(); + let (idle_peer, idle_socket) = connect(); + let slots = ConnectionSlots(Arc::new(Semaphore::new(1))); + let (upstream, client) = if reset_upstream { + (reset_socket, idle_socket) + } else { + (idle_socket, reset_socket) + }; + let (done, result) = tokio::sync::oneshot::channel(); + slots.spawn(move || { + let _ = done.send(proxy_bidirectional(upstream, client)); + }); + rustix::net::sockopt::set_socket_linger(&reset_peer, Some(Duration::ZERO)).unwrap(); + drop(reset_peer); + + 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(); + assert!(result.await.unwrap().is_err()); + drop(idle_peer); + } +} #[test] -fn recognizes_proxy_env_keys_case_insensitively() { +fn upload_failure_drains_the_pending_response() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let mut server = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let upstream = listener.accept().unwrap().0; + let mut peer = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let client = listener.accept().unwrap().0; + + server.write_all(b"response").unwrap(); + assert!(upstream.peek(&mut [0]).unwrap() > 0); + rustix::net::sockopt::set_socket_linger(&server, Some(Duration::ZERO)).unwrap(); + drop(server); + peer.write_all(b"upload").unwrap(); + peer.shutdown(Shutdown::Write).unwrap(); + assert!(proxy_bidirectional(upstream, client).is_err()); + let mut response = Vec::new(); + peer.read_to_end(&mut response).unwrap(); + assert_eq!(response, b"response"); +} + +#[test] +fn recognizes_only_proxy_url_keys_written_by_managed_setup() { assert_eq!(is_proxy_env_key("HTTP_PROXY"), true); assert_eq!(is_proxy_env_key("http_proxy"), true); assert_eq!(is_proxy_env_key("WS_PROXY"), true); assert_eq!(is_proxy_env_key("wss_proxy"), true); + assert_eq!(is_proxy_env_key("HtTp_PrOxY"), false); + assert_eq!(is_proxy_env_key("pip_proxy"), false); assert_eq!(is_proxy_env_key("PATH"), false); } @@ -54,6 +119,10 @@ fn plan_proxy_routes_only_includes_valid_loopback_endpoints() { "HTTPS_PROXY".to_string(), "http://example.com:3128".to_string(), ); + env.insert( + "HtTp_PrOxY".to_string(), + "http://127.0.0.1:32102".to_string(), + ); env.insert("PATH".to_string(), "/usr/bin".to_string()); let plan = plan_proxy_routes(&env); diff --git a/codex-rs/network-proxy/src/connect_policy.rs b/codex-rs/network-proxy/src/connect_policy.rs index abb6a67d91..916f753421 100644 --- a/codex-rs/network-proxy/src/connect_policy.rs +++ b/codex-rs/network-proxy/src/connect_policy.rs @@ -1,6 +1,13 @@ +use crate::network_policy::BlockDecisionAuditEventArgs; +use crate::network_policy::NetworkDecisionSource; +use crate::network_policy::NetworkPolicyDecision; +use crate::network_policy::emit_block_decision_audit_event; use crate::policy::is_non_public_ip; +use crate::reasons::REASON_POLICY_DENIED; use crate::runtime::HostAuthorization; use crate::runtime::HostBlockDecision; +use crate::state::BlockedRequest; +use crate::state::BlockedRequestArgs; use crate::state::NetworkProxyState; #[cfg(target_os = "macos")] use crate::system_dns::SystemDnsResolver; @@ -17,12 +24,84 @@ use rama_net::transport::TryRefIntoTransportContext; use rama_tcp::TcpStream; use rama_tcp::client::TcpStreamConnector; use rama_tcp::client::service::TcpConnector; +use std::error::Error; use std::io; use std::net::SocketAddr; use std::sync::Arc; +use std::sync::atomic::AtomicU8; +use std::sync::atomic::Ordering; + +const BLOCKED: u8 = 1; +const DIALED: u8 = 2; + +#[derive(Debug, thiserror::Error)] +#[error("all resolved addresses were blocked by network policy")] +pub(crate) struct ResolvedTargetsDenied; + +impl ResolvedTargetsDenied { + pub(crate) fn caused(error: &(dyn Error + 'static)) -> bool { + let mut cause = Some(error); + while let Some(error) = cause { + if error.is::() { + return true; + } + cause = error.source(); + } + false + } +} + +/// Metadata from the trusted admission decision, scoped to its logical destination. +#[derive(Clone, Debug)] +pub(crate) struct DialPolicy { + target: HostWithPort, + authorization: HostAuthorization, + protocol: crate::network_policy::NetworkProtocol, + client: Option, + method: Option, +} +impl DialPolicy { + pub(crate) fn new( + target: &HostWithPort, + authorization: HostAuthorization, + request: &crate::network_policy::NetworkPolicyRequest, + ) -> Self { + Self { + target: target.clone(), + authorization, + protocol: request.protocol, + client: request.client_addr.clone(), + method: request.method.clone(), + } + } + // MITM can drop the DNS root dot while reconstructing the dial authority. + fn for_target<'a>( + extensions: &'a rama_core::extensions::Extensions, + target: &HostWithPort, + ) -> Option<&'a Self> { + extensions.get::().filter(|policy| { + policy.target.port == target.port + && match (&policy.target.host, &target.host) { + (Host::Name(a), Host::Name(b)) => a + .as_str() + .trim_end_matches('.') + .eq_ignore_ascii_case(b.as_str().trim_end_matches('.')), + (a, b) => a == b, + } + }) + } + + pub(crate) fn authorization( + extensions: &rama_core::extensions::Extensions, + target: &HostWithPort, + ) -> HostAuthorization { + Self::for_target(extensions, target) + .map_or(HostAuthorization::RequireAllowlist, |p| p.authorization) + } +} /// Connects traffic whose host policy has already allowed the request. -/// Resolved non-public targets still undergo explicit-deny and local-address checks. +/// Apply address-specific restrictions at the actual direct dial, including DNS results. #[derive(Clone)] pub(crate) struct TargetCheckedTcpConnector { state: Arc, @@ -57,18 +136,62 @@ where .host_with_port() .ok_or_else(|| OpaqueError::from_display("network target is missing a port"))?; - connector + let policy = DialPolicy::for_target(input.extensions(), &target).cloned(); + let attempts = Arc::new(AtomicU8::new(0)); + let result = connector .with_connector(TargetCheckedStreamConnector { state: self.state.clone(), + authorization: policy + .as_ref() + .map_or(HostAuthorization::RequireAllowlist, |policy| { + policy.authorization + }), + attempts: attempts.clone(), target, }) .serve(input) - .await + .await; + if result.is_err() + && attempts.load(Ordering::Relaxed) == BLOCKED + && let Some(policy) = policy + { + let host = crate::policy::normalize_host(&policy.target.host.to_string()); + emit_block_decision_audit_event( + &self.state, + BlockDecisionAuditEventArgs { + source: NetworkDecisionSource::BaselinePolicy, + reason: REASON_POLICY_DENIED, + protocol: policy.protocol, + server_address: &host, + server_port: policy.target.port, + method: policy.method.as_deref(), + client_addr: policy.client.as_deref(), + }, + ); + let _ = self + .state + .record_blocked(BlockedRequest::new(BlockedRequestArgs { + host, + port: Some(policy.target.port), + client: policy.client, + method: policy.method, + mode: None, + protocol: policy.protocol.as_policy_protocol().into(), + reason: REASON_POLICY_DENIED.into(), + decision: Some(NetworkPolicyDecision::Deny.as_str().into()), + source: Some(NetworkDecisionSource::BaselinePolicy.as_str().into()), + })) + .await; + return Err(ResolvedTargetsDenied.into()); + } + result } } #[derive(Clone)] struct TargetCheckedStreamConnector { + authorization: HostAuthorization, + attempts: Arc, state: Arc, target: HostWithPort, } @@ -77,7 +200,13 @@ impl TcpStreamConnector for TargetCheckedStreamConnector { type Error = BoxError; async fn connect(&self, addr: SocketAddr) -> Result { - if is_non_public_ip(addr.ip()) && !self.allows_non_public_target(addr).await? { + if self + .state + .resolved_target_blocked(&self.target.host.to_string(), addr.ip(), self.authorization) + .await? + || (is_non_public_ip(addr.ip()) && !self.allows_non_public_target(addr).await?) + { + self.attempts.fetch_or(BLOCKED, Ordering::Relaxed); return Err(io::Error::new( io::ErrorKind::PermissionDenied, "network target rejected by policy", @@ -85,6 +214,7 @@ impl TcpStreamConnector for TargetCheckedStreamConnector { .into()); } + self.attempts.fetch_or(DIALED, Ordering::Relaxed); tokio::net::TcpStream::connect(addr) .await .map(TcpStream::from) @@ -112,7 +242,7 @@ impl TargetCheckedStreamConnector { &self.target.host.to_string(), self.target.port, /*allow_local_binding*/ None, - HostAuthorization::Approved, + self.authorization, ) .await .map(|decision| decision == HostBlockDecision::Allowed) @@ -152,6 +282,8 @@ mod tests { use super::*; use crate::config::NetworkProxyConfig; use crate::state::network_proxy_state_for_policy; + use pretty_assertions::assert_eq; + use rama_core::extensions::ExtensionsRef; use rama_net::address::HostWithPort; use std::net::Ipv4Addr; use tokio::net::TcpListener; @@ -206,7 +338,6 @@ mod tests { let target = listener.local_addr().expect("local addr"); let mut config = NetworkProxyConfig::default(); config.set_allowed_domains(vec![target.ip().to_string()]); - config.review_domains = vec![target.ip().to_string()]; let state = Arc::new(network_proxy_state_for_policy(config)); let connector = TargetCheckedTcpConnector::new(Arc::clone(&state)); @@ -234,7 +365,6 @@ mod tests { let target = listener.local_addr().expect("local addr"); let mut config = NetworkProxyConfig::default(); config.set_allowed_domains(vec!["localhost".to_string()]); - config.review_domains = vec!["localhost".to_string()]; let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy(config))); @@ -248,6 +378,90 @@ mod tests { ); } + #[tokio::test] + async fn policy_refresh_cannot_grant_a_missing_dial_approval() { + use crate::network_policy::NetworkDecision; + use crate::network_policy::NetworkPolicyDecider; + use crate::network_policy::NetworkPolicyRequest; + use crate::network_policy::NetworkPolicyRequestArgs; + use crate::network_policy::NetworkProtocol; + use crate::network_policy::evaluate_host_policy_for_dial; + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let target = HostWithPort::new(Host::LOCALHOST_NAME, addr.port()); + let mut config = NetworkProxyConfig { + allow_local_binding: Some(true), + ..Default::default() + }; + config.set_allowed_domains(vec!["localhost".into()]); + config.review_domains = vec![addr.ip().to_string(), "::1".into()]; + let state = Arc::new(network_proxy_state_for_policy(config.clone())); + let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { + host: "localhost".into(), + port: addr.port(), + protocol: NetworkProtocol::Socks5Tcp, + environment_id: None, + client_addr: None, + method: None, + command: None, + exec_policy_hint: None, + }); + let admitted_target = + HostWithPort::new(Host::Name("LOCALHOST.".parse().unwrap()), addr.port()); + let admit = async |decider: Option>| { + let mut req = rama_tcp::client::Request::new(target.clone()); + let decision = evaluate_host_policy_for_dial( + &state, + decider.as_ref(), + &request, + Some((&admitted_target, req.extensions_mut())), + ) + .await + .unwrap(); + assert_eq!(decision, NetworkDecision::Allow); + req + }; + let assert_addresses_blocked = async |authorization| { + let dial = TargetCheckedStreamConnector { + state: state.clone(), + target: target.clone(), + attempts: Arc::default(), + authorization, + }; + for ip in [addr.ip(), Ipv4Addr::LOCALHOST.to_ipv6_mapped().into()] { + let error = dial + .connect(SocketAddr::new(ip, addr.port())) + .await + .unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + io::ErrorKind::PermissionDenied + ); + } + }; + let original = admit(None).await; + assert_addresses_blocked(HostAuthorization::RequireAllowlist).await; + config.review_domains.push("localhost".into()); + state + .replace_config_state( + crate::build_config_state(config, Default::default(), crate::Platform::native()) + .unwrap(), + ) + .await + .unwrap(); + let approved = admit(Some(Arc::new(|_| async { NetworkDecision::Allow }))).await; + let other_port = HostWithPort::new(target.host.clone(), addr.port() ^ 1); + assert!(DialPolicy::for_target(approved.extensions(), &other_port).is_none()); + let connector = TargetCheckedTcpConnector::new(state.clone()); + assert!(connector.serve(original).await.is_err()); + assert!(connector.serve(approved).await.is_ok()); + state + .add_denied_domain(&addr.ip().to_string()) + .await + .unwrap(); + assert_addresses_blocked(HostAuthorization::Approved).await; + } + #[test] fn resolved_private_address_does_not_match_allowlisted_hostname() { let host = Host::Name("example.com".parse().expect("valid domain")); diff --git a/codex-rs/network-proxy/src/domain_matcher.rs b/codex-rs/network-proxy/src/domain_matcher.rs index bf71f8a276..30a4c1aef0 100644 --- a/codex-rs/network-proxy/src/domain_matcher.rs +++ b/codex-rs/network-proxy/src/domain_matcher.rs @@ -20,11 +20,13 @@ pub(crate) enum GlobalWildcard { #[derive(Clone)] pub struct DomainPatternSet { patterns: Arc<[Vec]>, + restricted_ip_literals: Arc>, } impl DomainPatternSet { pub(crate) fn new(patterns: &[String], global_wildcard: GlobalWildcard) -> Result { let mut compiled = Vec::new(); + let mut restricted_ip_literals = HashSet::new(); let mut seen = HashSet::new(); for pattern in patterns { if global_wildcard == GlobalWildcard::Reject @@ -73,11 +75,19 @@ impl DomainPatternSet { continue; } ensure!(!candidate.is_empty(), "domain pattern is empty"); + // Keep exact address equivalence separate from ordinary allowlist + // matching. A scoped IP rule must never apply to other interfaces. + if !candidate.contains('%') + && let Some(ip) = crate::policy::numeric_host_address(&candidate) + { + restricted_ip_literals.insert(ip.to_canonical()); + } compiled.push(candidate.into_bytes()); } } Ok(Self { patterns: compiled.into(), + restricted_ip_literals: Arc::new(restricted_ip_literals), }) } @@ -85,6 +95,37 @@ impl DomainPatternSet { let host = host.as_ref().as_bytes(); self.patterns.iter().any(|pattern| matches(pattern, host)) } + + /// Conservatively include wildcard patterns that could match a canonical IP spelling. + pub(crate) fn may_restrict_ip(&self) -> bool { + !self.restricted_ip_literals.is_empty() + || self.patterns.iter().any(|p| { + p.iter().any(|b| matches!(b, b'*' | b'?')) + && p.iter() + .all(|b| b.is_ascii_hexdigit() || matches!(b, b':' | b'.' | b'*' | b'?')) + // Dots occur only in the decimal IPv4 tail, including mapped IPv6. + && p.iter().skip_while(|b| **b != b'.') + .all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'*' | b'?')) + }) + } + + /// An IPv4 address and its IPv4-mapped IPv6 form can dial the same peer. + /// Check both spellings for restrictive rules, without expanding allowlists. + pub(crate) fn restricts_ip(&self, ip: IpAddr) -> bool { + if self.patterns.is_empty() { + return false; + } + let ip = ip.to_canonical(); + if self.restricted_ip_literals.contains(&ip) { + return true; + } + match ip { + IpAddr::V4(ip) => { + self.is_match(ip.to_string()) || self.is_match(ip.to_ipv6_mapped().to_string()) + } + IpAddr::V6(ip) => self.is_match(ip.to_string()), + } + } } /// Normalize host fragments for policy matching (trim whitespace, strip ports/brackets, lowercase). diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index 541c4e8f0f..2f20a3b7ac 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -1,5 +1,6 @@ use crate::attribution::BindConnectionAttribution; use crate::config::NetworkMode; +use crate::connect_policy::ResolvedTargetsDenied; use crate::connect_policy::TargetCheckedTcpConnector; use crate::connection_lifecycle::CancelOnShutdown; use crate::mitm; @@ -14,7 +15,7 @@ use crate::network_policy::NetworkPolicyRequestArgs; use crate::network_policy::NetworkProtocol; use crate::network_policy::emit_allow_decision_audit_event; use crate::network_policy::emit_block_decision_audit_event; -use crate::network_policy::evaluate_host_policy; +use crate::network_policy::evaluate_host_policy_for_dial; use crate::policy::normalize_host; use crate::reasons::REASON_METHOD_NOT_ALLOWED; use crate::reasons::REASON_MITM_HOOK_DENIED; @@ -252,7 +253,12 @@ async fn http_connect_accept( match disconnect .track_http_request( started_at, - evaluate_host_policy(&app_state, policy_decider.as_ref(), &request), + evaluate_host_policy_for_dial( + &app_state, + policy_decider.as_ref(), + &request, + Some((&authority, req.extensions_mut())), + ), ) .await { @@ -788,7 +794,12 @@ async fn http_plain_proxy( match disconnect .track_http_request( started_at, - evaluate_host_policy(&app_state, policy_decider.as_ref(), &request), + evaluate_host_policy_for_dial( + &app_state, + policy_decider.as_ref(), + &request, + Some((&authority, req.extensions_mut())), + ), ) .await { @@ -1019,6 +1030,21 @@ async fn http_plain_proxy( remove_hop_by_hop_request_headers(req.headers_mut()); match client.serve(req).await { Ok(resp) => Ok(resp), + Err(err) if ResolvedTargetsDenied::caused(&err) => { + let reason = crate::reasons::REASON_POLICY_DENIED; + Ok(json_blocked( + &host, + reason, + Some(&PolicyDecisionDetails { + decision: NetworkPolicyDecision::Deny, + source: NetworkDecisionSource::BaselinePolicy, + reason, + protocol: NetworkProtocol::Http, + host: &host, + port, + }), + )) + } Err(err) => { warn!("upstream request failed: {err}"); Ok(text_response(StatusCode::BAD_GATEWAY, "upstream failure")) @@ -1339,6 +1365,47 @@ mod tests { ); } + #[tokio::test] + async fn all_resolved_addresses_blocked_reports_a_policy_denial() { + let mut config = NetworkProxyConfig { + allow_local_binding: Some(true), + allow_upstream_proxy: false, + ..Default::default() + }; + config.set_allowed_domains(vec!["localhost".into()]); + config.set_denied_domains(vec!["127.0.0.1".into(), "::1".into()]); + let mut state = network_proxy_state_for_policy(config); + let (tx, audit) = std::sync::mpsc::channel(); + state.set_policy_audit_observer(Arc::new(move |event| tx.send(event).unwrap())); + let state = Arc::new(state); + let mut req = Request::builder() + .uri("http://localhost:8080/") + .header("host", "localhost:8080") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(state.clone()); + let response = http_plain_proxy( + /*policy_decider*/ None, /*environment_id*/ None, req, + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let decisions: Vec<_> = audit + .try_iter() + .map(|event| (event.decision, event.host, event.port)) + .collect(); + assert_eq!( + decisions, + [ + ("allow".to_string(), "localhost".to_string(), 8080), + ("deny".to_string(), "localhost".to_string(), 8080), + ] + ); + let blocked = state.drain_blocked().await.unwrap(); + assert_eq!(blocked.len(), 1); + assert_eq!(blocked[0].host, "localhost"); + } + #[tokio::test] async fn http_connect_accept_allows_allowlisted_host_in_full_mode() { let policy = { diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 7e12ae9b90..9367e0e022 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -32,6 +32,7 @@ mod socks5; mod state; #[cfg(target_os = "macos")] mod system_dns; +mod tcp_upstream; mod upstream; #[cfg(target_os = "windows")] mod windows_proxy_ingress; @@ -136,3 +137,4 @@ pub use state::NetworkProxyConstraints; pub use state::PartialNetworkProxyConfig; pub use state::build_config_state; pub use state::validate_policy_against_constraints; +pub use tcp_upstream::TcpUpstreamProxy; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 01784c7be5..5012f8d012 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -125,6 +125,7 @@ impl MitmPolicyContext { #[derive(Clone)] struct MitmRequestContext { + dial_policy: Option, policy: MitmPolicyContext, mitm: Arc, upstream: UpstreamClient, @@ -233,6 +234,10 @@ where ) }; let request_ctx = Arc::new(MitmRequestContext { + dial_policy: stream + .extensions() + .get::() + .cloned(), policy: MitmPolicyContext { target_host, target_port, @@ -292,6 +297,9 @@ async fn handle_mitm_request( .await { Ok(resp) => resp, + Err(err) if crate::connect_policy::ResolvedTargetsDenied::caused(err.as_ref()) => { + blocked_text_response(crate::reasons::REASON_POLICY_DENIED) + } Err(err) => { warn!("MITM request handling failed: {err}"); text_response(StatusCode::BAD_GATEWAY, "mitm upstream error") @@ -385,6 +393,9 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu }; upstream_req = signed; } + if let Some(policy) = &request_ctx.dial_policy { + upstream_req.extensions_mut().insert(policy.clone()); + } let upstream_resp = if request_ctx.policy.scheme == Scheme::HTTP { crate::brokered_tunnel::forward_http_request(upstream_req, &request_ctx.upstream).await? } else { diff --git a/codex-rs/network-proxy/src/mitm_forwarding_tests.rs b/codex-rs/network-proxy/src/mitm_forwarding_tests.rs index 057806f369..62421e759e 100644 --- a/codex-rs/network-proxy/src/mitm_forwarding_tests.rs +++ b/codex-rs/network-proxy/src/mitm_forwarding_tests.rs @@ -118,6 +118,7 @@ async fn tls_origin_with_hook( max_body_bytes: MITM_MAX_BODY_BYTES, }); let context = MitmRequestContext { + dial_policy: None, policy: MitmPolicyContext { target_host: "localhost".to_string(), target_port: port, diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index 9af47415ac..51ebd9c4a1 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -365,6 +365,18 @@ pub(crate) async fn evaluate_host_policy( state: &NetworkProxyState, decider: Option<&Arc>, request: &NetworkPolicyRequest, +) -> Result { + evaluate_host_policy_for_dial(state, decider, request, /*dial*/ None).await +} + +pub(crate) async fn evaluate_host_policy_for_dial( + state: &NetworkProxyState, + decider: Option<&Arc>, + request: &NetworkPolicyRequest, + dial: Option<( + &rama_net::address::HostWithPort, + &mut rama_core::extensions::Extensions, + )>, ) -> Result { let execution_id = state.execution_id(); let host_decision = state.host_blocked(&request.host, request.port).await?; @@ -456,6 +468,20 @@ pub(crate) async fn evaluate_host_policy( }, ); + if let Some((target, extensions)) = dial + && matches!(decision, NetworkDecision::Allow) + { + let authorization = if policy_override { + HostAuthorization::Approved + } else { + HostAuthorization::RequireAllowlist + }; + extensions.insert(crate::connect_policy::DialPolicy::new( + target, + authorization, + request, + )); + } Ok(decision) } diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs index d4f2142295..4a6f6054d0 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -28,6 +28,26 @@ impl Host { } } +/// Interpret numeric host spellings without consulting DNS. System resolvers +/// also accept shortened, integer, octal and hexadecimal IPv4 forms. +pub(crate) fn numeric_host_address(host: &str) -> Option { + let host = unscoped_ip_literal(host).unwrap_or(host); + if let Ok(ip) = host.parse() { + return Some(ip); + } + if !host.as_bytes().first()?.is_ascii_digit() + || !host + .bytes() + .all(|b| b.is_ascii_hexdigit() || matches!(b, b'.' | b'x' | b'X')) + { + return None; + } + match UrlHost::parse(host).ok()? { + UrlHost::Ipv4(ip) => Some(IpAddr::V4(ip)), + _ => None, + } +} + /// Returns true if the host is a loopback hostname or IP literal. pub fn is_loopback_host(host: &Host) -> bool { let host = host.as_str(); diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 2c826c4eb3..c7a0196674 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -91,7 +91,7 @@ impl std::fmt::Display for HostBlockReason { } } -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum HostAuthorization { RequireAllowlist, Approved, @@ -287,6 +287,7 @@ pub struct NetworkProxyState { /// Belongs to this proxy; config retains omission for launches on other executors. pub(crate) local_binding_policy: LocalBindingPolicy, pub(crate) proxy_private_ips_via_upstream: bool, + pub(crate) tcp_upstream_proxy: Option, reloader: Arc, blocked_request_observer: Arc>>>, pub(crate) policy_audit_observer: Option, @@ -328,6 +329,7 @@ impl Clone for NetworkProxyState { state: self.state.clone(), local_binding_policy: self.local_binding_policy, proxy_private_ips_via_upstream: self.proxy_private_ips_via_upstream, + tcp_upstream_proxy: self.tcp_upstream_proxy.clone(), reloader: self.reloader.clone(), blocked_request_observer: self.blocked_request_observer.clone(), policy_audit_observer: self.policy_audit_observer.clone(), @@ -350,6 +352,20 @@ impl NetworkProxyState { self.proxy_private_ips_via_upstream = enabled; } + /// Require the trusted HTTP CONNECT endpoint for opaque SOCKS TCP connections. + pub fn set_tcp_upstream_proxy(&mut self, proxy: crate::TcpUpstreamProxy) { + self.tcp_upstream_proxy = Some(proxy); + } + + /// A remote DNS answer cannot be checked at our direct socket boundary. + pub(crate) async fn permits_remote_tcp_dns(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(self.local_binding_policy.resolve(&guard.config) + && !guard.deny_set.may_restrict_ip() + && !guard.review_set.may_restrict_ip()) + } + /// Builds runtime state for one executor-local proxy launch. /// The launching executor supplies its own OS; the wire policy cannot override it. pub fn from_remote_launch_config( @@ -446,6 +462,7 @@ impl NetworkProxyState { state: Arc::new(RwLock::new(state)), local_binding_policy, proxy_private_ips_via_upstream: false, + tcp_upstream_proxy: None, reloader, blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)), policy_audit_observer: None, @@ -885,12 +902,16 @@ impl NetworkProxyState { // 2) local/private networking is opt-in (defense-in-depth) // 3) managed review selectors precede every ordinary allow rule // 4) DNS requires an allowlist match or an approval - if domains_match_host_or_unscoped(&deny_set, host_str) { + let numeric_host = crate::policy::numeric_host_address(host_str); + if domains_match_host_or_unscoped(&deny_set, host_str) + || numeric_host.is_some_and(|ip| deny_set.restricts_ip(ip)) + { return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied)); } let is_authorized = authorization == HostAuthorization::Approved || (!domains_match_host_or_unscoped(&review_set, host_str) + && !numeric_host.is_some_and(|ip| review_set.restricts_ip(ip)) && domains_match_host_or_unscoped(&allow_set, host_str)); if !allow_local_binding { // If the intent is "prevent access to local/internal networks", we must not rely solely @@ -1052,6 +1073,32 @@ impl NetworkProxyState { Ok(guard.config.allow_upstream_proxy) } + /// The logical host has already passed policy. Fail closed if the actual + /// socket needs an IP-specific denial or review the hostname did not receive. + pub(crate) async fn resolved_target_blocked( + &self, + host: &str, + ip: IpAddr, + authorization: HostAuthorization, + ) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + if guard.deny_set.restricts_ip(ip) { + return Ok(true); + } + if !guard.review_set.restricts_ip(ip) { + return Ok(false); + } + if authorization != HostAuthorization::Approved { + return Ok(true); + } + let host = Host::parse(host)?; + let host = host.as_str(); + Ok(!domains_match_host_or_unscoped(&guard.review_set, host) + && crate::policy::numeric_host_address(host) + .is_none_or(|target| target.to_canonical() != ip.to_canonical())) + } + pub async fn allow_local_binding(&self) -> Result { self.reload_if_needed().await?; let guard = self.state.read().await; @@ -1984,13 +2031,22 @@ mod tests { } #[tokio::test] - async fn host_blocked_denies_scoped_ipv6_literal_before_local_binding() { + async fn host_blocked_denies_equivalent_ip_literals_before_local_binding() { let state = network_proxy_state_for_policy(NetworkProxyConfig { allow_local_binding: Some(true), - ..network_settings(&["*"], &["fd00::1"]) + ..network_settings(&["*"], &["fd00::1", "127.0.0.1"]) }); - for host in ["fd00::1%eth0", "[fd00::1%eth0]", "[fd00::1%25eth0]"] { + for host in [ + "fd00::1%eth0", + "[fd00::1%eth0]", + "[fd00::1%25eth0]", + "::ffff:127.0.0.1", + "127.1", + "2130706433", + "0177.0.0.1", + "0x7f000001", + ] { assert_eq!( state.host_blocked(host, /*port*/ 80).await.unwrap(), HostBlockDecision::Blocked(HostBlockReason::Denied), diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index c8e7120c8d..4a28777d5d 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -1,5 +1,6 @@ use crate::attribution::BindConnectionAttribution; use crate::config::NetworkMode; +use crate::connect_policy::DialPolicy; use crate::connect_policy::TargetCheckedTcpConnector; use crate::connection_lifecycle::CancelOnShutdown; use crate::mitm; @@ -13,6 +14,7 @@ use crate::network_policy::NetworkPolicyRequestArgs; use crate::network_policy::NetworkProtocol; use crate::network_policy::emit_block_decision_audit_event; use crate::network_policy::evaluate_host_policy; +use crate::network_policy::evaluate_host_policy_for_dial; use crate::policy::normalize_host; use crate::reasons::REASON_METHOD_NOT_ALLOWED; use crate::reasons::REASON_MITM_REQUIRED; @@ -33,6 +35,7 @@ use rama_core::extensions::ExtensionsRef; use rama_core::graceful::ShutdownGuard; use rama_core::service::BoxService; use rama_core::service::service_fn; +use rama_core::stream::rewind::Rewind; use rama_net::address::HostWithPort; use rama_net::client::EstablishedClientConnection; use rama_net::proxy::ProxyRequest; @@ -157,15 +160,13 @@ pub(crate) fn socks5_proxy_service( environment_id: Option, enable_socks5_udp: bool, ) -> BoxService { - let tcp_connector = TargetCheckedTcpConnector::new(state.clone()); let policy_tcp_connector = service_fn({ let policy_decider = policy_decider.clone(); let environment_id = environment_id.clone(); move |req: TcpRequest| { - let tcp_connector = tcp_connector.clone(); let policy_decider = policy_decider.clone(); let environment_id = environment_id.clone(); - async move { handle_socks5_tcp(req, tcp_connector, policy_decider, environment_id).await } + async move { handle_socks5_tcp(req, policy_decider, environment_id).await } } }); @@ -198,8 +199,7 @@ pub(crate) fn socks5_proxy_service( } async fn handle_socks5_tcp( - req: TcpRequest, - tcp_connector: TargetCheckedTcpConnector, + mut req: TcpRequest, policy_decider: Option>, environment_id: Option, ) -> Result, BoxError> { @@ -330,7 +330,14 @@ async fn handle_socks5_tcp( exec_policy_hint: None, }); - match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { + match evaluate_host_policy_for_dial( + &app_state, + policy_decider.as_ref(), + &request, + Some((&target, req.extensions_mut())), + ) + .await + { Ok(NetworkDecision::Deny { reason, source, @@ -431,21 +438,25 @@ async fn handle_socks5_tcp( if let Some(mitm_state) = mitm_state { let client = client.as_deref().unwrap_or_default(); + let mut extensions = Extensions::new(); + if let Some(policy) = req.extensions().get::() { + extensions.insert(policy.clone()); + } let conn = match socks_mitm_mode { SocksMitmMode::Disabled => None, SocksMitmMode::Enabled => Some(Socks5TcpConnection::Mitm { target, mode, mitm: mitm_state, - extensions: Extensions::new(), + extensions, }), SocksMitmMode::DetectProtocol(protocols) => Some(Socks5TcpConnection::DetectProtocol { protocols, target, mode, mitm: mitm_state, - state: app_state, - extensions: Extensions::new(), + state: app_state.clone(), + extensions, }), }; if let Some(conn) = conn { @@ -458,13 +469,7 @@ async fn handle_socks5_tcp( info!("SOCKS upstream dial started (host={host}, port={port})"); let connect_started_at = Instant::now(); - let result = tcp_connector.serve(req).await.map(|connection| { - let EstablishedClientConnection { input, conn } = connection; - EstablishedClientConnection { - input, - conn: Socks5TcpConnection::Direct(conn), - } - }); + let result = connect_opaque_tcp(app_state, req).await; match &result { Ok(_) => info!( "SOCKS upstream dial established (host={host}, port={port}, elapsed_ms={})", @@ -478,6 +483,31 @@ async fn handle_socks5_tcp( result } +async fn connect_opaque_tcp( + state: Arc, + req: TcpRequest, +) -> Result, BoxError> { + if let Some(proxy) = &state.tcp_upstream_proxy { + let tunnel = proxy + .connect( + &state, + &req.authority, + DialPolicy::authorization(req.extensions(), &req.authority), + ) + .await?; + return Ok(EstablishedClientConnection { + input: req, + conn: Socks5TcpConnection::Tunnel(tunnel), + }); + } + let EstablishedClientConnection { input, conn } = + TargetCheckedTcpConnector::new(state).serve(req).await?; + Ok(EstablishedClientConnection { + input, + conn: Socks5TcpConnection::Direct(conn), + }) +} + /// Internal connector output for SOCKS5 TCP. MITM requests do not dial upstream before the /// inner HTTPS request is inspected, so they carry the target metadata instead of a socket. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -490,6 +520,7 @@ enum SocksMitmMode { #[derive(Debug)] enum Socks5TcpConnection { Direct(TcpStream), + Tunnel(Rewind), Mitm { target: HostWithPort, mode: NetworkMode, @@ -514,6 +545,7 @@ impl AsyncRead for Socks5TcpConnection { ) -> Poll> { match self.get_mut() { Self::Direct(stream) => Pin::new(stream).poll_read(cx, buf), + Self::Tunnel(stream) => Pin::new(stream).poll_read(cx, buf), Self::Mitm { .. } | Self::DetectProtocol { .. } => Poll::Ready(Ok(())), } } @@ -527,6 +559,7 @@ impl AsyncWrite for Socks5TcpConnection { ) -> Poll> { match self.get_mut() { Self::Direct(stream) => Pin::new(stream).poll_write(cx, buf), + Self::Tunnel(stream) => Pin::new(stream).poll_write(cx, buf), Self::Mitm { .. } | Self::DetectProtocol { .. } => Poll::Ready(Ok(buf.len())), } } @@ -534,6 +567,7 @@ impl AsyncWrite for Socks5TcpConnection { fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { match self.get_mut() { Self::Direct(stream) => Pin::new(stream).poll_flush(cx), + Self::Tunnel(stream) => Pin::new(stream).poll_flush(cx), Self::Mitm { .. } | Self::DetectProtocol { .. } => Poll::Ready(Ok(())), } } @@ -541,6 +575,7 @@ impl AsyncWrite for Socks5TcpConnection { fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { match self.get_mut() { Self::Direct(stream) => Pin::new(stream).poll_shutdown(cx), + Self::Tunnel(stream) => Pin::new(stream).poll_shutdown(cx), Self::Mitm { .. } | Self::DetectProtocol { .. } => Poll::Ready(Ok(())), } } @@ -550,7 +585,7 @@ impl Socket for Socks5TcpConnection { fn local_addr(&self) -> io::Result { match self { Self::Direct(stream) => stream.local_addr(), - Self::Mitm { .. } | Self::DetectProtocol { .. } => { + Self::Tunnel(_) | Self::Mitm { .. } | Self::DetectProtocol { .. } => { Ok(SocketAddr::from(([0, 0, 0, 0], 0))) } } @@ -559,7 +594,7 @@ impl Socket for Socks5TcpConnection { fn peer_addr(&self) -> io::Result { match self { Self::Direct(stream) => stream.peer_addr(), - Self::Mitm { .. } | Self::DetectProtocol { .. } => { + Self::Tunnel(_) | Self::Mitm { .. } | Self::DetectProtocol { .. } => { Ok(SocketAddr::from(([0, 0, 0, 0], 0))) } } @@ -570,6 +605,7 @@ impl ExtensionsRef for Socks5TcpConnection { fn extensions(&self) -> &Extensions { match self { Self::Direct(stream) => stream.extensions(), + Self::Tunnel(stream) => stream.extensions(), Self::Mitm { extensions, .. } | Self::DetectProtocol { extensions, .. } => extensions, } } @@ -579,6 +615,7 @@ impl ExtensionsMut for Socks5TcpConnection { fn extensions_mut(&mut self) -> &mut Extensions { match self { Self::Direct(stream) => stream.extensions_mut(), + Self::Tunnel(stream) => stream.extensions_mut(), Self::Mitm { extensions, .. } | Self::DetectProtocol { extensions, .. } => extensions, } } @@ -589,13 +626,21 @@ async fn proxy_socks5_tcp( ) -> Result<(), BoxError> { let ProxyRequest { mut source, target } = request; match target { - Socks5TcpConnection::Direct(target) => StreamForwardService::default() - .serve(ProxyRequest { source, target }) - .await - .map_err(Into::into), + target @ (Socks5TcpConnection::Direct(_) | Socks5TcpConnection::Tunnel(_)) => { + StreamForwardService::default() + .serve(ProxyRequest { source, target }) + .await + .map_err(Into::into) + } Socks5TcpConnection::Mitm { - target, mode, mitm, .. + target, + mode, + mitm, + extensions, } => { + if let Some(policy) = extensions.get::() { + source.extensions_mut().insert(policy.clone()); + } source.extensions_mut().insert(ProxyTarget(target)); source.extensions_mut().insert(mode); source.extensions_mut().insert(mitm); @@ -609,8 +654,11 @@ async fn proxy_socks5_tcp( mode, mitm, state, - .. + extensions, } => { + if let Some(policy) = extensions.get::() { + source.extensions_mut().insert(policy.clone()); + } source.extensions_mut().insert(ProxyTarget(target.clone())); source.extensions_mut().insert(mode); source.extensions_mut().insert(mitm); @@ -638,10 +686,11 @@ async fn proxy_socks5_tcp( } info!("SOCKS opaque upstream dial started (target={target})"); let connect_started_at = Instant::now(); - let EstablishedClientConnection { conn: upstream, .. } = - TargetCheckedTcpConnector::new(state) - .serve(TcpRequest::new(target.clone())) - .await?; + let EstablishedClientConnection { conn: upstream, .. } = connect_opaque_tcp( + state, + TcpRequest::new_with_extensions(target.clone(), extensions), + ) + .await?; info!( "SOCKS opaque upstream dial established (target={target}, elapsed_ms={})", connect_started_at.elapsed().as_millis() @@ -927,10 +976,7 @@ mod tests { let (result, events) = capture_events(|| async { handle_socks5_tcp( - request, - TargetCheckedTcpConnector::new(state.clone()), - /*policy_decider*/ None, - /*environment_id*/ None, + request, /*policy_decider*/ None, /*environment_id*/ None, ) .await }) @@ -971,10 +1017,7 @@ mod tests { request.extensions_mut().insert(state.clone()); let result = handle_socks5_tcp( - request, - TargetCheckedTcpConnector::new(state), - /*policy_decider*/ None, - /*environment_id*/ None, + request, /*policy_decider*/ None, /*environment_id*/ None, ) .await .expect("limited-mode HTTPS should use MITM"); @@ -997,10 +1040,7 @@ mod tests { let (result, events) = capture_events(|| async { handle_socks5_tcp( - request, - TargetCheckedTcpConnector::new(state), - /*policy_decider*/ None, - /*environment_id*/ None, + request, /*policy_decider*/ None, /*environment_id*/ None, ) .await }) @@ -1048,10 +1088,7 @@ mod tests { request.extensions_mut().insert(state.clone()); let result = handle_socks5_tcp( - request, - TargetCheckedTcpConnector::new(state), - /*policy_decider*/ None, - /*environment_id*/ None, + request, /*policy_decider*/ None, /*environment_id*/ None, ) .await .expect("brokered TLS should defer MITM until protocol detection"); @@ -1076,10 +1113,7 @@ mod tests { request.extensions_mut().insert(state.clone()); let err = handle_socks5_tcp( - request, - TargetCheckedTcpConnector::new(state), - /*policy_decider*/ None, - /*environment_id*/ None, + request, /*policy_decider*/ None, /*environment_id*/ None, ) .await .expect_err("limited-mode HTTPS requires MITM"); @@ -1114,10 +1148,7 @@ mod tests { request.extensions_mut().insert(state.clone()); let result = handle_socks5_tcp( - request, - TargetCheckedTcpConnector::new(state), - /*policy_decider*/ None, - /*environment_id*/ None, + request, /*policy_decider*/ None, /*environment_id*/ None, ) .await .expect("hooked HTTPS should use MITM"); @@ -1149,10 +1180,7 @@ mod tests { request.extensions_mut().insert(state.clone()); let err = handle_socks5_tcp( - request, - TargetCheckedTcpConnector::new(state), - /*policy_decider*/ None, - /*environment_id*/ None, + request, /*policy_decider*/ None, /*environment_id*/ None, ) .await .expect_err("hooked non-HTTPS SOCKS should require MITM"); diff --git a/codex-rs/network-proxy/src/tcp_upstream.rs b/codex-rs/network-proxy/src/tcp_upstream.rs new file mode 100644 index 0000000000..d2407781cf --- /dev/null +++ b/codex-rs/network-proxy/src/tcp_upstream.rs @@ -0,0 +1,133 @@ +//! Mandatory next hop for opaque SOCKS TCP on executors provisioned with an HTTP CONNECT proxy. +//! Only the executor selects the endpoint. The sandbox supplies the already-authorized target. + +use crate::connect_policy::TargetCheckedTcpConnector; +use crate::policy::is_non_public_ip; +use crate::policy::is_private_network_ip; +use crate::policy::normalize_host; +use crate::policy::numeric_host_address; +use crate::state::NetworkProxyState; +use anyhow::ensure; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::extensions::ExtensionsRef; +use rama_core::stream::rewind::Rewind; +use rama_http::Body; +use rama_http::Method; +use rama_http::Request; +use rama_http::Version; +use rama_http::header::HOST; +use rama_http::io::upgrade; +use rama_http_backend::client::HttpConnector; +use rama_net::Protocol; +use rama_net::address::HostWithPort; +use rama_net::address::ProxyAddress; +use rama_net::client::EstablishedClientConnection; +use rama_net::stream::ClientSocketInfo; +use rama_tcp::TcpStream; +use std::pin::Pin; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +/// Trusted plaintext HTTP CONNECT listener, expressed as a hostname or IP and an explicit port. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TcpUpstreamProxy(HostWithPort); + +impl FromStr for TcpUpstreamProxy { + type Err = anyhow::Error; + + fn from_str(raw: &str) -> Result { + let endpoint: HostWithPort = raw.parse()?; + ensure!( + endpoint.port != 0, + "TCP upstream proxy port must be nonzero" + ); + Ok(Self(endpoint)) + } +} + +impl TcpUpstreamProxy { + pub(crate) async fn connect( + &self, + state: &Arc, + target: &HostWithPort, + authorization: crate::runtime::HostAuthorization, + ) -> Result, BoxError> { + if !state.allow_upstream_proxy().await? { + return Err("upstream proxies are disabled by network policy".into()); + } + + let host = normalize_host(&target.host.to_string()); + let target_ip = numeric_host_address(&host); + let same_port = target.port == self.0.port; + if same_port && host == normalize_host(&self.0.host.to_string()) { + return Err("cannot tunnel back into the TCP upstream proxy".into()); + } + let authority = if let Some(ip) = target_ip { + if state + .resolved_target_blocked(&host, ip, authorization) + .await? + || (is_non_public_ip(ip) + && !(state.proxy_private_ips_via_upstream && is_private_network_ip(ip))) + { + return Err("IP destination cannot use the TCP upstream proxy".into()); + } + HostWithPort::new(ip.into(), target.port).to_string() + } else { + if host == "localhost" + || host.ends_with(".localhost") + || !state.permits_remote_tcp_dns().await? + { + return Err("destination requires local address enforcement".into()); + } + target.to_string() + }; + let request = Request::builder() + .method(Method::CONNECT) + .version(Version::HTTP_11) + .uri(&authority) + .header(HOST, &authority) + .extension(ProxyAddress { + protocol: Some(Protocol::HTTP), + address: self.0.clone(), + credential: None, + }) + .body(Body::empty())?; + // A fresh request carries only the trusted next hop. Rama dials that endpoint and + // retains bytes read past the CONNECT headers in the upgraded stream. + tokio::time::timeout(Duration::from_secs(10), async { + let connector = HttpConnector::new(TargetCheckedTcpConnector::new(state.clone())); + let EstablishedClientConnection { input, conn } = connector.serve(request).await?; + // Also reject the proxy's actual IP when provisioning supplied a hostname. + if same_port && let Some(ip) = target_ip { + let peer = conn + .extensions() + .get::() + .ok_or("missing TCP upstream peer address")? + .0 + .peer_addr(); + if ip.to_canonical() == peer.ip().to_canonical() { + return Err("cannot tunnel back into the TCP upstream proxy".into()); + } + } + let response = conn.serve(input).await?; + if !response.status().is_success() { + return Err(format!("TCP upstream refused CONNECT ({})", response.status()).into()); + } + let stream = upgrade::handle_upgrade(response).await?; + let parts = stream + .downcast::>>() + .map_err(|_| "unexpected transport for HTTP CONNECT")?; + Ok(Rewind::new_buffered( + *Pin::into_inner(parts.io), + parts.read_buf, + )) + }) + .await? + } +} + +#[cfg(test)] +#[path = "tcp_upstream_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/tcp_upstream_tests.rs b/codex-rs/network-proxy/src/tcp_upstream_tests.rs new file mode 100644 index 0000000000..5fd1cc9db8 --- /dev/null +++ b/codex-rs/network-proxy/src/tcp_upstream_tests.rs @@ -0,0 +1,126 @@ +#![cfg(test)] + +use crate::NetworkMode; +use crate::NetworkProxyConfig; +use crate::socks5::socks5_proxy_service; +use crate::state::network_proxy_state_for_policy; +use pretty_assertions::assert_eq; +use rama_core::Service; +use std::io::ErrorKind; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; + +#[tokio::test] +async fn socks_tcp_waits_for_http_connect_and_preserves_the_tunnel() { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let ingress = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut config = NetworkProxyConfig { + mode: NetworkMode::Full, + allow_local_binding: Some(true), + allow_upstream_proxy: true, + ..Default::default() + }; + config.set_allowed_domains(vec!["db.invalid".into()]); + let mut state = network_proxy_state_for_policy(config); + state.set_tcp_upstream_proxy(upstream.local_addr().unwrap().to_string().parse().unwrap()); + let service = socks5_proxy_service( + Arc::new(state), + /*policy_decider*/ None, + /*environment_id*/ None, + /*enable_socks5_udp*/ false, + ); + + for success in [false, true] { + let exercise = async { + let mut client = TcpStream::connect(ingress.local_addr().unwrap()) + .await + .unwrap(); + let (socket, _) = ingress.accept().await.unwrap(); + let peer = async { + let (stream, _) = upstream.accept().await.unwrap(); + let mut proxy = BufReader::new(stream); + let mut line = String::new(); + proxy.read_line(&mut line).await.unwrap(); + assert_eq!(line, "CONNECT db.invalid:5432 HTTP/1.1\r\n"); + loop { + line.clear(); + assert_ne!(proxy.read_line(&mut line).await.unwrap(), 0); + if line == "\r\n" { + break; + } + } + assert!(proxy.buffer().is_empty()); + // Exercise read-ahead and a non-200 success; no payload may escape on refusal. + let response = if success { + b"HTTP/1.1 201 Created\r\n\r\nhello".as_slice() + } else { + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n" + }; + proxy.get_mut().write_all(response).await.unwrap(); + let mut received = Vec::new(); + proxy.read_to_end(&mut received).await.unwrap(); + assert_eq!(received, if success { b"private".as_slice() } else { &[] }); + }; + let caller = async { + client.write_all(&[5, 1, 0]).await.unwrap(); + let mut greeting = [0; 2]; + client.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [5, 0]); + // Pipeline the payload with CONNECT, before the upstream has replied. + client + .write_all(b"\x05\x01\x00\x03\x0adb.invalid\x15\x38private") + .await + .unwrap(); + let mut reply = [0; 10]; + match client.read_exact(&mut reply).await { + Ok(_) => assert_eq!(reply[1] == 0, success), + // Closing a socket with unread pipelined data can reset it on Windows. + Err(err) if !success => assert_eq!(err.kind(), ErrorKind::ConnectionReset), + Err(err) => panic!("missing SOCKS reply: {err}"), + } + if success { + client.shutdown().await.unwrap(); + let mut bytes = Vec::new(); + client.read_to_end(&mut bytes).await.unwrap(); + assert_eq!(bytes, b"hello"); + } + }; + let (result, (), ()) = tokio::join!(service.serve(socket.into()), peer, caller); + assert_eq!(result.is_ok(), success); + }; + tokio::time::timeout(Duration::from_secs(5), exercise) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn remote_dns_requires_policy_independent_of_the_resolved_ip() { + for (allow_local, deny, allow_remote_dns) in [ + (false, "denied.example", false), + (true, "denied.example", true), + (true, "0177.0.0.1", false), + (true, "10.*", false), + (true, "*.de", true), + (true, "*.10", false), + (true, "[::ffff:192.0.2.1]", false), + ] { + let mut config = NetworkProxyConfig { + allow_local_binding: Some(allow_local), + ..Default::default() + }; + config.set_denied_domains(vec![deny.into()]); + let state = network_proxy_state_for_policy(config); + assert_eq!( + state.permits_remote_tcp_dns().await.unwrap(), + allow_remote_dns, + "{deny}" + ); + } +}