From 15fbcac045c29f77783bb176c20f54102f97fe32 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 23 Oct 2026 18:14:39 +0000 Subject: [PATCH] Enable masked AWS credentials for local proxy signing (#56554) ## What changed - Expose `aws_sigv4` actions in version 2 MITM permissions configuration and the config schema, rejecting multiple signing actions per hook. - Give local managed commands placeholder AWS credentials for the selected signing pair, and mask configured credentials in environment values and protected shell snapshots. - Set `AWS_CA_BUNDLE` to the managed proxy CA bundle when unset, and require `features.credential_masking` for local AWS signing hooks. - Validate placeholders against every configured signing pair to prevent overlap with real credentials. ## Testing Extend configuration, signing, and TLS forwarding tests to cover action resolution, invalid signing configuration, credential masking, CA trust, snapshot protection, and exclusion of local AWS defaults from remote environments. GitOrigin-RevId: 81dfdea30ebde74829882b38891ea41e9737058a --- codex-rs/config/src/permissions_toml.rs | 25 ++- codex-rs/config/src/permissions_toml_tests.rs | 55 +++++-- codex-rs/core/config.schema.json | 82 ++++++++++ codex-rs/core/src/config/config_tests.rs | 2 + .../core/src/config/network_proxy_spec.rs | 13 ++ codex-rs/network-proxy/README.md | 46 +++++- codex-rs/network-proxy/src/aws_sigv4.rs | 143 ++++++++++++++++-- codex-rs/network-proxy/src/aws_sigv4_tests.rs | 38 ++++- codex-rs/network-proxy/src/certs.rs | 9 ++ .../network-proxy/src/credential_broker.rs | 4 +- .../src/credential_broker/environment.rs | 3 + .../src/mitm_forwarding_tests.rs | 29 ++++ codex-rs/network-proxy/src/mitm_hook.rs | 6 + codex-rs/network-proxy/src/proxy.rs | 12 +- codex-rs/network-proxy/src/runtime.rs | 53 ++++++- 15 files changed, 480 insertions(+), 40 deletions(-) diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index dd558495ce..00446f0419 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -397,6 +397,7 @@ enum NetworkModeSchema { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(default, deny_unknown_fields)] pub struct NetworkMitmActionToml { + pub aws_sigv4: Option, pub strip_request_headers: Vec, pub inject_request_headers: Vec, } @@ -445,7 +446,7 @@ impl NetworkMitmToml { .inject_request_headers .iter() .any(|header| header.placeholder.is_some()); - if self.version.is_none() && uses_placeholder { + if self.version.is_none() && (uses_placeholder || action.aws_sigv4.is_some()) { return Err("scoped MITM actions require network.mitm.version = 2".to_string()); } if action.is_empty() { @@ -461,6 +462,21 @@ impl NetworkMitmToml { }; for (hook_name, hook) in hooks { + let signing_count = hook + .action + .iter() + .filter(|name| { + self.actions + .as_ref() + .and_then(|actions| actions.get(*name)) + .is_some_and(|action| action.aws_sigv4.is_some()) + }) + .count(); + if signing_count > 1 { + return Err(format!( + "network.mitm.hooks.{hook_name} must select at most one AWS signing action" + )); + } if self.version.is_none() && (!hook.schemes.is_empty() || !hook.ports.is_empty()) { return Err("scoped MITM hooks require network.mitm.version = 2".to_string()); } @@ -492,7 +508,9 @@ impl NetworkMitmToml { impl NetworkMitmActionToml { pub fn is_empty(&self) -> bool { - self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty() + self.aws_sigv4.is_none() + && self.strip_request_headers.is_empty() + && self.inject_request_headers.is_empty() } } @@ -590,6 +608,9 @@ impl NetworkMitmHookToml { let mut selected = MitmHookActionsConfig::default(); for action_name in &self.action { if let Some(action) = actions_by_name.get(action_name) { + if action.aws_sigv4.is_some() { + selected.aws_sigv4 = action.aws_sigv4.clone(); + } selected .strip_request_headers .extend(action.strip_request_headers.clone()); diff --git a/codex-rs/config/src/permissions_toml_tests.rs b/codex-rs/config/src/permissions_toml_tests.rs index 6a7bfb5fec..f3149d4ed8 100644 --- a/codex-rs/config/src/permissions_toml_tests.rs +++ b/codex-rs/config/src/permissions_toml_tests.rs @@ -1,10 +1,19 @@ -//! Trusted network extensions survive TOML resolution with origin-scoped header placeholders. +//! Trusted network extensions survive TOML resolution without ambiguous signing authority. use super::*; +use codex_network_proxy::ProxySecretSource; use pretty_assertions::assert_eq; -const HEADER_ACTION: &str = r#" +const SIGNING: &str = r#" version = 2 +[actions.aws.aws_sigv4] +placeholder_access_key_id = "MASKEDACCESSKEY" +region = "us-east-1" +service = "s3" +access_key_id = { env_var = "PROXY_ACCESS_KEY" } +secret_access_key = { file = "/trusted/aws-secret" } +session_token = { env_var = "PROXY_SESSION_TOKEN" } + [[actions.substitute.inject_request_headers]] name = "x-api-key" secret_env_var = "PROXY_API_KEY" @@ -16,15 +25,21 @@ schemes = ["https"] ports = [443] methods = ["PUT"] path_prefixes = ["/workspace/"] -action = ["substitute"] +action = ["substitute", "aws"] "#; #[test] -fn resolves_origin_scoped_header_substitution_from_toml() { - let config: NetworkMitmToml = toml::from_str(HEADER_ACTION).unwrap(); +fn resolves_native_signing_and_substitution_actions_from_toml() { + let config: NetworkMitmToml = toml::from_str(SIGNING).unwrap(); let hooks = config.to_runtime_hooks(config.actions.as_ref()); assert_eq!(hooks[0].matcher.schemes, vec!["https"]); assert_eq!(hooks[0].matcher.ports, vec![443]); + let signer = hooks[0].actions.aws_sigv4.as_ref().unwrap(); + assert_eq!( + signer.access_key_id, + ProxySecretSource::EnvVar("PROXY_ACCESS_KEY".into()) + ); + assert_eq!(signer.port, 443); assert_eq!( hooks[0].actions.inject_request_headers[0] .placeholder @@ -47,18 +62,20 @@ fn scoped_rules_reject_rollback_to_the_legacy_format() { _actions: toml::Value, } - let unversioned = HEADER_ACTION.replacen("version = 2\n", "", 1); + let unversioned = SIGNING.replacen("version = 2\n", "", 1); assert!(toml::from_str::(&unversioned).is_err()); assert!(toml::from_str::(&unversioned).is_ok()); - assert!(toml::from_str::(HEADER_ACTION).is_err()); + assert!(toml::from_str::(SIGNING).is_err()); } #[test] fn profile_and_managed_requirements_reject_misspelled_mitm_matchers() { - let profile = HEADER_ACTION - .replace("[[actions.", "[[permissions.test.network.mitm.actions.") + let profile = SIGNING + .replace("[actions.", "[permissions.test.network.mitm.actions.") .replace("[hooks.", "[permissions.test.network.mitm.hooks."); let profile = format!("[permissions.test.network.mitm]\n{profile}"); + toml::from_str::(&profile).unwrap(); + toml::from_str::(&profile).unwrap(); for (key, value) in [ ("header", r#"{ x-tenant = ["allowed"] }"#), ("port", "[443]"), @@ -80,7 +97,7 @@ fn profile_and_managed_requirements_reject_misspelled_mitm_matchers() { #[test] fn rejects_misspelled_header_placeholder() { - let input = HEADER_ACTION.replacen("placeholder =", "unknown_placeholder =", 1); + let input = SIGNING.replacen("placeholder =", "unknown_placeholder =", 1); let error = toml::from_str::(&input).unwrap_err(); assert!( error @@ -88,3 +105,21 @@ fn rejects_misspelled_header_placeholder() { .contains("unknown field `unknown_placeholder`") ); } + +#[test] +fn rejects_multiple_signing_actions_and_unknown_signing_parameters() { + assert!( + toml::from_str::(&SIGNING.replace( + "action = [\"substitute\", \"aws\"]", + "action = [\"aws\", \"aws\"]" + )) + .is_err() + ); + assert!( + toml::from_str::(&SIGNING.replace( + "region = \"us-east-1\"", + "region = \"us-east-1\"\nallow_unsigned_payload = true" + )) + .is_err() + ); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 811de91311..b4bb88331b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -521,6 +521,51 @@ ], "type": "object" }, + "AwsSigV4Config": { + "additionalProperties": false, + "description": "AWS SigV4 action selected by an exact-host, method, and path MITM hook.", + "properties": { + "access_key_id": { + "$ref": "#/definitions/ProxySecretSource" + }, + "max_body_bytes": { + "default": 8388608, + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "placeholder_access_key_id": { + "description": "Nonsecret access key ID installed in the guest AWS profile.", + "type": "string" + }, + "port": { + "default": 443, + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "region": { + "type": "string" + }, + "secret_access_key": { + "$ref": "#/definitions/ProxySecretSource" + }, + "service": { + "type": "string" + }, + "session_token": { + "$ref": "#/definitions/ProxySecretSource" + } + }, + "required": [ + "access_key_id", + "placeholder_access_key_id", + "region", + "secret_access_key", + "service" + ], + "type": "object" + }, "BrowserUseConfigToml": { "additionalProperties": false, "properties": { @@ -3155,6 +3200,14 @@ "NetworkMitmActionToml": { "additionalProperties": false, "properties": { + "aws_sigv4": { + "allOf": [ + { + "$ref": "#/definitions/AwsSigV4Config" + } + ], + "default": null + }, "inject_request_headers": { "default": [], "items": { @@ -3960,6 +4013,35 @@ }, "type": "object" }, + "ProxySecretSource": { + "description": "A credential source in the trusted proxy process, never in the executor environment.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "env_var": { + "type": "string" + } + }, + "required": [ + "env_var" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "file": { + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + } + ] + }, "RawMcpServerConfig": { "additionalProperties": false, "description": "Raw MCP config shape used for deserialization and supported-field JSON Schema generation.\n\nFields that are accepted only to produce targeted validation errors should be skipped in the generated schema.\n\nKeep `TryFrom for McpServerConfig` exhaustively destructuring this struct so new TOML fields cannot be added here without updating the validation/mapping logic that produces [`McpServerConfig`].", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index d253342031..674101920a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1683,6 +1683,7 @@ strip_request_headers = ["authorization"] actions: Some(IndexMap::from([( "strip_auth".to_string(), NetworkMitmActionToml { + aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: Vec::new(), }, @@ -1765,6 +1766,7 @@ fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() { actions: Some(IndexMap::from([( "strip_auth".to_string(), NetworkMitmActionToml { + aws_sigv4: None, strip_request_headers: vec!["authorization".to_string()], inject_request_headers: Vec::new(), }, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 44e6087a9b..3d4591c1e4 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -174,6 +174,19 @@ impl NetworkProxySpec { } else { (config, NetworkProxyConstraints::default()) }; + if config.enabled + && config.mitm + && !config.credential_broker + && config + .mitm_hooks + .iter() + .any(|hook| hook.actions.aws_sigv4.is_some()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "local AWS SigV4 hooks require features.credential_masking for protected shell snapshots", + )); + } validate_policy_against_constraints(&config, &constraints).map_err(|err| { std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 66d0dccf21..89a0271d7c 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -274,12 +274,42 @@ what it can reasonably guarantee. transport layer. If your threat model includes hostile DNS, enforce network egress at a lower layer too (e.g., firewall / VPC / corporate proxy policies). -### Native origin and exact secret substitution +### Native AWS signing and exact secret substitution -MITM hook `match.schemes` and `match.ports` constrain the trusted tunnel origin; -empty lists preserve existing behavior. An injected header can set `placeholder` -to require one exact whole-header value before substitution. Missing, mismatched, -or duplicate values fail closed. This form requires HTTPS port 443 and cannot -modify routing or framing headers. Secret sources remain trusted host references; -resolved values are marked sensitive in diagnostics and file sources are bounded -to 64 KiB. Ambiguous semicolon paths cannot authorize scoped credentials. +A trusted `aws_sigv4` action pairs a `placeholder_access_key_id` with a real +`access_key_id`, `secret_access_key`, and optional `session_token`. The latter +three reference files or environment variables in the proxy process. Resolve them +at startup and replace the proxy installation to rotate them. + +For local Codex, enable `credential_masking = true` under `[features]` in +`config.toml` and set `placeholder_access_key_id = "CODEXMASKEDWORKSPACE"` on the +proxy action. Local managed commands receive the dummy access key, secret and, +for temporary credentials, session token in their standard AWS environment. +An empty AWS environment selects a single configured pair. With multiple pairs, +set `AWS_ACCESS_KEY_ID` to the intended placeholder. An unrelated access key or +an explicit `AWS_PROFILE` keeps its own selection. Inherited copies of configured +credentials are masked in child environment values and protected shell snapshots. +File-backed credentials require filesystem permissions that prevent child access. +The AWS CLI or SDK signs with the dummy secret. After the exact +host, method and path hook authorizes the request, the proxy recognizes the +placeholder in the SigV4 credential scope and re-signs for the configured HTTPS +port (443 by default), region and service. The dummy ID selects the pair and is +visible to the workload. Unsigned requests and requests using other credentials +follow the ordinary network and hook policy with their original AWS identity. +A matched placeholder with an invalid scope or unsupported signature is denied. + +Re-signing preserves the client timestamp and signed headers and substitutes the +real session token when configured. The AWS SDK signs the forwarded bytes after +header mutations. Literal payloads use a 30-second deadline and an 8 MiB default +buffer limit (16 MiB maximum). An explicit `UNSIGNED-PAYLOAD` streams without +using the signing buffer. OpenSearch Serverless requires a literal payload hash. +Masked presigned URLs, SigV4a, AWS signed streams and declared trailers are denied. + +An injected header can additionally set `placeholder` to require one exact +whole-header value before substituting the secret. Missing, mismatched, or +duplicate values fail closed. This form requires HTTPS port 443 and cannot +modify routing or framing headers. Resolved secret headers and signing headers +are marked sensitive in diagnostics. +All native buffering stages share eight request slots, with a five-second bounded +wait. Each lease follows the prepared body until consumed or dropped; the 16 MiB +body cap bounds retained memory even when upstream connections are slow. diff --git a/codex-rs/network-proxy/src/aws_sigv4.rs b/codex-rs/network-proxy/src/aws_sigv4.rs index 717a3fccc5..d362b0030b 100644 --- a/codex-rs/network-proxy/src/aws_sigv4.rs +++ b/codex-rs/network-proxy/src/aws_sigv4.rs @@ -1,6 +1,9 @@ //! Trusted, destination-scoped AWS signing after network and HTTP hook authorization. //! Credentials never enter the workload; unsupported streaming signatures fail closed. +use crate::credential_broker::env_value; +use crate::credential_broker::remove_env_value; +use crate::credential_broker::set_env_value; use anyhow::Result; use anyhow::anyhow; use anyhow::ensure; @@ -29,6 +32,9 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use sha2::Digest as _; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; use std::time::Duration; use std::time::SystemTime; @@ -153,17 +159,6 @@ impl AwsSigV4Config { /*expires_after*/ None, "network-proxy", ); - ensure!( - [ - Some(credentials.access_key_id()), - Some(credentials.secret_access_key()), - credentials.session_token(), - ] - .into_iter() - .flatten() - .all(|value| !self.placeholder_access_key_id.contains(value)), - "AWS guest placeholders overlap a real credential" - ); Ok(AwsSigV4Signer { config: self.clone(), credentials, @@ -236,6 +231,132 @@ pub(crate) struct GuestSignature { unsigned_payload: bool, } +const DUMMY_SECRET: &str = "codex-local-aws-secret"; +const DUMMY_TOKEN: &str = "codex-local-aws-token"; +pub(crate) const AWS_CA_BUNDLE: &str = "AWS_CA_BUNDLE"; + +/// Every value exposed to a child must be safe with respect to every configured pair. +pub(crate) fn validate_guest_placeholders<'a>( + signers: impl Iterator + Clone, +) -> Result<()> { + for signer in signers.clone() { + let credentials = &signer.credentials; + let mut placeholders = [DUMMY_SECRET, DUMMY_TOKEN].into_iter().chain( + signers + .clone() + .map(|other| other.config.placeholder_access_key_id.as_str()), + ); + ensure!( + placeholders.all(|placeholder| { + [ + Some(credentials.access_key_id()), + Some(credentials.secret_access_key()), + credentials.session_token(), + ] + .into_iter() + .flatten() + .all(|real| !placeholder.contains(real)) + }), + "AWS guest placeholders overlap a real credential" + ); + } + Ok(()) +} + +pub(crate) fn snapshot_env_keys(env: &HashMap) -> Option<[&'static str; 5]> { + (env_value(env, "AWS_SECRET_ACCESS_KEY") == Some(DUMMY_SECRET) + || env_value(env, AWS_CA_BUNDLE) + .is_some_and(crate::certs::is_managed_mitm_ca_trust_bundle_path)) + .then_some([ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + AWS_CA_BUNDLE, + ]) +} + +/// Prepare the standard AWS environment for a local process before generic credential discovery. +pub(crate) fn mask_local_aws_env( + signers: &[Arc], + env: &mut HashMap, + ca_path: Option<&Path>, +) { + if signers.is_empty() { + return; + } + if env_value(env, AWS_CA_BUNDLE).is_none_or(str::is_empty) + && let Some(path) = ca_path + { + set_env_value(env, AWS_CA_BUNDLE, path.to_string_lossy().into_owned()); + } + let access = env_value(env, "AWS_ACCESS_KEY_ID").filter(|id| !id.is_empty()); + let use_default = access.is_none() + && env_value(env, "AWS_PROFILE").is_none() + && env_value(env, "AWS_DEFAULT_PROFILE").is_none(); + let mut candidates = signers.iter().filter(|s| { + use_default + || access.is_some_and(|id| { + id == s.credentials.access_key_id() || id == s.config.placeholder_access_key_id + }) + }); + let first = candidates.next(); + let selected = first.filter(|first| { + candidates.all(|s| { + s.config.placeholder_access_key_id == first.config.placeholder_access_key_id + && s.credentials.session_token().is_some() + == first.credentials.session_token().is_some() + }) + }); + let ambiguous_access = access.is_some() && first.is_some() && selected.is_none(); + // Mask aliases of every configured pair, including pairs the SDK didn't select. + for value in env.values_mut() { + mask_local_aws_text(signers, value); + } + if ambiguous_access { + for key in [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + ] { + remove_env_value(env, key); + } + } else if let Some(signer) = selected { + set_env_value( + env, + "AWS_ACCESS_KEY_ID", + signer.config.placeholder_access_key_id.clone(), + ); + set_env_value(env, "AWS_SECRET_ACCESS_KEY", DUMMY_SECRET.to_string()); + remove_env_value(env, "AWS_SECURITY_TOKEN"); + if signer.credentials.session_token().is_some() { + set_env_value(env, "AWS_SESSION_TOKEN", DUMMY_TOKEN.to_string()); + } else { + remove_env_value(env, "AWS_SESSION_TOKEN"); + } + } +} + +pub(crate) fn mask_local_aws_text(signers: &[Arc], text: &mut String) { + for signer in signers { + for (real, dummy) in [ + ( + Some(signer.credentials.access_key_id()), + signer.config.placeholder_access_key_id.as_str(), + ), + (Some(signer.credentials.secret_access_key()), DUMMY_SECRET), + (signer.credentials.session_token(), DUMMY_TOKEN), + ] { + if let Some(real) = real + && text.contains(real) + { + *text = text.replace(real, dummy); + } + } + } +} + impl AwsSigV4Signer { pub(crate) fn plan(&self, req: &Request) -> Result> { let placeholder = &self.config.placeholder_access_key_id; diff --git a/codex-rs/network-proxy/src/aws_sigv4_tests.rs b/codex-rs/network-proxy/src/aws_sigv4_tests.rs index fcde5d2389..05e164f135 100644 --- a/codex-rs/network-proxy/src/aws_sigv4_tests.rs +++ b/codex-rs/network-proxy/src/aws_sigv4_tests.rs @@ -92,7 +92,33 @@ fn matches_aws_published_iam_signature_vector() { async fn signs_actual_payload_and_replaces_untrusted_signing_headers() { let mut config = config("s3"); config.session_token = Some(ProxySecretSource::EnvVar("SESSION_TOKEN".into())); - let signer = signer(config); + let signer = Arc::new(signer(config)); + let mut env = HashMap::from([ + ( + "AWS_ACCESS_KEY_ID".to_string(), + signer.credentials.access_key_id().to_string(), + ), + ( + "SESSION_TOKEN".to_string(), + "secret-session-token".to_string(), + ), + ]); + mask_local_aws_env( + std::slice::from_ref(&signer), + &mut env, + /*ca_path*/ None, + ); + assert_eq!(env["AWS_ACCESS_KEY_ID"], "MASKEDACCESSKEY"); + assert_eq!(env["SESSION_TOKEN"], env["AWS_SESSION_TOKEN"]); + assert_ne!(env["AWS_SESSION_TOKEN"], "secret-session-token"); + env.insert("AWS_ACCESS_KEY_ID".to_string(), "OTHERKEY".to_string()); + env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "unrelated".to_string()); + mask_local_aws_env( + std::slice::from_ref(&signer), + &mut env, + /*ca_path*/ None, + ); + assert_eq!(env["AWS_SECRET_ACCESS_KEY"], "unrelated"); let mut cookie = HeaderValue::from_static("b=2"); cookie.set_sensitive(true); let request = Request::builder() @@ -289,6 +315,16 @@ fn credentials_are_required_and_configured_limits_are_bounded() { .compile(&|_| None, &|_| panic!("unexpected file")) .is_err() ); + let first = signer(config("s3")); + let mut other = config("sts"); + other.placeholder_access_key_id = format!("GUEST{}", first.credentials.access_key_id()); + let other = other + .compile(&|key| Some(format!("other-{key}")), &|_| { + panic!("unexpected file") + }) + .unwrap(); + assert!(validate_guest_placeholders([&first, &other].into_iter()).is_err()); + let mut config = config("s3"); config.max_body_bytes = 16 * 1024 * 1024 + 1; assert_eq!( diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 1248a11052..80730ecb0d 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -51,8 +51,17 @@ static MANAGED_MITM_CAS: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); const MAX_EXTERNAL_CA_FILE_BYTES: u64 = 1024 * 1024; +#[cfg(test)] +tokio::task_local! { + pub(super) static TEST_MITM_CA: Arc; +} + impl ManagedMitmCa { pub(super) fn load_or_create() -> Result> { + #[cfg(test)] + if let Ok(ca) = TEST_MITM_CA.try_with(Arc::clone) { + return Ok(ca); + } let proxy_dir = managed_ca_dir()?; let mut managed_cas = MANAGED_MITM_CAS .lock() diff --git a/codex-rs/network-proxy/src/credential_broker.rs b/codex-rs/network-proxy/src/credential_broker.rs index cd0dfa0e7f..727e9df5a0 100644 --- a/codex-rs/network-proxy/src/credential_broker.rs +++ b/codex-rs/network-proxy/src/credential_broker.rs @@ -258,14 +258,14 @@ pub(super) fn env_value<'a>(env: &'a HashMap, key: &str) -> Opti env_entry(env, key).map(|(_, value)| value) } -fn set_env_value(env: &mut HashMap, key: &str, value: String) { +pub(super) fn set_env_value(env: &mut HashMap, key: &str, value: String) { if cfg!(windows) { env.retain(|candidate, _| !env_key_matches(candidate, key)); } env.insert(key.to_string(), value); } -fn remove_env_value(env: &mut HashMap, key: &str) { +pub(super) fn remove_env_value(env: &mut HashMap, key: &str) { if cfg!(windows) { env.retain(|candidate, _| !env_key_matches(candidate, key)); } else { diff --git a/codex-rs/network-proxy/src/credential_broker/environment.rs b/codex-rs/network-proxy/src/credential_broker/environment.rs index 4455e217b0..52081880b4 100644 --- a/codex-rs/network-proxy/src/credential_broker/environment.rs +++ b/codex-rs/network-proxy/src/credential_broker/environment.rs @@ -455,6 +455,9 @@ pub fn brokered_credential_env_keys( let active = env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY).is_some_and(|value| value == "1"); let mut keys = Vec::new(); if active { + if let Some(aws_keys) = crate::aws_sigv4::snapshot_env_keys(env) { + keys.extend(aws_keys); + } let brokered_keys = brokered_credential_dummy_env_keys(env); keys.extend(providers::credential_env_keys().filter(|key| { brokered_keys diff --git a/codex-rs/network-proxy/src/mitm_forwarding_tests.rs b/codex-rs/network-proxy/src/mitm_forwarding_tests.rs index f52d792556..e920c22537 100644 --- a/codex-rs/network-proxy/src/mitm_forwarding_tests.rs +++ b/codex-rs/network-proxy/src/mitm_forwarding_tests.rs @@ -94,6 +94,7 @@ async fn tls_origin( }], ..Default::default() }; + config.set_credential_broker_enabled(/*enabled*/ true); config.set_allowed_domains(vec!["localhost".to_string()]); let app_state = Arc::new(network_proxy_state_for_policy(config)); let roots = Arc::new(roots); @@ -194,6 +195,34 @@ fn request( async fn forwards_aoss_payload_hashes_and_signatures_over_tls() { let dir = tempdir().expect("TLS fixture directory"); let (context, listener, acceptor) = tls_origin(dir.path(), "aoss", b"http/1.1").await; + let builder = crate::NetworkProxy::builder() + .state(Arc::clone(&context.policy.app_state)) + .managed_proxy_routing(crate::ManagedProxyRouting::DedicatedListeners); + let proxy = crate::certs::TEST_MITM_CA + .scope(Arc::clone(&context.mitm.ca), builder.build()) + .await + .expect("local proxy"); + let mut env = HashMap::from([("ALIAS".to_string(), "prefix:test-secret-key".to_string())]); + proxy.apply_to_env_for_snapshot(&mut env); + assert_eq!(env["AWS_ACCESS_KEY_ID"], "MASKEDACCESSKEY"); + assert_eq!(env["AWS_CA_BUNDLE"], env["SSL_CERT_FILE"]); + assert_eq!(Path::new(&env["AWS_CA_BUNDLE"]).parent(), Some(dir.path())); + assert!(crate::brokered_credential_env_keys(&env).any(|key| key == "AWS_ACCESS_KEY_ID")); + assert_eq!( + env["ALIAS"], + format!("prefix:{}", env["AWS_SECRET_ACCESS_KEY"]) + ); + let mut snapshot = "export COPY=test-secret-key".to_string(); + assert!(proxy.virtualize_brokered_text(&mut snapshot, &env)); + assert_eq!( + snapshot, + format!("export COPY={}", env["AWS_SECRET_ACCESS_KEY"]) + ); + let remote = proxy + .prepare_for_remote_environment(HashMap::new(), "remote") + .expect("remote environment"); + assert!(!remote.env.contains_key("AWS_ACCESS_KEY_ID")); + assert!(!remote.env.contains_key("AWS_CA_BUNDLE")); let cases: [(&str, &[u8]); 4] = [ ("/unsigned", b""), ("/other-key", b""), diff --git a/codex-rs/network-proxy/src/mitm_hook.rs b/codex-rs/network-proxy/src/mitm_hook.rs index 49e87de77d..61e385949d 100644 --- a/codex-rs/network-proxy/src/mitm_hook.rs +++ b/codex-rs/network-proxy/src/mitm_hook.rs @@ -389,6 +389,12 @@ where }); } + crate::aws_sigv4::validate_guest_placeholders( + hooks_by_host + .values() + .flatten() + .filter_map(|hook| hook.actions.aws_sigv4.as_deref()), + )?; Ok(hooks_by_host) } diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 9c412f31e0..5b1240ae10 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -683,7 +683,7 @@ pub fn is_managed_proxy_env_var(key: &str, value: &str) -> bool { if PROXY_ENV_KEYS.contains(&key) { return true; } - if crate::certs::CUSTOM_CA_ENV_KEYS.contains(&key) { + if key == crate::aws_sigv4::AWS_CA_BUNDLE || crate::certs::CUSTOM_CA_ENV_KEYS.contains(&key) { return crate::certs::is_managed_mitm_ca_trust_bundle_path(value); } #[cfg(target_os = "macos")] @@ -1153,7 +1153,6 @@ impl NetworkProxy { &self, mut env: HashMap, addrs: EnvironmentProxyAddrs, - #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] client: EnvironmentProxyClient, environment_id: Option<&str>, ) -> PreparedManagedNetwork { @@ -1171,6 +1170,15 @@ impl NetworkProxy { ..addrs }; let runtime_settings = self.runtime_settings(); + if client == EnvironmentProxyClient::SandboxedProcess { + self.state.mask_local_aws_credentials( + &mut env, + runtime_settings + .mitm_ca_trust_bundle + .as_ref() + .map(|bundle| bundle.path.as_path()), + ); + } // Enforce proxying for child processes. Proxy endpoint values are always rewritten; // managed MITM CA vars preserve child-scoped overrides after proxy startup. apply_proxy_env_overrides( diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index cd09549cb4..2c826c4eb3 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -1,3 +1,6 @@ +use crate::aws_sigv4::AwsSigV4Signer; +use crate::aws_sigv4::mask_local_aws_env; +use crate::aws_sigv4::mask_local_aws_text; use crate::config::NetworkDomainPermission; use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; @@ -189,6 +192,19 @@ pub struct ConfigState { pub blocked_total: u64, } +impl ConfigState { + fn local_aws_signers(&self) -> Vec> { + if !self.config.enabled || !self.config.credential_broker || !self.config.mitm { + return Vec::new(); + } + self.mitm_hooks + .values() + .flatten() + .filter_map(|hook| hook.actions.aws_sigv4.clone()) + .collect() + } +} + pub trait ConfigReloader: Send + Sync { /// Human-readable description of where config is loaded from, for logs. fn source_label(&self) -> String; @@ -278,6 +294,7 @@ pub struct NetworkProxyState { pub(crate) process_log_metadata: crate::NetworkProxyProcessLogMetadata, /// Shared by this proxy's listeners; rebinding leaves other proxies' cached brokers intact. credential_broker: Arc>, + local_aws_signers: Arc>>>, audit_metadata: NetworkProxyAuditMetadata, execution_attributions: Arc>>, environment_id: Option>, @@ -317,6 +334,7 @@ impl Clone for NetworkProxyState { launch_span_context: self.launch_span_context.clone(), process_log_metadata: self.process_log_metadata.clone(), credential_broker: self.credential_broker.clone(), + local_aws_signers: self.local_aws_signers.clone(), audit_metadata: self.audit_metadata.clone(), execution_attributions: self.execution_attributions.clone(), environment_id: self.environment_id.clone(), @@ -424,6 +442,7 @@ impl NetworkProxyState { credential_broker.configure(&local_binding_policy.resolved_config(&state.config)); Self { credential_broker: Arc::new(std::sync::RwLock::new(credential_broker)), + local_aws_signers: Arc::new(std::sync::RwLock::new(state.local_aws_signers())), state: Arc::new(RwLock::new(state)), local_binding_policy, proxy_private_ips_via_upstream: false, @@ -561,6 +580,27 @@ impl NetworkProxyState { Ok(()) } + fn install_config(&self, current: &mut ConfigState, next: ConfigState) { + let mut signers = self + .local_aws_signers + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *signers = next.local_aws_signers(); + *current = next; + } + + pub(crate) fn mask_local_aws_credentials( + &self, + env: &mut HashMap, + ca_path: Option<&Path>, + ) { + let signers = self + .local_aws_signers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + mask_local_aws_env(&signers, env, ca_path); + } + pub fn virtualize_child_credentials(&self, env: &mut HashMap) { self.virtualize_child_credentials_for_environment(env, self.environment_id()); } @@ -624,6 +664,11 @@ impl NetworkProxyState { text: &mut String, env: &HashMap, ) -> bool { + let signers = self + .local_aws_signers + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + mask_local_aws_text(&signers, text); self.credential_broker().virtualize_text(text, env) } @@ -742,7 +787,7 @@ impl NetworkProxyState { // Log policy diffs without dumping potentially sensitive config values. log_policy_changes(&guard.config, &new_state.config); new_state.blocked = guard.blocked.clone(); - *guard = new_state; + self.install_config(&mut guard, new_state); } let source = self.reloader.source_label(); info!("reloaded config from {source}"); @@ -764,7 +809,7 @@ impl NetworkProxyState { log_policy_changes(&guard.config, &new_state.config); new_state.blocked = guard.blocked.clone(); new_state.blocked_total = guard.blocked_total; - *guard = new_state; + self.install_config(&mut guard, new_state); info!("updated network proxy config state"); Ok(()) } @@ -1176,7 +1221,7 @@ impl NetworkProxyState { } log_policy_changes(&guard.config, &candidate); - *guard = new_state; + self.install_config(&mut guard, new_state); info!("updated network {list_name} with {normalized_host}"); return Ok(()); } @@ -1193,7 +1238,7 @@ impl NetworkProxyState { log_policy_changes(&guard.config, &new_state.config); new_state.blocked = guard.blocked.clone(); new_state.blocked_total = guard.blocked_total; - *guard = new_state; + self.install_config(&mut guard, new_state); } let source = self.reloader.source_label(); info!("reloaded config from {source}");