Files
codex/codex-rs/network-proxy/src/mitm_tests.rs
Winston Howes 989f55defa feat(network-proxy): experimental local credential broker (#28034)
## Why

Codex child processes can inherit injectable local credentials directly,
which lets commands read and exfiltrate the real values. This
experimental slice keeps supported workflows working while moving those
credentials behind the managed network proxy.

This PR contains only the proxy-owned broker implementation. The Codex
config and runtime integration is stacked separately in #29752.

## What changed

- discover supported credentials during child setup, retain real values
only in the in-memory proxy broker, and replace them with shaped dummy
values
- require a presented dummy to select a stored credential and preserve
unrelated explicit authorization headers
- bind GitHub cloud, GitHub Enterprise, and OpenAI credentials to their
intended hosts
- inject credentials only into TLS traffic by default; plaintext
injection requires the explicit dangerous opt-in
- use TLS ClientHello routing for CONNECT so non-TLS protocols remain
opaque tunnels
- expose a pure API that identifies environment keys still holding
broker-generated dummies without mutating the caller's environment

## Scope

- supported credentials: `GH_TOKEN`, `GITHUB_TOKEN`,
`GH_ENTERPRISE_TOKEN`, `GITHUB_ENTERPRISE_TOKEN`, and `OPENAI_API_KEY`
- GitHub cloud credentials match `github.com`, `api.github.com`, and
`*.ghe.com`
- GitHub Enterprise credentials match only the normalized non-cloud
`GH_HOST`
- OpenAI API keys match only `api.openai.com`
- this does not cover SSH agents, kube client certificates, filesystem
secret discovery, or context-injected secret scrubbing

## Validation

- `just test -p codex-network-proxy` (191 passed)
- focused opaque CONNECT, plaintext opt-in, dummy-selection, and
child-isolation regressions passed
- scoped Clippy check for `codex-network-proxy` passed

---------

Co-authored-by: viyatb-oai <viyatb@openai.com>
Co-authored-by: Codex <noreply@openai.com>
2026-06-24 13:21:16 -07:00

407 lines
13 KiB
Rust

use super::*;
use crate::config::NetworkProxySettings;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_HOOK_DENIED;
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
use crate::runtime::network_proxy_state_for_policy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rama_core::extensions::Extensions;
use rama_core::extensions::ExtensionsMut;
use rama_core::extensions::ExtensionsRef;
use rama_http::Body;
use rama_http::HeaderMap;
use rama_http::HeaderValue;
use rama_http::Method;
use rama_http::Request;
use rama_http::StatusCode;
use rama_http::header::HeaderName;
use std::pin::Pin;
use std::task::Context;
use std::task::Poll;
use tempfile::NamedTempFile;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWrite;
use tokio::io::AsyncWriteExt;
use tokio::io::DuplexStream;
use tokio::io::ReadBuf;
use tokio::time::Duration;
struct TestStream {
inner: DuplexStream,
extensions: Extensions,
}
impl TestStream {
fn new(inner: DuplexStream) -> Self {
Self {
inner,
extensions: Extensions::new(),
}
}
}
impl AsyncRead for TestStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
}
}
impl AsyncWrite for TestStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
}
}
impl ExtensionsRef for TestStream {
fn extensions(&self) -> &Extensions {
&self.extensions
}
}
impl ExtensionsMut for TestStream {
fn extensions_mut(&mut self) -> &mut Extensions {
&mut self.extensions
}
}
#[tokio::test]
async fn tls_prefix_detection_accumulates_fragmented_reads() {
let tls_prefix = [0x16, 0x03, 0x03, 0x00, 0x80];
let (mut writer, reader) = tokio::io::duplex(16);
let writer_task = tokio::spawn(async move {
writer.write_all(&tls_prefix[..1]).await.unwrap();
tokio::time::sleep(TLS_PREFIX_FIRST_BYTE_TIMEOUT + Duration::from_millis(50)).await;
writer.write_all(&tls_prefix[1..]).await.unwrap();
});
let (is_tls, mut stream) = peek_tls_prefix(TestStream::new(reader)).await.unwrap();
let mut replayed = [0_u8; 5];
stream.read_exact(&mut replayed).await.unwrap();
assert!(is_tls);
assert_eq!(replayed, tls_prefix);
writer_task.await.unwrap();
}
fn github_write_hook() -> crate::mitm_hook::MitmHookConfig {
crate::mitm_hook::MitmHookConfig {
host: "api.github.com".to_string(),
matcher: crate::mitm_hook::MitmHookMatchConfig {
methods: vec!["POST".to_string(), "PUT".to_string()],
path_prefixes: vec!["/repos/openai/".to_string()],
..crate::mitm_hook::MitmHookMatchConfig::default()
},
actions: crate::mitm_hook::MitmHookActionsConfig {
strip_request_headers: vec!["authorization".to_string()],
inject_request_headers: vec![crate::mitm_hook::InjectedHeaderConfig {
name: "authorization".to_string(),
secret_env_var: Some("CODEX_GITHUB_TOKEN".to_string()),
secret_file: None,
prefix: Some("Bearer ".to_string()),
}],
},
}
}
fn policy_ctx(
app_state: Arc<NetworkProxyState>,
mode: NetworkMode,
target_host: &str,
target_port: u16,
) -> MitmPolicyContext {
MitmPolicyContext {
target_host: target_host.to_string(),
target_port,
mode,
app_state,
}
}
#[tokio::test]
async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() {
let app_state = Arc::new(network_proxy_state_for_policy({
let mut network = NetworkProxySettings::default();
network.set_allowed_domains(vec!["example.com".to_string()]);
network
}));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Limited,
"example.com",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::POST)
.uri("/v1/responses?api_key=secret")
.header(HOST, "example.com")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("POST should be blocked in limited mode");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-method-policy"
);
let blocked = app_state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_METHOD_NOT_ALLOWED);
assert_eq!(blocked[0].method.as_deref(), Some("POST"));
assert_eq!(blocked[0].host, "example.com");
assert_eq!(blocked[0].port, Some(443));
}
#[tokio::test]
async fn mitm_policy_rejects_host_mismatch() {
let app_state = Arc::new(network_proxy_state_for_policy({
let mut network = NetworkProxySettings::default();
network.set_allowed_domains(vec!["example.com".to_string()]);
network
}));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Full,
"example.com",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::GET)
.uri("/")
.header(HOST, "evil.example")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("mismatched host should be rejected");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0);
}
#[tokio::test]
async fn mitm_policy_rechecks_local_private_target_after_connect() {
let app_state = Arc::new(network_proxy_state_for_policy({
let mut network = NetworkProxySettings::default();
network.set_allowed_domains(vec!["example.com".to_string()]);
network.allow_local_binding = false;
network
}));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Full,
"10.0.0.1",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::GET)
.uri("/health?token=secret")
.header(HOST, "10.0.0.1")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("local/private target should be blocked on inner request");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let blocked = app_state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_NOT_ALLOWED_LOCAL);
assert_eq!(blocked[0].host, "10.0.0.1");
assert_eq!(blocked[0].port, Some(443));
}
#[tokio::test]
async fn mitm_policy_allows_matching_hooked_write_in_full_mode() {
let secret_file = NamedTempFile::new().unwrap();
std::fs::write(secret_file.path(), "ghp-secret\n").unwrap();
let mut hook = github_write_hook();
hook.actions.inject_request_headers[0].secret_env_var = None;
hook.actions.inject_request_headers[0].secret_file =
Some(secret_file.path().display().to_string());
let mut network = NetworkProxySettings {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Full,
..NetworkProxySettings::default()
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
let app_state = Arc::new(network_proxy_state_for_policy(network));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Full,
"api.github.com",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::POST)
.uri("/repos/openai/codex/issues")
.header(HOST, "api.github.com")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx).await.unwrap();
assert!(
response.is_none(),
"matching hook should bypass method clamp"
);
assert_eq!(app_state.blocked_snapshot().await.unwrap().len(), 0);
}
#[tokio::test]
async fn mitm_policy_blocks_matching_hooked_write_in_limited_mode() {
let mut hook = github_write_hook();
hook.actions.inject_request_headers.clear();
let mut network = NetworkProxySettings {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Limited,
..NetworkProxySettings::default()
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
let app_state = Arc::new(network_proxy_state_for_policy(network));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Limited,
"api.github.com",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::POST)
.uri("/repos/openai/codex/issues")
.header(HOST, "api.github.com")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("matching POST hook should still be blocked in limited mode");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-method-policy"
);
let blocked = app_state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_METHOD_NOT_ALLOWED);
assert_eq!(blocked[0].method.as_deref(), Some("POST"));
assert_eq!(blocked[0].host, "api.github.com");
assert_eq!(blocked[0].port, Some(443));
}
#[tokio::test]
async fn mitm_policy_blocks_hook_miss_for_hooked_host_and_records_telemetry_in_full_mode() {
let secret_file = NamedTempFile::new().unwrap();
std::fs::write(secret_file.path(), "ghp-secret\n").unwrap();
let mut hook = github_write_hook();
hook.actions.inject_request_headers[0].secret_env_var = None;
hook.actions.inject_request_headers[0].secret_file =
Some(secret_file.path().display().to_string());
let mut network = NetworkProxySettings {
mitm: true,
mitm_hooks: vec![hook],
mode: NetworkMode::Full,
..NetworkProxySettings::default()
};
network.set_allowed_domains(vec!["api.github.com".to_string()]);
let app_state = Arc::new(network_proxy_state_for_policy(network));
let ctx = policy_ctx(
app_state.clone(),
NetworkMode::Full,
"api.github.com",
/*target_port*/ 443,
);
let req = Request::builder()
.method(Method::GET)
.uri("/repos/openai/codex/issues?token=secret")
.header(HOST, "api.github.com")
.header("authorization", "Bearer user-supplied")
.body(Body::empty())
.unwrap();
let response = mitm_blocking_response(&req, &ctx)
.await
.unwrap()
.expect("hook miss should be blocked");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-mitm-hook"
);
let blocked = app_state.drain_blocked().await.unwrap();
assert_eq!(blocked.len(), 1);
assert_eq!(blocked[0].reason, REASON_MITM_HOOK_DENIED);
assert_eq!(blocked[0].method.as_deref(), Some("GET"));
assert_eq!(blocked[0].host, "api.github.com");
assert_eq!(blocked[0].port, Some(443));
}
#[test]
fn apply_mitm_hook_actions_replaces_authorization_header() {
let mut headers = HeaderMap::new();
headers.append(
HeaderName::from_static("authorization"),
HeaderValue::from_static("Bearer user-supplied"),
);
headers.append(
HeaderName::from_static("x-request-id"),
HeaderValue::from_static("req_123"),
);
let actions = crate::mitm_hook::MitmHookActions {
strip_request_headers: vec![HeaderName::from_static("authorization")],
inject_request_headers: vec![crate::mitm_hook::ResolvedInjectedHeader {
name: HeaderName::from_static("authorization"),
value: HeaderValue::from_static("Bearer secret-token"),
source: crate::mitm_hook::SecretSource::File(
AbsolutePathBuf::try_from("/tmp/github-token").unwrap(),
),
}],
};
apply_mitm_hook_actions(&mut headers, Some(&actions));
assert_eq!(
headers.get("authorization"),
Some(&HeaderValue::from_static("Bearer secret-token"))
);
assert_eq!(
headers.get("x-request-id"),
Some(&HeaderValue::from_static("req_123"))
);
}