mirror of
https://github.com/openai/codex.git
synced 2026-05-24 13:04:29 +00:00
## Stack 1. Parent PR: #18868 adds MITM hook config and model only. 2. Parent PR: #20659 wires hook enforcement into the proxy request path. 3. This PR changes the user facing PermissionProfile TOML shape. ## Why 1. The broader goal is to make MITM clamping usable from the same permission profile that already controls network behavior. 2. This PR is the config UX layer for the stack. It moves MITM policy into `[permissions.<profile>.network.mitm]` instead of exposing the flat runtime shape to users. 3. The named hook and action tables belong here because users need reusable policy blocks that are easy to review, while the proxy runtime only needs a flat hook list. 4. This PR validates action refs during config parsing so mistakes in the user facing policy fail before a proxy session starts. 5. Keeping the lowering here lets the proxy keep its simpler runtime model and lets PermissionProfile remain the single source of network permission policy. ## Summary 1. Keep MITM policy inside `[permissions.<profile>.network.mitm]` so the selected PermissionProfile owns network proxy policy. 2. Use named MITM hooks under `[permissions.<profile>.network.mitm.hooks.<name>]`. 3. Put host, methods, path prefixes, query, headers, body, and action refs on the hook table. 4. Define reusable action blocks under `[permissions.<profile>.network.mitm.actions.<name>]`. 5. Represent action blocks with `NetworkMitmActionToml`, then lower them into the proxy runtime action config. 6. Reject unknown refs, empty refs, and empty action blocks during config parsing. 7. Keep the runtime hook model unchanged by lowering config into the existing proxy hook list. 8. Preserve the #20659 activation fix for nested MITM policy. ## Example ```toml [permissions.workspace.network.mitm] enabled = true [permissions.workspace.network.mitm.hooks.github_write] host = "api.github.com" methods = ["POST", "PUT"] path_prefixes = ["/repos/openai/"] action = ["strip_auth"] [permissions.workspace.network.mitm.actions.strip_auth] strip_request_headers = ["authorization"] ``` ## Validation 1. Regenerated the config schema. 2. Ran the core MITM config parsing and validation tests. 3. Ran the core PermissionProfile MITM proxy activation tests. 4. Ran the core config schema fixture test. 5. Ran the network proxy MITM policy tests. 6. Ran the scoped Clippy fixer for the network proxy crate. 7. Ran the scoped Clippy fixer for the core crate. --------- Co-authored-by: Winston Howes <winston@openai.com>
321 lines
10 KiB
Rust
321 lines
10 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_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 tempfile::NamedTempFile;
|
|
|
|
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"))
|
|
);
|
|
}
|