Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions codex-rs/network-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ For HTTP(S) traffic:
```bash
export HTTP_PROXY="http://127.0.0.1:3128"
export HTTPS_PROXY="http://127.0.0.1:3128"
export WS_PROXY="http://127.0.0.1:3128"
export WSS_PROXY="http://127.0.0.1:3128"
```

For SOCKS5 traffic (when `enable_socks5 = true`):
Expand All @@ -83,6 +85,9 @@ When a request is blocked, the proxy responds with `403` and includes:
In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed. HTTPS `CONNECT` and SOCKS5 are
blocked because they would bypass method enforcement.

Websocket clients typically tunnel `wss://` through HTTPS `CONNECT`; those CONNECT targets still go
through the same host allowlist/denylist checks.

## Library API

`codex-network-proxy` can be embedded as a library with a thin API:
Expand Down
45 changes: 45 additions & 0 deletions codex-rs/network-proxy/src/http_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,51 @@ mod tests {
);
}

#[tokio::test]
async fn http_connect_accept_allows_allowlisted_host_in_full_mode() {
let policy = NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
..Default::default()
};
let state = Arc::new(network_proxy_state_for_policy(policy));

let mut req = Request::builder()
.method(Method::CONNECT)
.uri("https://example.com:443")
.header("host", "example.com:443")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(state);

let (response, _request) = http_connect_accept(None, req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn http_connect_accept_denies_denylisted_host() {
let policy = NetworkProxySettings {
allowed_domains: vec!["**.openai.com".to_string()],
denied_domains: vec!["api.openai.com".to_string()],
..Default::default()
};
let state = Arc::new(network_proxy_state_for_policy(policy));

let mut req = Request::builder()
.method(Method::CONNECT)
.uri("https://api.openai.com:443")
.header("host", "api.openai.com:443")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(state);

let response = http_connect_accept(None, req).await.unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-denylist"
);
}

#[test]
fn request_network_attempt_id_reads_proxy_authorization_header() {
let encoded = STANDARD.encode("codex-net-attempt-attempt-1:");
Expand Down
30 changes: 30 additions & 0 deletions codex-rs/network-proxy/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ impl Eq for NetworkProxy {}
pub const PROXY_URL_ENV_KEYS: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
"WS_PROXY",
"WSS_PROXY",
"ALL_PROXY",
"FTP_PROXY",
"YARN_HTTP_PROXY",
Expand All @@ -269,6 +271,7 @@ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"];
pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";

const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"];

pub const NO_PROXY_ENV_KEYS: &[&str] = &[
"NO_PROXY",
Expand Down Expand Up @@ -354,6 +357,9 @@ fn apply_proxy_env_overrides(
],
&http_proxy_url,
);
// Some websocket clients look for dedicated WS/WSS proxy environment variables instead of
// HTTP(S)_PROXY. Keep them aligned with the managed HTTP proxy endpoint.
set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);

// Keep local/private targets direct so local IPC and metadata endpoints avoid the proxy.
set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
Expand Down Expand Up @@ -714,6 +720,14 @@ mod tests {
assert_eq!(has_proxy_url_env_vars(&env), true);
}

#[test]
fn has_proxy_url_env_vars_detects_websocket_proxy_keys() {
let mut env = HashMap::new();
env.insert("wss_proxy".to_string(), "http://127.0.0.1:3128".to_string());

assert_eq!(has_proxy_url_env_vars(&env), true);
}

#[test]
fn apply_proxy_env_overrides_sets_common_tool_vars() {
let mut env = HashMap::new();
Expand All @@ -730,6 +744,14 @@ mod tests {
env.get("HTTP_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WS_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WSS_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("npm_config_proxy"),
Some(&"http://127.0.0.1:3128".to_string())
Expand Down Expand Up @@ -796,6 +818,14 @@ mod tests {
env.get("HTTPS_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WS_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WSS_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("ALL_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
Expand Down
Loading