From 1bfd383890b1bd7fab0d1b3d5e05129a9e293d8a Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Wed, 9 Sep 2026 04:50:26 +0000 Subject: [PATCH] Add configurable credential providers to the network proxy (#44056) ## What changed - Add `credential_providers` configuration for environment variables, credential patterns, static or environment-derived URL prefixes, and bearer, token, Basic, or custom header authentication. - Generate matching dummy credentials and restrict replacement to authorized schemes, hosts, ports, and path prefixes. Scope credentials and destination history to each environment. - Preserve real credentials for destinations that bypass the proxy with `allow_local_binding`, while providing snapshot redaction and alias matching. - Validate provider definitions and preserve unchanged providers across configuration reloads. ## Testing Add coverage for dummy generation, authentication translation, URL restrictions, environment isolation, configuration reloads, and credential restoration for local proxy bypass. GitOrigin-RevId: 5d60ecc684cf4e9aa7637af33af12179aa0c672a --- MODULE.bazel.lock | 1 + codex-rs/Cargo.lock | 14 + codex-rs/Cargo.toml | 2 + codex-rs/network-proxy/Cargo.toml | 4 + codex-rs/network-proxy/src/attribution.rs | 4 +- codex-rs/network-proxy/src/config.rs | 4 + .../network-proxy/src/credential_broker.rs | 924 ++++++++++-- .../src/credential_broker/configured.rs | 417 +++++ .../src/credential_broker/configured_tests.rs | 1338 +++++++++++++++++ .../src/credential_broker/destination.rs | 158 ++ .../src/credential_broker/environment.rs | 17 +- .../src/credential_broker/matching.rs | 2 +- .../src/credential_broker/provider_config.rs | 32 + .../src/credential_broker/providers.rs | 59 +- .../src/credential_broker/providers/github.rs | 58 +- .../src/credential_broker/providers/openai.rs | 4 + .../src/credential_broker/registry.rs | 314 +++- .../src/credential_broker_tests.rs | 157 +- codex-rs/network-proxy/src/http_proxy.rs | 194 ++- codex-rs/network-proxy/src/lib.rs | 2 + codex-rs/network-proxy/src/mitm.rs | 6 +- codex-rs/network-proxy/src/policy.rs | 10 + codex-rs/network-proxy/src/proxy.rs | 59 +- codex-rs/network-proxy/src/runtime.rs | 57 +- codex-rs/network-proxy/src/socks5.rs | 2 +- 25 files changed, 3540 insertions(+), 299 deletions(-) create mode 100644 codex-rs/network-proxy/src/credential_broker/configured.rs create mode 100644 codex-rs/network-proxy/src/credential_broker/configured_tests.rs create mode 100644 codex-rs/network-proxy/src/credential_broker/destination.rs create mode 100644 codex-rs/network-proxy/src/credential_broker/provider_config.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 6018e68775..39aa0f9507 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1591,6 +1591,7 @@ "rand_core_0.10.1": "{\"dependencies\":[],\"features\":{}}", "rand_core_0.6.4": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"serde1\":[\"serde\"],\"std\":[\"alloc\",\"getrandom\",\"getrandom/std\"]}}", "rand_core_0.9.5": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"os_rng\":[\"dep:getrandom\"],\"serde\":[\"dep:serde\"],\"std\":[\"getrandom?/std\"]}}", + "rand_regex_0.18.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\",\"unicode\"],\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"}],\"features\":{\"default\":[\"unicode\"],\"unicode\":[\"regex-syntax/unicode\"]}}", "rand_xorshift_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.118\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "ratatui-core_0.1.2": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bitflags\",\"req\":\"^2.12\"},{\"default_features\":false,\"name\":\"compact_str\",\"req\":\"^0.9\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.2\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.2\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"hashbrown\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"kasuari\",\"req\":\"^0.4\"},{\"name\":\"lru\",\"req\":\"^0.18\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"palette\",\"optional\":true,\"req\":\"^0.7.6\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"strum\",\"req\":\"^0.28\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unicode-truncate\",\"req\":\"^2\"},{\"name\":\"unicode-width\",\"req\":\">=0.2.0\"}],\"features\":{\"anstyle\":[\"dep:anstyle\"],\"default\":[],\"layout-cache\":[\"dep:critical-section\"],\"palette\":[\"dep:palette\"],\"portable-atomic\":[\"kasuari/portable-atomic\"],\"scrolling-regions\":[],\"serde\":[\"bitflags/serde\",\"compact_str/serde\",\"dep:serde\"],\"std\":[\"bitflags/std\",\"compact_str/std\",\"itertools/use_std\",\"kasuari/std\",\"palette?/std\",\"serde?/std\",\"strum/std\",\"thiserror/std\",\"unicode-truncate/std\"],\"underline-color\":[]}}", "ratatui-crossterm_0.1.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.1\"},{\"name\":\"crossterm_0_28\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.28\"},{\"name\":\"crossterm_0_29\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.29\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"instability\",\"req\":\"^0.3\"},{\"name\":\"ratatui-core\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"}],\"features\":{\"crossterm_0_28\":[\"dep:crossterm_0_28\"],\"crossterm_0_29\":[\"dep:crossterm_0_29\"],\"default\":[\"crossterm_0_29\",\"underline-color\"],\"scrolling-regions\":[\"ratatui-core/scrolling-regions\"],\"serde\":[\"crossterm_0_28?/serde\",\"crossterm_0_29?/serde\"],\"underline-color\":[\"ratatui-core/underline-color\"],\"unstable\":[\"unstable-backend-writer\"],\"unstable-backend-writer\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d53c1317b..52e087777b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4074,8 +4074,12 @@ dependencies = [ "rama-tls-rustls", "rama-unix", "rand 0.9.3", + "rand_regex", + "regex", + "regex-syntax", "rustls-native-certs", "schannel", + "schemars 0.8.22", "security-framework 3.5.1", "serde", "serde_json", @@ -12471,6 +12475,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_regex" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04db2e382d13679a1e42400e90e306cdbb79dc5cd41bb035ba4eae72e78cdf37" +dependencies = [ + "rand 0.9.3", + "regex-syntax", +] + [[package]] name = "rand_xorshift" version = "0.4.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6ec3e49fb1..38cafa75b9 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -409,6 +409,7 @@ pretty_assertions = "1.4.1" pulldown-cmark = { version = "0.10", default-features = false } quick-xml = "0.41.0" rand = "0.9" +rand_regex = "0.18.1" ratatui = { version = "0.30.2", default-features = false, features = [ "crossterm", "layout-cache", @@ -421,6 +422,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [ ] } regex = "1.12.3" regex-lite = "0.1.8" +regex-syntax = "0.8.8" reqwest = { version = "0.12", features = ["cookies"] } rmcp = { version = "=3.2.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index de72a7684e..707b50d2bc 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -23,6 +23,10 @@ codex-utils-rustls-provider = { workspace = true } globset = { workspace = true } opentelemetry = { workspace = true } rand = { workspace = true } +rand_regex = { workspace = true } +regex = { workspace = true } +regex-syntax = { workspace = true } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/network-proxy/src/attribution.rs b/codex-rs/network-proxy/src/attribution.rs index f526460fd6..85affff15b 100644 --- a/codex-rs/network-proxy/src/attribution.rs +++ b/codex-rs/network-proxy/src/attribution.rs @@ -53,7 +53,9 @@ where "unknown network proxy attribution token", ) })?, - None => self.state.as_ref().clone(), + None => self + .state + .for_environment_id(self.environment_id.as_deref()), }; if let Some(expected_environment_id) = self.environment_id.as_deref() && state diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 3e96704341..a3c48b63eb 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -138,6 +138,8 @@ pub struct NetworkProxyConfig { pub mitm: bool, #[serde(default)] pub credential_broker: bool, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub credential_providers: BTreeMap, /// Trusted OpenAI endpoint derived from local configuration, never sent to remote executors. #[serde(skip)] pub credential_broker_openai_host: Option, @@ -164,6 +166,7 @@ impl Default for NetworkProxyConfig { allow_local_binding: false, mitm: false, credential_broker: false, + credential_providers: BTreeMap::new(), credential_broker_openai_host: None, dangerously_allow_plaintext_credential_injection: false, mitm_hooks: Vec::new(), @@ -637,6 +640,7 @@ mod tests { allow_local_binding: false, mitm: false, credential_broker: false, + credential_providers: BTreeMap::new(), credential_broker_openai_host: None, dangerously_allow_plaintext_credential_injection: false, mitm_hooks: Vec::new(), diff --git a/codex-rs/network-proxy/src/credential_broker.rs b/codex-rs/network-proxy/src/credential_broker.rs index 50acdc70f8..d180a5398c 100644 --- a/codex-rs/network-proxy/src/credential_broker.rs +++ b/codex-rs/network-proxy/src/credential_broker.rs @@ -1,5 +1,8 @@ +mod configured; +mod destination; mod environment; mod matching; +mod provider_config; mod providers; mod registry; @@ -7,11 +10,16 @@ use crate::config::NetworkProxyConfig; use crate::policy::normalize_host; use environment::update_brokered_credentials_marker; use rama_http::HeaderMap; +use registry::ActiveCredentialSource; +use registry::BrokeredCredentialProvider; +use registry::active_credential_sources; use registry::prioritized_credentials; -use registry::select_credential; +use registry::select_credentials; +use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; +use url::Url; pub use environment::brokered_credential_binding_env_keys; pub use environment::brokered_credential_dummy_env_keys; @@ -21,6 +29,8 @@ pub use environment::brokered_credential_value_env_keys; pub use environment::credential_broker_provider_context_env_keys; pub use environment::credential_broker_provider_sources_allowed; pub use environment::is_credential_broker_provider_env_key; +pub use provider_config::CredentialAuthMethod; +pub use provider_config::CredentialProviderConfig; pub const CREDENTIAL_BROKER_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE"; pub(crate) const BROKERED_CREDENTIALS_ENV_KEY: &str = "CODEX_NETWORK_PROXY_BROKERED_CREDENTIALS"; @@ -36,7 +46,10 @@ pub(crate) struct CredentialBroker { struct CredentialBrokerState { config_revision: u64, enabled: bool, + allow_local_binding: bool, openai_api_host: Option, + configured_provider_configs: BTreeMap, + configured_providers: Vec>, credentials: Vec, credential_owners: Vec, credential_aliases: Vec, @@ -49,17 +62,126 @@ struct CredentialOwner { struct CredentialRecord { env_var: String, - provider: &'static providers::CredentialProvider, + provider: BrokeredCredentialProvider, host_binding: providers::CredentialHostBinding, + additional_host_bindings: Vec, + environment_id: Option, real_value: String, dummy_value: String, } +impl CredentialRecord { + fn should_reconcile_host_binding(&self, env: &HashMap) -> bool { + env_contains_credential_value(env, &self.dummy_value) + || self.invalidates_host_binding(env) + && env.iter().any(|(key, value)| { + !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) + && !crate::is_managed_proxy_env_var(key, value) + && (value == &self.real_value + || self + .provider + .contains_embedded_value(value, &self.real_value)) + }) + } + + fn invalidates_host_binding(&self, env: &HashMap) -> bool { + match &self.provider { + BrokeredCredentialProvider::Builtin(provider) => { + provider.sources().iter().any(|source| { + source + .env_vars + .iter() + .any(|key| env_key_matches(key, &self.env_var)) + && (source.invalidates_host_binding)(env) + }) + } + BrokeredCredentialProvider::Configured(provider) => { + provider + .config + .url_prefix_from_env + .as_deref() + .is_some_and(|key| env_value(env, key).is_some()) + && provider.dynamic_destination(env).is_none() + } + } + } + + fn observe_host_binding( + &mut self, + host_binding: providers::CredentialHostBinding, + env: &HashMap, + ) { + if self.invalidates_host_binding(env) { + self.additional_host_bindings.clear(); + self.host_binding = host_binding; + return; + } + if self.host_binding == host_binding { + return; + } + // Track the latest source separately, but keep prior destinations usable by + // concurrent commands sharing this dummy in the same environment. + self.additional_host_bindings + .retain(|binding| binding != &host_binding); + self.additional_host_bindings + .push(std::mem::replace(&mut self.host_binding, host_binding)); + } + + fn host_bindings(&self) -> impl Iterator { + std::iter::once(&self.host_binding).chain(&self.additional_host_bindings) + } + + fn belongs_to_environment(&self, environment_id: Option<&str>) -> bool { + self.environment_id.as_deref() == environment_id + } +} + +fn source_accepts_credential( + source: &ActiveCredentialSource, + credential: &CredentialRecord, +) -> bool { + credential.provider.same_provider(&source.provider) + && source + .env_vars + .iter() + .any(|env_var| env_key_matches(env_var, &credential.env_var)) +} + +fn source_tracks_credential( + source: &ActiveCredentialSource, + credential: &CredentialRecord, + env: &HashMap, +) -> bool { + if !source_accepts_credential(source, credential) { + return false; + } + let mut source_present = false; + let contains_credential = source + .env_vars + .iter() + .filter_map(|env_var| env_value(env, env_var)) + .any(|value| { + source_present = true; + let value = match &source.provider { + BrokeredCredentialProvider::Builtin(_) => value.trim(), + BrokeredCredentialProvider::Configured(_) => value, + }; + value == credential.dummy_value || value == credential.real_value + }); + !source_present || contains_credential +} + struct CredentialAlias { env_var: String, dummy_value: String, } +enum CredentialEnvironment { + Child, + Snapshot, +} + fn env_key_matches(candidate: &str, expected: &str) -> bool { if cfg!(windows) { candidate.eq_ignore_ascii_case(expected) @@ -93,6 +215,15 @@ fn remove_env_value(env: &mut HashMap, key: &str) { } } +fn env_contains_credential_value(env: &HashMap, credential: &str) -> bool { + env.iter().any(|(key, value)| { + !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) + && value.contains(credential) + && !crate::is_managed_proxy_env_var(key, value) + }) +} + impl CredentialBroker { pub(crate) fn new(enabled: bool) -> Self { Self { @@ -107,9 +238,12 @@ impl CredentialBroker { let mut state = self.write_state(); if state.enabled != config.credential_broker || state.openai_api_host != config.credential_broker_openai_host + || state.allow_local_binding != config.allow_local_binding + || state.configured_provider_configs != config.credential_providers { state.config_revision += 1; } + state.allow_local_binding = config.allow_local_binding; if state.enabled != config.credential_broker { state.enabled = config.credential_broker; state.credentials.clear(); @@ -120,9 +254,77 @@ impl CredentialBroker { state .openai_api_host .clone_from(&config.credential_broker_openai_host); + state.credentials.retain(|credential| { + !matches!( + &credential.provider, + BrokeredCredentialProvider::Builtin(provider) + if provider.reset_on_configuration_change + ) + }); + } + if state.configured_provider_configs != config.credential_providers { + let mut configured_providers: Vec> = + Vec::new(); + let mut provider_configs = config.credential_providers.iter().collect::>(); + provider_configs.sort_by_key(|(id, provider_config)| { + std::cmp::Reverse( + state.configured_provider_configs.get(id.as_str()) == Some(*provider_config) + && state + .configured_providers + .iter() + .any(|provider| provider.id == id.as_str()), + ) + }); + for (id, provider_config) in provider_configs { + if configured_providers.iter().any(|existing| { + existing.config.env.iter().any(|existing_key| { + provider_config + .env + .iter() + .any(|key| env_key_matches(existing_key, key)) + }) + }) { + tracing::warn!( + provider = %id, + "ignoring credential provider with an overlapping environment source" + ); + continue; + } + + let existing = (state.configured_provider_configs.get(id) == Some(provider_config)) + .then(|| { + state + .configured_providers + .iter() + .find(|provider| provider.id == *id) + .cloned() + }) + .flatten(); + match existing { + Some(provider) => configured_providers.push(provider), + None => { + match configured::ConfiguredCredentialProvider::compile(id, provider_config) + { + Ok(provider) => configured_providers.push(Arc::new(provider)), + Err(error) => { + tracing::warn!(provider = %id, %error, "ignoring invalid credential provider"); + } + } + } + } + } state .credentials - .retain(|credential| !credential.provider.reset_on_configuration_change); + .retain(|credential| match &credential.provider { + BrokeredCredentialProvider::Builtin(_) => true, + BrokeredCredentialProvider::Configured(provider) => configured_providers + .iter() + .any(|current| Arc::ptr_eq(current, provider)), + }); + state.configured_providers = configured_providers; + state + .configured_provider_configs + .clone_from(&config.credential_providers); } } @@ -130,10 +332,22 @@ impl CredentialBroker { self.read_state().config_revision } + #[cfg(test)] pub(crate) fn discover_parent_credentials( &self, parent_env: &HashMap, child_env: &HashMap, + ) { + self.discover_parent_credentials_for_environment( + parent_env, child_env, /*environment_id*/ None, + ); + } + + pub(crate) fn discover_parent_credentials_for_environment( + &self, + parent_env: &HashMap, + child_env: &HashMap, + environment_id: Option<&str>, ) { let mut state = self.write_state(); if !state.enabled { @@ -141,36 +355,60 @@ impl CredentialBroker { } state.observe_credential_owners(parent_env); - for provider in providers::credential_providers() { - for source in provider.sources() { - let Some(host_binding) = - (source.host_binding)(child_env, state.openai_api_host.as_deref()) + for source in active_credential_sources(&state, child_env) { + for env_var in source.env_vars { + let Some(real_value) = + brokerable_credential_value(parent_env, &state, &env_var, &source.provider) + .map(str::to_string) else { continue; }; - for env_var in source.env_vars { - let Some(real_value) = - brokerable_credential_value(parent_env, &state, env_var, provider) - .map(str::to_string) - else { - continue; - }; - if env_value(child_env, env_var) == Some(real_value.as_str()) { - continue; - } - if child_env.values().any(|value| { + if env_value(child_env, &env_var) != Some(real_value.as_str()) + && child_env.values().any(|value| { value == &real_value - || real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && value.contains(&real_value) - }) { - state.register(env_var, provider, host_binding.clone(), &real_value); - } + || source.provider.contains_embedded_value(value, &real_value) + }) + { + let _ = state.register( + &env_var, + source.provider.clone(), + source.host_binding.clone(), + environment_id, + &real_value, + parent_env, + ); } } } } + #[cfg(test)] pub(crate) fn virtualize_child_env(&self, env: &mut HashMap) { + self.virtualize_child_env_for_environment(env, /*environment_id*/ None); + } + + pub(crate) fn virtualize_child_env_for_environment( + &self, + env: &mut HashMap, + environment_id: Option<&str>, + ) { + self.virtualize_env(env, environment_id, CredentialEnvironment::Child); + } + + pub(crate) fn virtualize_snapshot_env( + &self, + env: &mut HashMap, + environment_id: Option<&str>, + ) { + self.virtualize_env(env, environment_id, CredentialEnvironment::Snapshot); + } + + fn virtualize_env( + &self, + env: &mut HashMap, + environment_id: Option<&str>, + destination: CredentialEnvironment, + ) { let mut state = self.write_state(); if !state.enabled { remove_env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY); @@ -180,34 +418,179 @@ impl CredentialBroker { set_env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY, "1".to_string()); state.observe_credential_owners(env); - for provider in providers::credential_providers() { - for source in provider.sources() { - let Some(host_binding) = - (source.host_binding)(env, state.openai_api_host.as_deref()) - else { - continue; - }; - for env_var in source.env_vars { - virtualize_env_var(env, &mut state, env_var, provider, host_binding.clone()); + let active_sources = active_credential_sources(&state, env); + for credential in state.credentials.iter_mut().filter(|credential| { + credential.belongs_to_environment(environment_id) + && credential.should_reconcile_host_binding(env) + }) { + if let Some(source) = active_sources + .iter() + .find(|source| source_tracks_credential(source, credential, env)) + { + credential.observe_host_binding(source.host_binding.clone(), env); + } + } + let stale_credentials = state + .credentials + .iter() + .filter(|credential| { + credential.belongs_to_environment(environment_id) + && credential.should_reconcile_host_binding(env) + && !active_sources + .iter() + .any(|source| source_tracks_credential(source, credential, env)) + }) + .map(|credential| { + ( + credential.dummy_value.clone(), + credential.real_value.clone(), + ) + }) + .collect::>(); + for (dummy_value, real_value) in &stale_credentials { + for value in env.values_mut() { + if value.contains(dummy_value) { + *value = value.replace(dummy_value, real_value); } } } + state.credentials.retain(|credential| { + !credential.belongs_to_environment(environment_id) + || !stale_credentials + .iter() + .any(|(dummy_value, _)| credential.dummy_value == *dummy_value) + }); + + let unbound_inherited_credentials = state + .credentials + .iter() + .filter(|credential| { + !credential.belongs_to_environment(environment_id) + && env_contains_credential_value(env, &credential.dummy_value) + && !active_sources + .iter() + .any(|source| source_accepts_credential(source, credential)) + }) + .map(|credential| { + ( + credential.dummy_value.clone(), + credential.real_value.clone(), + ) + }) + .collect::>(); + for (dummy_value, real_value) in unbound_inherited_credentials { + for value in env.values_mut() { + if value.contains(&dummy_value) { + *value = value.replace(&dummy_value, &real_value); + } + } + } + + let inherited_credentials = active_sources + .iter() + .flat_map(|source| { + state + .credentials + .iter() + .filter(|&credential| { + !credential.belongs_to_environment(environment_id) + && source_accepts_credential(source, credential) + && env_contains_credential_value(env, &credential.dummy_value) + }) + .map(|credential| { + ( + credential.env_var.clone(), + source.provider.clone(), + source.host_binding.clone(), + credential.real_value.clone(), + credential.dummy_value.clone(), + ) + }) + }) + .collect::>(); + for (env_var, provider, host_binding, real_value, inherited_dummy) in inherited_credentials + { + let current_dummy = if let Some(credential) = + state.credentials.iter_mut().find(|credential| { + credential.belongs_to_environment(environment_id) + && credential.provider.same_provider(&provider) + && env_key_matches(&credential.env_var, &env_var) + && credential.real_value == real_value + }) { + credential.observe_host_binding(host_binding, env); + credential.dummy_value.clone() + } else { + state.credentials.push(CredentialRecord { + env_var, + provider, + host_binding, + additional_host_bindings: Vec::new(), + environment_id: environment_id.map(str::to_string), + real_value, + dummy_value: inherited_dummy.clone(), + }); + inherited_dummy.clone() + }; + if current_dummy != inherited_dummy { + for value in env.values_mut() { + if value.contains(&inherited_dummy) { + *value = value.replace(&inherited_dummy, ¤t_dummy); + } + } + } + } + + for source in &active_sources { + for env_var in &source.env_vars { + virtualize_env_var( + env, + &mut state, + env_var, + source.provider.clone(), + source.host_binding.clone(), + environment_id, + ); + } + } + let provider_context_keys = credential_broker_provider_context_env_keys() + .map(str::to_string) + .chain( + state + .configured_providers + .iter() + .filter_map(|provider| provider.config.url_prefix_from_env.as_ref().cloned()), + ) + .collect::>(); + let discoverable_values = env + .iter() + .filter(|(key, value)| { + !key.eq_ignore_ascii_case("PATH") + && !key.to_ascii_uppercase().ends_with("_PATH") + && !crate::is_managed_proxy_env_var(key, value) + && !provider_context_keys + .iter() + .any(|context_key| env_key_matches(key, context_key)) + }) + .map(|(_, value)| value) + .collect::>(); for provider in providers::credential_providers() { + let brokered_provider = BrokeredCredentialProvider::Builtin(provider); let Some((source, host_binding)) = provider.sources().iter().rev().find_map(|source| { (source.host_binding)(env, state.openai_api_host.as_deref()) .map(|binding| (source, binding)) }) else { continue; }; - for (key, value) in env.iter() { - if key.eq_ignore_ascii_case("PATH") || key.to_ascii_uppercase().ends_with("_PATH") { - continue; - } + for value in &discoverable_values { for prefix in provider.credential_prefixes { for (start, _) in value.match_indices(prefix) { let credential = matching::builtin_credential_candidate(provider, value, start); if credential.len() < provider.minimum_credential_len + || state + .configured_providers + .iter() + .any(|configured| configured.matches_value(credential)) || matching::is_operational_path_match( value, start, @@ -220,6 +603,12 @@ impl CredentialBroker { .is_none_or(|watermark| !credential.contains(watermark)) }) || state.is_dummy_value(credential) + || state.credentials.iter().any(|existing| { + existing.belongs_to_environment(environment_id) + && existing.provider.same_provider(&brokered_provider) + && existing.host_binding == host_binding + && existing.real_value == credential + }) || source.binding_env_vars.is_empty() && state.credential_owners.iter().any(|existing| { !source @@ -228,10 +617,6 @@ impl CredentialBroker { .any(|key| env_key_matches(key, &existing.env_var)) && existing.real_value == credential }) - || state.credentials.iter().any(|existing| { - std::ptr::eq(existing.provider, provider) - && existing.real_value == credential - }) || provider.sources().iter().any(|candidate| { candidate .env_vars @@ -247,28 +632,49 @@ impl CredentialBroker { { continue; } - state.register( + let _ = state.register( source.env_vars[0], - provider, + brokered_provider.clone(), host_binding.clone(), + environment_id, credential, + env, ); } } } } - let credentials = prioritized_credentials(&state, env); + let credentials = prioritized_credentials(&state, env) + .into_iter() + .filter(|credential| { + credential.belongs_to_environment(environment_id) + && active_sources.iter().any(|source| { + credential.provider.same_provider(&source.provider) + && credential.host_binding == source.host_binding + }) + }) + .collect::>(); let mut credential_aliases = Vec::new(); for (key, value) in env.iter_mut() { + if crate::is_managed_proxy_env_var(key, value) { + continue; + } let mut virtualized = false; for credential in &credentials { if value == &credential.real_value { value.clone_from(&credential.dummy_value); virtualized = true; - } else if credential.real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && value.contains(&credential.real_value) + } else if credential + .provider + .contains_embedded_value(value, &credential.real_value) { - *value = value.replace(&credential.real_value, &credential.dummy_value); + *value = credential.provider.replace_embedded_value( + value, + &credential.real_value, + &credential.dummy_value, + ); + virtualized = true; + } else if value.contains(&credential.dummy_value) { virtualized = true; } } @@ -287,6 +693,15 @@ impl CredentialBroker { state.credential_aliases.push(alias); } } + if state.allow_local_binding && matches!(destination, CredentialEnvironment::Child) { + // Clients bypass the proxy for local destinations, so their credentials must stay real. + state.restore_child_env(env, |credential| { + credential.belongs_to_environment(environment_id) + && credential + .host_bindings() + .any(providers::CredentialHostBinding::bypasses_proxy_with_local_binding) + }); + } update_brokered_credentials_marker(&state, env); } @@ -299,55 +714,7 @@ impl CredentialBroker { if !state.enabled || env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) != Some("1") { return; } - - let credentials = state - .credentials - .iter() - .filter(|credential| { - env.iter().any(|(key, value)| { - !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) - && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) - && (value == &credential.dummy_value - || credential.real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && value.contains(&credential.dummy_value)) - }) - }) - .collect::>(); - for (key, value) in env.iter_mut() { - if env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) - || env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) - { - continue; - } - let canonical_credential = state - .credentials - .iter() - .any(|credential| env_key_matches(key, &credential.env_var)); - if !canonical_credential - && !state.credential_aliases.iter().any(|alias| { - env_key_matches(key, &alias.env_var) && value == &alias.dummy_value - }) - { - continue; - } - for credential in &credentials { - if canonical_credential - && !state.credentials.iter().any(|candidate| { - env_key_matches(key, &candidate.env_var) - && std::ptr::eq(candidate.provider, credential.provider) - && candidate.host_binding == credential.host_binding - && candidate.real_value == credential.real_value - }) - { - continue; - } - if value == &credential.dummy_value { - value.clone_from(&credential.real_value); - } else if credential.real_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH { - *value = value.replace(&credential.dummy_value, &credential.real_value); - } - } - } + state.restore_child_env(env, |_| true); } pub(crate) fn restore_and_disable_child_env( @@ -360,14 +727,94 @@ impl CredentialBroker { remove_env_value(env, BROKERED_CREDENTIALS_ENV_KEY); } - pub(crate) fn host_requires_mitm(&self, host: &str) -> bool { + pub(crate) fn child_alias_matches( + &self, + key: &str, + value: &str, + snapshot_value: &str, + environment_id: Option<&str>, + ) -> bool { + let state = self.read_state(); + if !state.enabled { + return false; + } + let mut expected = HashMap::from([(key.to_string(), snapshot_value.to_string())]); + state.restore_child_env(&mut expected, |_| true); + let referenced_credentials = |text: &str| { + state + .credentials + .iter() + .filter(|credential| { + text == credential.dummy_value + || credential + .provider + .contains_embedded_value(text, &credential.dummy_value) + }) + .collect::>() + }; + let expected_credentials = referenced_credentials(snapshot_value); + if expected_credentials.is_empty() { + return false; + } + state.credential_aliases.iter().any(|alias| { + if !env_key_matches(key, &alias.env_var) { + return false; + } + let alias_credentials = referenced_credentials(&alias.dummy_value); + let same_owner = |left: &&CredentialRecord, right: &&CredentialRecord| { + left.provider.same_provider(&right.provider) + && env_key_matches(&left.env_var, &right.env_var) + && left.real_value == right.real_value + }; + if !expected_credentials.iter().all(|expected| { + alias_credentials + .iter() + .any(|actual| same_owner(expected, actual)) + }) || !alias_credentials.iter().all(|actual| { + expected_credentials + .iter() + .any(|expected| same_owner(expected, actual)) + }) { + return false; + } + let mut candidate = HashMap::from([(key.to_string(), alias.dummy_value.clone())]); + let mut identity = candidate.clone(); + if state.allow_local_binding { + state.restore_child_env(&mut candidate, |credential| { + credential.belongs_to_environment(environment_id) + && credential.host_bindings().any( + providers::CredentialHostBinding::bypasses_proxy_with_local_binding, + ) + }); + } + if env_value(&candidate, key) != Some(value) { + return false; + } + state.restore_child_env(&mut identity, |_| true); + identity == expected + }) + } + + #[cfg(test)] + pub(crate) fn host_requires_mitm(&self, host: &str, port: u16) -> bool { + self.host_requires_mitm_for_environment(host, port, /*environment_id*/ None) + } + + pub(crate) fn host_requires_mitm_for_environment( + &self, + host: &str, + port: u16, + environment_id: Option<&str>, + ) -> bool { let normalized_host = normalize_host(host); let state = self.read_state(); state.enabled - && state - .credentials - .iter() - .any(|credential| credential.matches_host(&normalized_host)) + && state.credentials.iter().any(|credential| { + credential.belongs_to_environment(environment_id) + && credential + .host_bindings() + .any(|binding| binding.requires_mitm(&normalized_host, port)) + }) } pub(crate) fn virtualize_text(&self, text: &mut String, env: &HashMap) -> bool { @@ -389,31 +836,84 @@ impl CredentialBroker { if text.as_str() == credential.dummy_value { text.clone_from(&credential.real_value); restored = true; - } else if credential.dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH - && text.contains(&credential.dummy_value) + } else if credential + .provider + .contains_embedded_value(text, &credential.dummy_value) { - *text = text.replace(&credential.dummy_value, &credential.real_value); + *text = credential.provider.replace_embedded_value( + text, + &credential.dummy_value, + &credential.real_value, + ); restored = true; } } restored } - pub(crate) fn inject_request_headers(&self, host: &str, headers: &mut HeaderMap) { - let normalized_host = normalize_host(host); + #[cfg(test)] + pub(crate) fn inject_request_headers(&self, destination: &str, headers: &mut HeaderMap) { + self.inject_request_headers_for_environment( + destination, + headers, + /*environment_id*/ None, + ); + } + + pub(crate) fn inject_request_headers_for_environment( + &self, + destination: &str, + headers: &mut HeaderMap, + environment_id: Option<&str>, + ) { + let request = if destination.contains("://") { + let Ok(request) = Url::parse(destination) else { + return; + }; + Some(request) + } else { + None + }; + let normalized_host = normalize_host( + request + .as_ref() + .and_then(Url::host_str) + .unwrap_or(destination), + ); let state = self.read_state(); if !state.enabled { return; } - let Some((credential, header_value)) = - select_credential(headers, &normalized_host, &state.credentials) - else { - return; - }; - credential - .provider - .insert_request_header(headers, header_value); + let credentials = select_credentials( + headers, + &normalized_host, + request.as_ref(), + &state.credentials, + environment_id, + ); + if credentials.iter().any(|(credential, _, _)| { + matches!( + &credential.provider, + BrokeredCredentialProvider::Configured(_) + ) + }) && let Some(request) = request.as_ref() + { + let Ok(raw_request) = destination.parse::() else { + return; + }; + let raw_path = raw_request.path(); + if raw_path != request.path() + || !crate::authorization_path::is_safe_for_authorization(raw_path) + { + return; + } + } + for (credential, header_name, header_value) in credentials { + credential + .provider + .insert_request_header(headers, header_name, header_value); + } } fn read_state(&self) -> std::sync::RwLockReadGuard<'_, CredentialBrokerState> { @@ -433,27 +933,24 @@ fn virtualize_env_var( env: &mut HashMap, state: &mut CredentialBrokerState, env_var: &str, - provider: &'static providers::CredentialProvider, + provider: BrokeredCredentialProvider, host_binding: providers::CredentialHostBinding, + environment_id: Option<&str>, ) { - let Some(real_value) = brokerable_credential_value(env, state, env_var, provider) + let previous_dummy = env_value(env, env_var) + .filter(|value| state.is_dummy_value(value)) + .map(str::to_string); + let Some(real_value) = brokerable_credential_value(env, state, env_var, &provider) .map(str::to_string) .or_else(|| { - let dummy = env_value(env, env_var)?; + let dummy = previous_dummy.as_deref()?; state .credentials .iter() .find(|credential| { credential.dummy_value == dummy - && std::ptr::eq(credential.provider, provider) - && !env_key_matches(&credential.env_var, env_var) - && credential.host_binding == host_binding - && state.credentials.iter().any(|candidate| { - env_key_matches(&candidate.env_var, env_var) - && std::ptr::eq(candidate.provider, provider) - && candidate.host_binding == host_binding - && candidate.real_value == credential.real_value - }) + && credential.provider.same_provider(&provider) + && env_key_matches(&credential.env_var, env_var) }) .map(|credential| credential.real_value.clone()) }) @@ -461,17 +958,38 @@ fn virtualize_env_var( return; }; - let dummy_value = state.register(env_var, provider, host_binding, &real_value); - set_env_value(env, env_var, dummy_value); + if let Some(dummy_value) = state.register( + env_var, + provider, + host_binding, + environment_id, + &real_value, + env, + ) { + if let Some(previous_dummy) = previous_dummy + && previous_dummy != dummy_value + { + for value in env.values_mut() { + if value.contains(&previous_dummy) { + *value = value.replace(&previous_dummy, &dummy_value); + } + } + } + set_env_value(env, env_var, dummy_value); + } } fn brokerable_credential_value<'a>( env: &'a HashMap, state: &CredentialBrokerState, env_var: &str, - provider: &providers::CredentialProvider, + provider: &BrokeredCredentialProvider, ) -> Option<&'a str> { - let real_value = env_value(env, env_var)?.trim(); + let real_value = env_value(env, env_var)?; + let real_value = match provider { + BrokeredCredentialProvider::Builtin(_) => real_value.trim(), + BrokeredCredentialProvider::Configured(_) => real_value, + }; (!real_value.is_empty() && !state.is_dummy_value(real_value) && provider.request_header_value(real_value).is_some()) @@ -479,19 +997,100 @@ fn brokerable_credential_value<'a>( } impl CredentialBrokerState { - fn observe_credential_owners(&mut self, env: &HashMap) { - // Ownership is known even when a destination is missing or invalid. - for provider in providers::credential_providers() { - for env_var in provider.sources().iter().flat_map(|source| source.env_vars) { - if let Some(real_value) = - brokerable_credential_value(env, self, env_var, provider).map(str::to_string) + fn restore_child_env( + &self, + env: &mut HashMap, + should_restore: impl Fn(&CredentialRecord) -> bool, + ) { + let credentials = self + .credentials + .iter() + .filter(|credential| should_restore(credential)) + .filter(|credential| { + env.iter().any(|(key, value)| { + !env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + && !env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) + && (value == &credential.dummy_value + || credential + .provider + .contains_embedded_value(value, &credential.dummy_value)) + }) + }) + .collect::>(); + for (key, value) in env.iter_mut() { + if env_key_matches(key, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) + || env_key_matches(key, BROKERED_CREDENTIALS_ENV_KEY) + { + continue; + } + let canonical_credential = self + .credentials + .iter() + .any(|credential| env_key_matches(key, &credential.env_var)); + if !canonical_credential + && !self.credential_aliases.iter().any(|alias| { + env_key_matches(key, &alias.env_var) && value == &alias.dummy_value + }) + { + continue; + } + for credential in &credentials { + if canonical_credential + && !self.credentials.iter().any(|candidate| { + env_key_matches(key, &candidate.env_var) + && candidate.provider.same_provider(&credential.provider) + && candidate.host_binding == credential.host_binding + && candidate.real_value == credential.real_value + }) { - self.remember_credential_owner(env_var, &real_value); + continue; + } + if value == &credential.dummy_value { + value.clone_from(&credential.real_value); + } else if credential + .provider + .contains_embedded_value(value, &credential.dummy_value) + { + *value = credential.provider.replace_embedded_value( + value, + &credential.dummy_value, + &credential.real_value, + ); } } } } + fn observe_credential_owners(&mut self, env: &HashMap) { + // Ownership is known even when a destination is missing or invalid. + let sources = providers::credential_providers() + .flat_map(|provider| { + provider.sources().iter().flat_map(move |source| { + source + .env_vars + .iter() + .map(move |key| (*key, BrokeredCredentialProvider::Builtin(provider))) + }) + }) + .chain(self.configured_providers.iter().flat_map(|provider| { + provider.config.env.iter().map(move |key| { + ( + key.as_str(), + BrokeredCredentialProvider::Configured(Arc::clone(provider)), + ) + }) + })); + let owners = sources + .filter_map(|(key, provider)| { + brokerable_credential_value(env, self, key, &provider) + .map(|value| (key.to_string(), value.to_string())) + }) + .collect::>(); + for (key, value) in owners { + self.remember_credential_owner(&key, &value); + } + } + fn remember_credential_owner(&mut self, env_var: &str, real_value: &str) { if !self .credential_owners @@ -508,34 +1107,49 @@ impl CredentialBrokerState { fn register( &mut self, env_var: &str, - provider: &'static providers::CredentialProvider, + provider: BrokeredCredentialProvider, host_binding: providers::CredentialHostBinding, + environment_id: Option<&str>, real_value: &str, - ) -> String { + existing_env: &HashMap, + ) -> Option { self.remember_credential_owner(env_var, real_value); - if let Some(existing) = self.credentials.iter().find(|credential| { - credential.env_var == env_var - && std::ptr::eq(credential.provider, provider) - && credential.host_binding == host_binding + if let Some(existing) = self.credentials.iter_mut().find(|credential| { + env_key_matches(&credential.env_var, env_var) + && credential.provider.same_provider(&provider) + && credential.belongs_to_environment(environment_id) && credential.real_value == real_value }) { - return existing.dummy_value.clone(); + existing.observe_host_binding(host_binding, existing_env); + return Some(existing.dummy_value.clone()); } - - let dummy_value = loop { - let candidate = provider.dummy_value(real_value); - if candidate != real_value && !self.is_dummy_value(&candidate) { - break candidate; - } + let Some(dummy_value) = (0..64).find_map(|_| { + let candidate = provider.dummy_value(real_value)?; + (candidate != real_value + && !existing_env + .values() + .any(|value| value.contains(&candidate)) + && !self.credentials.iter().any(|credential| { + credential.dummy_value == candidate || credential.real_value == candidate + })) + .then_some(candidate) + }) else { + tracing::warn!( + env_var, + "credential brokerage skipped: unable to generate a unique dummy credential" + ); + return None; }; self.credentials.push(CredentialRecord { env_var: env_var.to_string(), provider, host_binding, + additional_host_bindings: Vec::new(), + environment_id: environment_id.map(str::to_string), real_value: real_value.to_string(), dummy_value: dummy_value.clone(), }); - dummy_value + Some(dummy_value) } fn is_dummy_value(&self, value: &str) -> bool { @@ -545,12 +1159,10 @@ impl CredentialBrokerState { } } -impl CredentialRecord { - fn matches_host(&self, host: &str) -> bool { - self.host_binding.matches_host(host) - } -} - #[cfg(test)] #[path = "credential_broker_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "credential_broker/configured_tests.rs"] +mod configured_tests; diff --git a/codex-rs/network-proxy/src/credential_broker/configured.rs b/codex-rs/network-proxy/src/credential_broker/configured.rs new file mode 100644 index 0000000000..a4b991d2e2 --- /dev/null +++ b/codex-rs/network-proxy/src/credential_broker/configured.rs @@ -0,0 +1,417 @@ +use super::CredentialAuthMethod; +use super::CredentialProviderConfig; +use super::destination::CredentialDestination; +use super::env_value; +use super::providers; +use super::providers::CredentialHostBinding; +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use base64::Engine as _; +use rama_http::HeaderMap; +use rama_http::HeaderName; +use rama_http::HeaderValue; +use rama_http::header::AUTHORIZATION; +use rand::Rng as _; +use regex::RegexBuilder; +use regex_syntax::hir::Class; +use regex_syntax::hir::ClassBytes; +use regex_syntax::hir::ClassBytesRange; +use regex_syntax::hir::ClassUnicode; +use regex_syntax::hir::ClassUnicodeRange; +use regex_syntax::hir::Hir; +use regex_syntax::hir::HirKind; +use regex_syntax::hir::Look; +use std::collections::HashMap; + +const MAX_REGEX_BYTES: usize = 2048; +const MAX_REGEX_REPEAT: u32 = 64; +const MAX_DUMMY_ATTEMPTS: usize = 64; +const MAX_DUMMY_VALUE_BYTES: usize = 2048; + +pub(super) struct ConfiguredCredentialProvider { + pub(super) id: String, + pub(super) config: CredentialProviderConfig, + patterns: Vec, + header: Option, +} + +struct ConfiguredCredentialPattern { + full_matcher: regex::Regex, + ascii_generator: Option, + generator: rand_regex::Regex, +} + +impl ConfiguredCredentialPattern { + fn candidates(&self) -> impl Iterator + '_ { + self.ascii_generator + .iter() + .chain(std::iter::once(&self.generator)) + .flat_map(|generator| { + (0..MAX_DUMMY_ATTEMPTS).map(move |_| rand::rng().sample::(generator)) + }) + .filter(move |candidate| { + candidate.len() <= MAX_DUMMY_VALUE_BYTES + && !candidate.contains('\0') + && self.full_matcher.is_match(candidate) + }) + } +} + +#[derive(Clone, Copy)] +enum DummyAlphabet { + Ascii, + Unicode, +} + +impl ConfiguredCredentialProvider { + pub(super) fn compile(id: &str, config: &CredentialProviderConfig) -> Result { + ensure!(!id.is_empty(), "credential provider name must not be empty"); + ensure!( + !config.env.is_empty(), + "credential provider `{id}` has no environment keys" + ); + ensure!( + !config.patterns.is_empty(), + "credential provider `{id}` has no credential patterns" + ); + ensure!( + !config.url_prefixes.is_empty() || config.url_prefix_from_env.is_some(), + "credential provider `{id}` has no destination URL prefixes" + ); + ensure!( + config.env.iter().all(|key| valid_environment_key(key)), + "credential provider `{id}` has an invalid environment key" + ); + ensure!( + !config + .env + .iter() + .any(|key| super::is_credential_broker_provider_env_key(key)), + "credential provider `{id}` overlaps a built-in credential source" + ); + if let Some(key) = config.url_prefix_from_env.as_deref() { + ensure!( + valid_environment_key(key) + && !config.env.iter().any(|source| { + source.as_str() == key || cfg!(windows) && source.eq_ignore_ascii_case(key) + }), + "credential provider `{id}` has an invalid host environment key" + ); + } + for destination in &config.url_prefixes { + CredentialDestination::parse(destination) + .with_context(|| format!("invalid destination for credential provider `{id}`"))?; + } + let methods = auth_methods(config); + let header = config + .header + .as_deref() + .map(|header| HeaderName::from_bytes(header.as_bytes())) + .transpose() + .with_context(|| format!("invalid header for credential provider `{id}`"))?; + ensure!( + methods.contains(&CredentialAuthMethod::Header) == header.is_some(), + "credential provider `{id}` requires a header name exactly when using header authentication" + ); + ensure!( + config.prefix.is_none() || header.is_some(), + "credential provider `{id}` requires header authentication to use a header prefix" + ); + let patterns = config + .patterns + .iter() + .map(|pattern| { + ensure!( + pattern.len() <= MAX_REGEX_BYTES, + "credential pattern exceeds {MAX_REGEX_BYTES} bytes" + ); + let parsed = regex_syntax::parse(pattern)?; + let ascii_generator = rand_regex::Regex::with_hir( + dummy_credential_pattern(parsed.clone(), DummyAlphabet::Ascii), + MAX_REGEX_REPEAT, + ) + .ok(); + let generator = rand_regex::Regex::with_hir( + dummy_credential_pattern(parsed.clone(), DummyAlphabet::Unicode), + MAX_REGEX_REPEAT, + )?; + let full_pattern = + Hir::concat(vec![Hir::look(Look::Start), parsed, Hir::look(Look::End)]) + .to_string(); + let full_matcher = RegexBuilder::new(&full_pattern) + .size_limit(1 << 20) + .dfa_size_limit(1 << 20) + .build()?; + ensure!( + !full_matcher.is_match(""), + "credential pattern must not match an empty value" + ); + ensure!( + generator.is_utf8() && generator.capacity() <= MAX_REGEX_BYTES, + "credential pattern must generate bounded UTF-8 values" + ); + let compiled = ConfiguredCredentialPattern { + full_matcher, + ascii_generator, + generator, + }; + ensure!( + compiled.candidates().next().is_some(), + "credential pattern could not independently generate a matching dummy" + ); + Ok(compiled) + }) + .collect::>>() + .with_context(|| format!("invalid credential pattern for provider `{id}`"))?; + + Ok(Self { + id: id.to_string(), + config: config.clone(), + patterns, + header, + }) + } + + pub(super) fn matches_value(&self, value: &str) -> bool { + self.patterns + .iter() + .any(|pattern| pattern.full_matcher.is_match(value)) + } + + pub(super) fn host_binding( + &self, + env: &HashMap, + ) -> Option { + let mut destinations = self + .config + .url_prefixes + .iter() + .filter_map(|destination| CredentialDestination::parse(destination).ok()) + .collect::>(); + if let Some(destination) = self.dynamic_destination(env) + && !destinations.contains(&destination) + { + destinations.push(destination); + } + (!destinations.is_empty()).then_some(CredentialHostBinding::ConfiguredHosts(destinations)) + } + + pub(super) fn dynamic_destination( + &self, + env: &HashMap, + ) -> Option { + let key = self.config.url_prefix_from_env.as_deref()?; + CredentialDestination::parse(env_value(env, key)?) + .ok() + .filter(|destination| !destination.is_wildcard()) + } + + pub(super) fn dummy_value(&self, real_value: &str) -> Option { + let pattern = self + .patterns + .iter() + .find(|pattern| pattern.full_matcher.is_match(real_value))?; + pattern.candidates().find(|candidate| { + candidate != real_value && self.preserves_usable_auth_methods(real_value, candidate) + }) + } + + pub(super) fn request_header_value(&self, value: &str) -> Option { + auth_methods(&self.config) + .iter() + .find_map(|method| self.request_header_value_for_method(*method, value)) + } + + fn preserves_usable_auth_methods(&self, real_value: &str, dummy_value: &str) -> bool { + auth_methods(&self.config).iter().all(|method| { + // Preserve whether clients serialize a whole Basic pair or one component. + if *method == CredentialAuthMethod::Basic + && real_value.contains(':') != dummy_value.contains(':') + { + return false; + } + let Some(real_header) = self.request_header_value_for_method(*method, real_value) + else { + return true; + }; + let Some(dummy_header) = self.request_header_value_for_method(*method, dummy_value) + else { + return false; + }; + if dummy_header.as_bytes().trim_ascii() != dummy_header.as_bytes() { + return false; + } + let header = match method { + CredentialAuthMethod::Bearer + | CredentialAuthMethod::Token + | CredentialAuthMethod::Basic => AUTHORIZATION, + CredentialAuthMethod::Header => { + let Some(header) = self.header.as_ref() else { + return false; + }; + header.clone() + } + }; + self.translate_request_headers( + &HeaderMap::from_iter([(header.clone(), dummy_header)]), + dummy_value, + real_value, + ) + .contains(&(header, real_header)) + }) + } + + fn request_header_value_for_method( + &self, + method: CredentialAuthMethod, + value: &str, + ) -> Option { + let value = match method { + CredentialAuthMethod::Bearer => format!("Bearer {value}"), + CredentialAuthMethod::Token => format!("token {value}"), + CredentialAuthMethod::Basic => format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(value) + ), + CredentialAuthMethod::Header => { + format!( + "{}{value}", + self.config.prefix.as_deref().unwrap_or_default() + ) + } + }; + HeaderValue::from_str(&value) + .ok() + .filter(|header| header.to_str().is_ok()) + } + + pub(super) fn translate_request_headers( + &self, + headers: &HeaderMap, + expected_value: &str, + replacement_value: &str, + ) -> Vec<(HeaderName, HeaderValue)> { + let mut translated = Vec::new(); + for method in auth_methods(&self.config) { + match method { + CredentialAuthMethod::Bearer + | CredentialAuthMethod::Token + | CredentialAuthMethod::Basic => { + let Some(header) = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + else { + continue; + }; + let Some((scheme, _)) = header.split_once(' ') else { + continue; + }; + let expected_scheme = match method { + CredentialAuthMethod::Bearer => "bearer", + CredentialAuthMethod::Token => "token", + CredentialAuthMethod::Basic => "basic", + CredentialAuthMethod::Header => continue, + }; + if !scheme.eq_ignore_ascii_case(expected_scheme) { + continue; + } + if let Some(value) = providers::translate_standard_request_header( + headers, + expected_value, + replacement_value, + ) && !translated.iter().any(|(header, _)| header == AUTHORIZATION) + { + translated.push((AUTHORIZATION, value)); + } + } + CredentialAuthMethod::Header => { + let Some(header) = self.header.as_ref() else { + continue; + }; + let Some(value) = headers.get(header).and_then(|value| value.to_str().ok()) + else { + continue; + }; + let prefix = self.config.prefix.as_deref().unwrap_or_default(); + if value == format!("{prefix}{expected_value}") + && !translated.iter().any(|(name, _)| name == header) + && let Ok(value) = + HeaderValue::from_str(&format!("{prefix}{replacement_value}")) + { + translated.push((header.clone(), value)); + } + } + } + } + translated + } +} + +fn dummy_credential_pattern(hir: Hir, alphabet: DummyAlphabet) -> Hir { + match hir.into_kind() { + HirKind::Empty => Hir::empty(), + HirKind::Literal(literal) => { + if matches!(alphabet, DummyAlphabet::Ascii) && !literal.0.is_ascii() { + Hir::fail() + } else { + Hir::literal(literal.0) + } + } + HirKind::Class(mut class) => { + if matches!(alphabet, DummyAlphabet::Ascii) { + match &mut class { + Class::Unicode(class) => { + class.intersect(&ClassUnicode::new([ClassUnicodeRange::new(' ', '~')])) + } + Class::Bytes(class) => { + class.intersect(&ClassBytes::new([ClassBytesRange::new(b' ', b'~')])) + } + } + } + Hir::class(class) + } + HirKind::Look(_) => Hir::empty(), + HirKind::Repetition(mut repetition) => { + repetition.sub = Box::new(dummy_credential_pattern(*repetition.sub, alphabet)); + Hir::repetition(repetition) + } + HirKind::Capture(mut capture) => { + capture.sub = Box::new(dummy_credential_pattern(*capture.sub, alphabet)); + Hir::capture(capture) + } + HirKind::Concat(expressions) => Hir::concat( + expressions + .into_iter() + .map(|expression| dummy_credential_pattern(expression, alphabet)) + .collect(), + ), + HirKind::Alternation(expressions) => { + let mut expressions = expressions + .into_iter() + .map(|expression| dummy_credential_pattern(expression, alphabet)) + .filter(|expression| expression.properties().minimum_len().is_some()) + .collect::>(); + expressions.sort_by_key(|expression| { + std::cmp::Reverse(expression.properties().maximum_len().unwrap_or(usize::MAX)) + }); + Hir::alternation(expressions) + } + } +} + +fn auth_methods(config: &CredentialProviderConfig) -> &[CredentialAuthMethod] { + if config.auth.is_empty() { + &[CredentialAuthMethod::Bearer] + } else { + &config.auth + } +} + +pub(super) fn valid_environment_key(key: &str) -> bool { + let mut chars = key.chars(); + chars + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) + && chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) +} diff --git a/codex-rs/network-proxy/src/credential_broker/configured_tests.rs b/codex-rs/network-proxy/src/credential_broker/configured_tests.rs new file mode 100644 index 0000000000..78ab0f4d83 --- /dev/null +++ b/codex-rs/network-proxy/src/credential_broker/configured_tests.rs @@ -0,0 +1,1338 @@ +use super::CredentialAuthMethod; +use super::CredentialBroker; +use super::CredentialProviderConfig; +use super::brokered_credential_marker_env_keys; +use super::brokered_credential_value_env_keys; +use crate::NetworkProxyConfig; +use base64::Engine as _; +use pretty_assertions::assert_eq; +use rama_http::HeaderMap; +use rama_http::HeaderValue; +use rama_http::header::AUTHORIZATION; +use std::collections::BTreeMap; +use std::collections::HashMap; + +fn broker_for(provider: CredentialProviderConfig) -> CredentialBroker { + let broker = CredentialBroker::new(/*enabled*/ true); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + credential_providers: BTreeMap::from([("custom".to_string(), provider)]), + ..NetworkProxyConfig::default() + }); + broker +} + +#[test] +fn local_proxy_bypass_preserves_credentials_and_aliases_across_reload() { + let token = "vendor_abcdefghijklmnopqrstuvwx"; + let github = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"; + for (destination, bypassed) in [ + ("http://localhost:1234/v1", true), + ("http://127.0.0.1:1234/v1", true), + ("http://[::1]:1234/v1", true), + ("https://10.1.2.3/v1", true), + ("https://172.16.2.3/v1", true), + ("https://192.168.2.3/v1", true), + ("https://api.localhost/v1", true), + ("https://127.0.0.2/v1", false), + ("https://172.32.2.3/v1", false), + ("https://api.vendor.example/v1", false), + ] { + let broker = CredentialBroker::new(/*enabled*/ true); + let mut config = NetworkProxyConfig { + credential_broker: true, + credential_providers: BTreeMap::from([( + "vendor".to_string(), + CredentialProviderConfig { + env: vec!["VENDOR_TOKEN".to_string()], + patterns: vec!["^vendor_[a-z]{24}$".to_string()], + url_prefixes: vec!["https://public.vendor.example/v1".to_string()], + url_prefix_from_env: Some("VENDOR_URL".to_string()), + ..CredentialProviderConfig::default() + }, + )]), + ..NetworkProxyConfig::default() + }; + broker.configure(&config); + let mut env = HashMap::from([ + ("VENDOR_TOKEN".to_string(), token.to_string()), + ("VENDOR_URL".to_string(), destination.to_string()), + ("AUTH_HEADER".to_string(), format!("Bearer {token}")), + ("GH_TOKEN".to_string(), github.to_string()), + ]); + broker.virtualize_child_env(&mut env); + let dummy = env["VENDOR_TOKEN"].clone(); + let github_dummy = env["GH_TOKEN"].clone(); + assert_ne!(dummy, token); + assert_ne!(github_dummy, github); + + config.allow_local_binding = true; + let revision = broker.config_revision(); + broker.configure(&config); + assert_eq!(broker.config_revision(), revision + 1); + for _ in 0..2 { + broker.virtualize_child_env(&mut env); + let expected = if bypassed { token } else { &dummy }; + assert_eq!( + (&env["VENDOR_TOKEN"], &env["AUTH_HEADER"], &env["GH_TOKEN"]), + ( + &expected.to_string(), + &format!("Bearer {expected}"), + &github_dummy + ), + "{destination}" + ); + assert_eq!( + brokered_credential_value_env_keys(&env), + if bypassed { + vec!["GH_TOKEN"] + } else { + vec!["AUTH_HEADER", "GH_TOKEN", "VENDOR_TOKEN"] + } + ); + } + config.allow_local_binding = false; + let mut snapshot_env = env.clone(); + broker.virtualize_snapshot_env(&mut snapshot_env, /*environment_id*/ None); + assert_eq!(snapshot_env["VENDOR_TOKEN"], dummy); + assert_eq!(snapshot_env["AUTH_HEADER"], format!("Bearer {dummy}")); + broker.configure(&config); + broker.virtualize_child_env(&mut env); + assert_eq!(env["VENDOR_TOKEN"], dummy); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {dummy}")); + } +} + +#[test] +fn child_alias_identity_survives_scoped_dummies_and_partial_direct_restoration() { + let local = "local_abcdefghijklmnopqrstuvwx"; + let remote = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"; + for allow_local_binding in [false, true] { + let broker = CredentialBroker::new(/*enabled*/ true); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + allow_local_binding, + credential_providers: BTreeMap::from([( + "local".to_string(), + CredentialProviderConfig { + env: vec!["LOCAL_TOKEN".to_string()], + patterns: vec!["^local_[a-z]{24}$".to_string()], + url_prefixes: vec!["http://127.0.0.1:1234".to_string()], + ..CredentialProviderConfig::default() + }, + )]), + ..NetworkProxyConfig::default() + }); + let real_alias = format!("Local {local}; Remote {remote}"); + let mut env = HashMap::from([ + ("LOCAL_TOKEN".to_string(), local.to_string()), + ("GH_TOKEN".to_string(), remote.to_string()), + ("AUTH_HEADER".to_string(), real_alias.clone()), + ]); + let mut snapshot = env.clone(); + broker.virtualize_snapshot_env(&mut snapshot, Some("snapshot")); + let snapshot_alias = snapshot["AUTH_HEADER"].clone(); + broker.virtualize_child_env_for_environment(&mut env, Some("child")); + env.remove("LOCAL_TOKEN"); + env.remove("GH_TOKEN"); + assert_eq!(env["AUTH_HEADER"].contains(local), allow_local_binding); + assert!(!env["AUTH_HEADER"].contains(remote)); + assert!(broker.child_alias_matches( + "AUTH_HEADER", + &env["AUTH_HEADER"], + &snapshot_alias, + Some("child") + )); + assert!(!broker.child_alias_matches( + "AUTH_HEADER", + &real_alias, + &snapshot_alias, + Some("child") + )); + assert!(!broker.child_alias_matches( + "AUTH_HEADER", + &format!("{} altered", env["AUTH_HEADER"]), + &snapshot_alias, + Some("child") + )); + assert!(!broker.child_alias_matches( + "OTHER_HEADER", + &env["AUTH_HEADER"], + &snapshot_alias, + Some("child") + )); + } +} + +#[test] +fn local_proxy_bypass_is_scoped_for_inherited_credentials_and_aliases() { + let token = "vendor_abcdefghijklmnopqrstuvwx"; + for parent_is_local in [false, true] { + for retain_source in [false, true] { + let broker = CredentialBroker::new(/*enabled*/ true); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + allow_local_binding: true, + credential_providers: BTreeMap::from([( + "vendor".to_string(), + CredentialProviderConfig { + env: vec!["VENDOR_TOKEN".to_string()], + patterns: vec!["^vendor_[a-z]{24}$".to_string()], + url_prefix_from_env: Some("VENDOR_URL".to_string()), + ..CredentialProviderConfig::default() + }, + )]), + ..NetworkProxyConfig::default() + }); + let local = "http://127.0.0.1:1234/v1"; + let public = "https://api.vendor.example/v1"; + let mut snapshot = HashMap::from([ + ("VENDOR_TOKEN".to_string(), token.to_string()), + ( + "VENDOR_URL".to_string(), + if parent_is_local { local } else { public }.to_string(), + ), + ("AUTH_HEADER".to_string(), format!("Bearer {token}")), + ]); + broker.virtualize_snapshot_env(&mut snapshot, Some("parent")); + let dummy = snapshot["VENDOR_TOKEN"].clone(); + let snapshot_alias = snapshot["AUTH_HEADER"].clone(); + let mut child = snapshot.clone(); + child.insert( + "VENDOR_URL".to_string(), + if parent_is_local { public } else { local }.to_string(), + ); + if !retain_source { + child.remove("VENDOR_TOKEN"); + } + for (id, env, bypasses) in [ + ("child", &mut child, !parent_is_local), + ("parent", &mut snapshot, parent_is_local), + ] { + broker.virtualize_child_env_for_environment(env, Some(id)); + let expected = if bypasses { token } else { &dummy }; + assert_eq!( + env.get("VENDOR_TOKEN").map(String::as_str), + (retain_source || id == "parent").then_some(expected) + ); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {expected}")); + assert!(broker.child_alias_matches( + "AUTH_HEADER", + &env["AUTH_HEADER"], + &snapshot_alias, + Some(id) + )); + if !bypasses { + assert!(!broker.child_alias_matches( + "AUTH_HEADER", + &format!("Bearer {token}"), + &snapshot_alias, + Some(id) + )); + } + } + } + } +} + +#[test] +fn configured_provider_virtualizes_credentials_aliases_and_snapshots() { + let token = "stripe_live_abcdefghijklmnopqrstuvwx"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["STRIPE_API_KEY".to_string()], + patterns: vec!["^stripe_live_[a-z]{24}$".to_string()], + url_prefixes: vec!["api.stripe.com".to_string(), "*.stripe.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + ("STRIPE_API_KEY".to_string(), token.to_string()), + ("AUTH_HEADER".to_string(), format!("Bearer {token}")), + ]); + + broker.virtualize_child_env(&mut env); + + let dummy = &env["STRIPE_API_KEY"]; + assert_ne!(dummy, token); + assert!( + regex::Regex::new("^stripe_live_[a-z]{24}$") + .expect("valid credential pattern") + .is_match(dummy) + ); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {dummy}")); + assert_eq!( + brokered_credential_value_env_keys(&env), + vec!["AUTH_HEADER", "STRIPE_API_KEY"] + ); + let mut snapshot = format!("token={token}"); + assert!(broker.virtualize_text(&mut snapshot, &env)); + assert_eq!(snapshot, format!("token={dummy}")); + assert!(broker.host_requires_mitm("api.stripe.com", /*port*/ 443)); + assert!(broker.host_requires_mitm("billing.stripe.example", /*port*/ 443)); + assert!(!broker.host_requires_mitm("stripe.example", /*port*/ 443)); + assert!(!broker.host_requires_mitm("attacker.example", /*port*/ 443)); + let dummy_header = format!("Bearer {dummy}"); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&dummy_header).expect("valid dummy authentication"), + ); + broker.inject_request_headers("https://attacker.example/", &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(dummy_header.as_str()) + ); + env.remove("STRIPE_API_KEY"); + broker.virtualize_child_env(&mut env); + assert_eq!( + brokered_credential_marker_env_keys(&env), + vec!["AUTH_HEADER", "STRIPE_API_KEY"] + ); + assert_eq!( + brokered_credential_value_env_keys(&env), + vec!["AUTH_HEADER"] + ); +} + +#[test] +fn configured_provider_preserves_credentials_without_a_resolved_destination() { + let token = "stripe_live_abcdefghijklmnopqrstuvwx"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["STRIPE_API_KEY".to_string()], + patterns: vec!["^stripe_live_[a-z]{24}$".to_string()], + url_prefix_from_env: Some("STRIPE_HOST".to_string()), + ..CredentialProviderConfig::default() + }); + let mut bound_env = HashMap::from([ + ("STRIPE_API_KEY".to_string(), token.to_string()), + ("STRIPE_HOST".to_string(), "api.stripe.com".to_string()), + ]); + broker.virtualize_child_env(&mut bound_env); + assert_ne!(bound_env["STRIPE_API_KEY"], token); + + let mut env = HashMap::from([("STRIPE_API_KEY".to_string(), token.to_string())]); + + broker.virtualize_child_env(&mut env); + + assert_eq!(env["STRIPE_API_KEY"], token); +} + +#[test] +fn configured_provider_accepts_anchored_alternatives_and_ascii_character_classes() { + for (pattern, token) in [ + ("(?i)^token_[a-z]{24}$", "TOKEN_abcdefghijklmnopqrstuvwx"), + ("(?i:^token_[a-z]{24}$)", "TOKEN_abcdefghijklmnopqrstuvwx"), + ( + "(?x) ^ token_[a-z]{24} $", + "token_abcdefghijklmnopqrstuvwx", + ), + ( + "(?x)^token_[a-z]{24}$ # provider token", + "token_abcdefghijklmnopqrstuvwx", + ), + ( + "(?x)^token_[a-z]{24} # [legacy provider\n$", + "token_abcdefghijklmnopqrstuvwx", + ), + (r"\btoken_[a-z]{24}\b", "token_abcdefghijklmnopqrstuvwx"), + (r"\Atoken_[a-z]{24}\z", "token_abcdefghijklmnopqrstuvwx"), + ( + "^(token_[a-z]{8}|token_[a-z]{24})$", + "token_abcdefghijklmnopqrstuvwx", + ), + (r"^token_\d{24}$", "token_012345678901234567890123"), + ("^token_.{24}$", "token_abcdefghijklmnopqrstuvwx"), + ("^token_[^:]{24}$", "token_abcdefghijklmnopqrstuvwx"), + ("^token_[]$a-z]{8}$", "token_a$bcd]ef"), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("PROVIDER_TOKEN".to_string(), token.to_string())]); + + broker.virtualize_child_env(&mut env); + + let dummy = &env["PROVIDER_TOKEN"]; + assert_ne!(dummy, token, "credential pattern: {pattern}"); + assert!(dummy.is_ascii(), "credential pattern: {pattern}"); + assert!( + regex::Regex::new(pattern) + .expect("valid credential pattern") + .is_match(dummy), + "credential pattern: {pattern}" + ); + + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {dummy}")).expect("valid dummy authentication"), + ); + broker.inject_request_headers("https://api.provider.example/", &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(format!("Bearer {token}").as_str()), + "credential pattern: {pattern}" + ); + } +} + +#[test] +fn configured_provider_generates_bounded_independent_dummies() { + let long_token = format!("token_{}", "a".repeat(4096)); + let long_broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["^token_[a-z]+$".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut long_env = HashMap::from([("PROVIDER_TOKEN".to_string(), long_token.clone())]); + + long_broker.virtualize_child_env(&mut long_env); + + let long_dummy = &long_env["PROVIDER_TOKEN"]; + assert_ne!(long_dummy, &long_token); + assert!(long_dummy.len() <= 2048); + + let narrow_token = format!("token_{}", "a".repeat(64)); + for _ in 0..3 { + let narrow_broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["^token_[ab]{64}$".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }); + let mut narrow_env = HashMap::from([("PROVIDER_TOKEN".to_string(), narrow_token.clone())]); + + narrow_broker.virtualize_child_env(&mut narrow_env); + + let changed = narrow_token + .bytes() + .zip(narrow_env["PROVIDER_TOKEN"].bytes()) + .filter(|(real, dummy)| real != dummy) + .count(); + assert!(changed >= 12, "dummy changed only {changed} bytes"); + } +} + +#[test] +fn configured_provider_reload_preserves_credentials_and_rejects_overlapping_sources() { + let token = "provider_abcdefghijklmnopqrstuvwx"; + let provider = CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["provider_[a-z]{24}".to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + ..CredentialProviderConfig::default() + }; + let broker = broker_for(provider.clone()); + let mut env = HashMap::from([("PROVIDER_TOKEN".to_string(), token.to_string())]); + broker.virtualize_child_env(&mut env); + let dummy = env["PROVIDER_TOKEN"].clone(); + + let overlapping_builtin = CredentialProviderConfig { + env: vec!["GH_TOKEN".to_string()], + url_prefixes: vec!["builtin-overlap.example".to_string()], + ..provider.clone() + }; + let overlapping_configured = CredentialProviderConfig { + url_prefixes: vec!["configured-overlap.example".to_string()], + ..provider.clone() + }; + let unrelated = CredentialProviderConfig { + env: vec!["ANOTHER_TOKEN".to_string()], + url_prefixes: vec!["another.example".to_string()], + ..provider.clone() + }; + broker.configure(&NetworkProxyConfig { + credential_broker: true, + credential_providers: BTreeMap::from([ + ("aaa-overlap".to_string(), overlapping_configured), + ("builtin".to_string(), overlapping_builtin), + ("custom".to_string(), provider), + ("second".to_string(), unrelated), + ]), + ..NetworkProxyConfig::default() + }); + + broker.virtualize_child_env(&mut env); + assert_eq!(env["PROVIDER_TOKEN"], dummy); + assert!(!broker.host_requires_mitm("builtin-overlap.example", /*port*/ 443)); + assert!(!broker.host_requires_mitm("configured-overlap.example", /*port*/ 443)); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {dummy}")).expect("valid authentication"), + ); + broker.inject_request_headers("https://api.provider.example/", &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(format!("Bearer {token}").as_str()) + ); +} + +#[test] +fn configured_provider_preserves_bearer_token_basic_and_custom_header_auth() { + let token = "provider_abcdefghijklmnopqrstuvwx"; + let github_token = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + for (method, header_name, header_value) in [ + ( + CredentialAuthMethod::Bearer, + "authorization", + format!("Bearer {token}"), + ), + ( + CredentialAuthMethod::Token, + "authorization", + format!("token {token}"), + ), + ( + CredentialAuthMethod::Basic, + "authorization", + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("user:{token}")) + ), + ), + ( + CredentialAuthMethod::Header, + "x-api-key", + format!("Key {token}"), + ), + ] { + let host = if method == CredentialAuthMethod::Header { + "api.github.com" + } else { + "api.provider.example" + }; + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["provider_[a-z]{24}".to_string()], + url_prefixes: vec![host.to_string()], + auth: if method == CredentialAuthMethod::Header { + vec![CredentialAuthMethod::Bearer, method] + } else { + vec![method] + }, + header: (method == CredentialAuthMethod::Header).then(|| "x-api-key".to_string()), + prefix: (method == CredentialAuthMethod::Header).then(|| "Key ".to_string()), + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("PROVIDER_TOKEN".to_string(), token.to_string())]); + if method == CredentialAuthMethod::Header { + env.insert("GH_TOKEN".to_string(), github_token.to_string()); + } + broker.virtualize_child_env(&mut env); + let dummy = &env["PROVIDER_TOKEN"]; + let dummy_header = if method == CredentialAuthMethod::Basic { + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("user:{dummy}")) + ) + } else { + header_value.replace(token, dummy) + }; + let mut headers = HeaderMap::new(); + headers.insert( + rama_http::HeaderName::from_bytes(header_name.as_bytes()).expect("valid header"), + HeaderValue::from_str(&dummy_header).expect("valid dummy authentication"), + ); + if method == CredentialAuthMethod::Header { + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer unrelated-session-token"), + ); + } + + broker.inject_request_headers(&format!("https://{host}/"), &mut headers); + + assert_eq!( + headers + .get(header_name) + .and_then(|value| value.to_str().ok()), + Some(header_value.as_str()), + "authentication method: {method:?}" + ); + if method == CredentialAuthMethod::Header { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {dummy}")) + .expect("valid dummy authentication"), + ); + headers.insert( + "x-api-key", + HeaderValue::from_str(&dummy_header).expect("valid dummy authentication"), + ); + broker.inject_request_headers(&format!("https://{host}/"), &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(format!("Bearer {token}").as_str()) + ); + assert_eq!( + headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + Some(header_value.as_str()) + ); + + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", env["GH_TOKEN"])) + .expect("valid dummy authentication"), + ); + headers.insert( + "x-api-key", + HeaderValue::from_str(&dummy_header).expect("valid dummy authentication"), + ); + broker.inject_request_headers(&format!("https://{host}/"), &mut headers); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(format!("Bearer {github_token}").as_str()) + ); + assert_eq!( + headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + Some(header_value.as_str()) + ); + } + } +} + +#[test] +fn configured_provider_translates_full_basic_pairs_only_on_exact_match() { + let token = "user:provider_abcdefghijklmnopqrstuvwx:abcd"; + for (pattern, brokered) in [ + ("^user:provider_[a-z]{24}:[a-z]{4}$", true), + ("^[a-z]{4}(?::provider_[a-z]{24}:[a-z]{4})?$", true), + ( + "^(?:user:provider_abcdefghijklmnopqrstuvwx:abcd|[a-z]{32})$", + false, + ), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_CREDENTIALS".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + auth: vec![CredentialAuthMethod::Basic], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("PROVIDER_CREDENTIALS".to_string(), token.to_string())]); + broker.virtualize_child_env(&mut env); + let dummy = &env["PROVIDER_CREDENTIALS"]; + assert_eq!(dummy != token, brokered); + + for (destination, suffix, injected) in [ + ("https://api.provider.example/", "", true), + ("https://api.provider.example/", "extra", false), + ("https://other.example/", "", false), + ] { + // curl --user adds an empty password when the argument has no colon. + let pair = if dummy.contains(':') { + dummy.clone() + } else { + format!("{dummy}:") + }; + let input = format!("{pair}{suffix}"); + let header = |value: &str| { + HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!( + "bAsIc {}", + base64::engine::general_purpose::STANDARD.encode(value) + )) + .expect("valid Basic authentication"), + )]) + }; + let mut headers = header(&input); + + broker.inject_request_headers(destination, &mut headers); + + assert_eq!(headers, header(if injected { token } else { &input })); + } + } +} + +#[test] +fn configured_provider_generates_unicode_basic_dummies() { + for (pattern, secret, token) in [ + ( + "^pass_wörd[a-z]{8} $", + "pass_wördabcdefgh ".to_string(), + "pass_wördabcdefgh ".to_string(), + ), + ( + r"^pass_\B[éöü]{24}[a-z]$", + "éöü".repeat(8), + format!("pass_{}a", "éöü".repeat(8)), + ), + (r"^(?:α{32}|β{32})$", "α".repeat(32), "α".repeat(32)), + ( + r"^(?:α{32}|β{32}|\x00{32})$", + "α".repeat(32), + "α".repeat(32), + ), + ( + r"^pass_\B(?:[éöü]{24}|[-+]{24})[a-z]$", + "éöü".repeat(8), + format!("pass_{}a", "éöü".repeat(8)), + ), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_PASSWORD".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: vec!["api.provider.example".to_string()], + auth: vec![CredentialAuthMethod::Basic], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("PROVIDER_PASSWORD".to_string(), token.clone())]); + + broker.virtualize_child_env(&mut env); + + let dummy = &env["PROVIDER_PASSWORD"]; + assert_ne!(dummy, &token); + assert!(!dummy.contains('\0')); + assert!(!dummy.contains(&secret)); + assert!(regex::Regex::new(pattern).unwrap().is_match(dummy)); + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("user:{dummy}")) + )) + .expect("valid dummy authentication"), + )]); + broker.inject_request_headers("https://api.provider.example/", &mut headers); + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("user:{token}")) + ); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(expected.as_str()) + ); + } +} + +#[test] +fn configured_provider_does_not_break_usable_auth_methods_when_generating_dummies() { + let token = "provider_abcdefghijklmnopqrstuvwx"; + for (alternative, method) in [ + "β{32}", + "provider_[a-z]{23} ", + " provider_[a-z]{23}", + "provider_[a-z]{23}\\t", + "provider_[a-z]{12}:[a-z]{12}", + ] + .into_iter() + .flat_map(|alternative| { + [CredentialAuthMethod::Bearer, CredentialAuthMethod::Header] + .map(|method| (alternative, method)) + }) + .chain(std::iter::once((r"\x00{24}", CredentialAuthMethod::Basic))) + { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_PASSWORD".to_string()], + patterns: vec![format!("^(?:{token}|{alternative})$")], + url_prefixes: vec!["api.provider.example".to_string()], + auth: vec![method, CredentialAuthMethod::Basic], + header: (method == CredentialAuthMethod::Header).then(|| "x-api-key".to_string()), + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("PROVIDER_PASSWORD".to_string(), token.to_string())]); + + broker.virtualize_child_env(&mut env); + + assert_eq!(env["PROVIDER_PASSWORD"], token); + } +} + +#[test] +fn configured_basic_dummies_preserve_username_password_and_whole_value_auth() { + let token = "provider_abcdefghijklmnopqrstuvwx"; + let second = "provider_zyxwvutsrqponmlkjihgfedcb"; + let github = "ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"; + let broker = broker_for(CredentialProviderConfig { + env: vec![ + "PROVIDER_TOKEN".to_string(), + "PROVIDER_SECRET".to_string(), + "PROVIDER_COPY".to_string(), + ], + patterns: vec!["^provider_(?:[a-z]{24}|[a-z]{12}:[a-z]{12})$".to_string()], + url_prefixes: vec!["https://api.github.com/v1".to_string()], + auth: vec![CredentialAuthMethod::Basic], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + ("PROVIDER_TOKEN".to_string(), token.to_string()), + ("PROVIDER_SECRET".to_string(), second.to_string()), + ("PROVIDER_COPY".to_string(), token.to_string()), + ("GH_TOKEN".to_string(), github.to_string()), + ]); + broker.virtualize_child_env(&mut env); + let dummy = &env["PROVIDER_TOKEN"]; + assert_ne!(dummy, token); + assert!(!dummy.contains(':')); + for (input, expected) in [ + ( + format!("{dummy}:x-oauth-basic"), + format!("{token}:x-oauth-basic"), + ), + (format!("user:{dummy}"), format!("user:{token}")), + (format!("{dummy}:{dummy}"), format!("{token}:{token}")), + ( + format!("{dummy}:{}", env["PROVIDER_COPY"]), + format!("{token}:{token}"), + ), + ( + format!("{}:{dummy}", env["PROVIDER_COPY"]), + format!("{token}:{token}"), + ), + ( + format!("{dummy}:{}", env["PROVIDER_SECRET"]), + format!("{token}:{second}"), + ), + ( + format!("{}:{dummy}", env["PROVIDER_SECRET"]), + format!("{second}:{token}"), + ), + ( + format!("{}:{dummy}", env["GH_TOKEN"]), + format!("{github}:{token}"), + ), + ( + format!("{dummy}:{}", env["GH_TOKEN"]), + format!("{token}:{github}"), + ), + (dummy.clone(), token.to_string()), + ] { + let headers_for = |value: &str| { + HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(value) + )) + .unwrap(), + )]) + }; + let mut headers = headers_for(&input); + broker.inject_request_headers("https://other.example/", &mut headers); + assert_eq!(headers, headers_for(&input)); + broker.inject_request_headers( + "https://api.github.com/public/%2e%2e/v1/models", + &mut headers, + ); + assert_eq!(headers, headers_for(&input)); + broker.inject_request_headers("https://api.github.com/v1/models", &mut headers); + assert_eq!(headers, headers_for(&expected)); + } +} + +#[test] +fn configured_provider_destination_history_is_scoped_to_the_environment() { + let token = "stripe_live_abcdefghijklmnopqrstuvwx"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["STRIPE_API_KEY".to_string()], + patterns: vec!["^stripe_live_[a-z]{24}$".to_string()], + url_prefixes: vec!["static.example".to_string()], + url_prefix_from_env: Some("STRIPE_HOST".to_string()), + ..CredentialProviderConfig::default() + }); + let mut first_env = HashMap::from([ + ("STRIPE_API_KEY".to_string(), token.to_string()), + ("STRIPE_HOST".to_string(), "first.example".to_string()), + ]); + broker.virtualize_child_env_for_environment(&mut first_env, Some("first-environment")); + let first_dummy = first_env["STRIPE_API_KEY"].clone(); + let mut second_env = HashMap::from([ + ("STRIPE_HOST".to_string(), "second.example".to_string()), + ("AUTH_HEADER".to_string(), format!("Bearer {first_dummy}")), + ]); + + broker.virtualize_child_env_for_environment(&mut second_env, Some("second-environment")); + + let second_dummy = second_env["AUTH_HEADER"] + .strip_prefix("Bearer ") + .expect("dummy bearer credential") + .to_string(); + assert_eq!(second_dummy, first_dummy); + assert!(!second_env.contains_key("STRIPE_API_KEY")); + assert_eq!(second_env["AUTH_HEADER"], format!("Bearer {second_dummy}")); + let translated = |environment_id: &str, destination: &str, dummy: &str| { + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {dummy}")).expect("valid dummy authentication"), + )]); + broker.inject_request_headers_for_environment( + &format!("https://{destination}/"), + &mut headers, + Some(environment_id), + ); + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .expect("authorization header") + .to_string() + }; + assert_eq!( + translated("first-environment", "first.example", &first_dummy), + format!("Bearer {token}") + ); + assert_eq!( + translated("second-environment", "second.example", &second_dummy), + format!("Bearer {token}") + ); + assert_eq!( + translated("first-environment", "static.example", &first_dummy), + format!("Bearer {token}") + ); + + second_env.insert("STRIPE_HOST".to_string(), "third.example".to_string()); + broker.virtualize_child_env_for_environment(&mut second_env, Some("second-environment")); + + assert_eq!(second_env["AUTH_HEADER"], format!("Bearer {second_dummy}")); + assert_eq!( + translated("second-environment", "second.example", &second_dummy), + format!("Bearer {token}") + ); + assert_eq!( + translated("second-environment", "third.example", &second_dummy), + format!("Bearer {token}") + ); + assert_eq!( + translated("first-environment", "first.example", &first_dummy), + format!("Bearer {token}") + ); + assert_eq!( + translated("second-environment", "first.example", &second_dummy), + format!("Bearer {second_dummy}") + ); + for host in ["second.example", "third.example"] { + assert_eq!( + translated("first-environment", host, &first_dummy), + format!("Bearer {first_dummy}") + ); + } + + let revision = broker.config_revision(); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + ..NetworkProxyConfig::default() + }); + assert_eq!(broker.config_revision(), revision + 1); + for host in ["second.example", "third.example", "static.example"] { + assert_eq!( + translated("second-environment", host, &second_dummy), + format!("Bearer {second_dummy}") + ); + assert!(!broker.host_requires_mitm_for_environment( + host, + /*port*/ 443, + Some("second-environment"), + )); + } +} + +#[test] +fn configured_provider_restores_credentials_when_the_dynamic_destination_disappears() { + let token = "stripe_live_abcdefghijklmnopqrstuvwx"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["STRIPE_API_KEY".to_string()], + patterns: vec!["^stripe_live_[a-z]{24}$".to_string()], + url_prefix_from_env: Some("STRIPE_HOST".to_string()), + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + ("STRIPE_API_KEY".to_string(), token.to_string()), + ("STRIPE_HOST".to_string(), "first.example".to_string()), + ("AUTH_HEADER".to_string(), format!("Bearer {token}")), + ]); + broker.virtualize_child_env_for_environment(&mut env, Some("environment")); + let dummy = env["STRIPE_API_KEY"].clone(); + + env.remove("STRIPE_HOST"); + broker.virtualize_child_env_for_environment(&mut env, Some("environment")); + + assert_eq!(env["STRIPE_API_KEY"], token); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {token}")); + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {dummy}")).expect("valid dummy authentication"), + )]); + broker.inject_request_headers_for_environment( + "https://first.example/", + &mut headers, + Some("environment"), + ); + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(format!("Bearer {dummy}").as_str()) + ); + assert!(!broker.host_requires_mitm_for_environment( + "first.example", + /*port*/ 443, + Some("environment") + )); +} + +#[test] +fn explicit_invalid_destinations_clear_previous_dynamic_bindings() { + for (key, host_key, token, static_host) in [ + ( + "GH_ENTERPRISE_TOKEN", + "GH_HOST", + "ghp_abcdefghijklmnopqrstuvwxyz0123456789", + None, + ), + ( + "GITHUB_ENTERPRISE_TOKEN", + "GH_HOST", + "ghp_abcdefghijklmnopqrstuvwxyz0123456789", + None, + ), + ( + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789", + Some("api.openai.com"), + ), + ( + "PROVIDER_TOKEN", + "PROVIDER_ENDPOINT", + "provider_abcdefghijklmnopqrstuvwx", + Some("static.example"), + ), + ( + "PROVIDER_TOKEN", + "PROVIDER_ENDPOINT", + "provider_abcdefghijklmnopqrstuvwx", + None, + ), + ] { + for use_real in [false, true] { + for use_alias in [false, true] { + for invalid in [ + "", + " ", + "not a valid destination", + "http://untrusted.example", + ] + .into_iter() + .chain((key == "PROVIDER_TOKEN").then_some("https://*.example")) + .chain((host_key == "GH_HOST").then_some("github.com")) + .chain( + (host_key == "GH_HOST") + .then_some([ + "second.example:bad-port", + "127.0.0.1:bad-port", + "[::1]bad", + "[::1]:bad-port", + "second.example:99999", + "[example.com]", + ]) + .into_iter() + .flatten(), + ) { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["^provider_[a-z]{24}$".to_string()], + url_prefixes: static_host + .map(|host| format!("https://{host}")) + .into_iter() + .collect(), + url_prefix_from_env: Some("PROVIDER_ENDPOINT".to_string()), + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + (key.to_string(), token.to_string()), + ( + host_key.to_string(), + if host_key == "GH_HOST" { + "first.example".to_string() + } else { + "https://first.example/v1".to_string() + }, + ), + ]); + broker.virtualize_child_env(&mut env); + let dummy = env[key].clone(); + let second_host = static_host.unwrap_or("second.example"); + env.insert( + host_key.to_string(), + if host_key == "GH_HOST" { + second_host.to_string() + } else { + format!("https://{second_host}/v1") + }, + ); + broker.virtualize_child_env(&mut env); + assert!(broker.host_requires_mitm("first.example", /*port*/ 443)); + env.clear(); + let value = if use_real { token } else { &dummy }; + let (input_key, input_value) = if use_alias { + ("AUTH_HEADER", format!("Bearer {value}")) + } else { + (key, value.to_string()) + }; + env.insert(input_key.to_string(), input_value); + env.insert(host_key.to_string(), invalid.to_string()); + broker.virtualize_child_env(&mut env); + + for host in ["first.example", second_host] { + let expected = if static_host == Some(host) { + token + } else { + &dummy + }; + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {dummy}")).unwrap(), + )]); + broker.inject_request_headers(&format!("https://{host}/v1"), &mut headers); + assert_eq!( + headers[AUTHORIZATION], + format!("Bearer {expected}"), + "{key}, {invalid:?}, {host}, real={use_real}, alias={use_alias}" + ); + } + assert!(!broker.host_requires_mitm("first.example", /*port*/ 443)); + if host_key == "GH_HOST" { + for _ in 0..2 { + broker.virtualize_child_env(&mut env); + assert_eq!( + env[input_key], + if use_alias { + format!("Bearer {token}") + } else { + token.to_string() + }, + "{key}, {invalid:?}, real={use_real}, alias={use_alias}" + ); + for cloud in ["api.github.com", "github.com", "tenant.ghe.com"] { + assert!(!broker.host_requires_mitm(cloud, /*port*/ 443)); + } + } + + env.insert(host_key.to_string(), "restored.example".to_string()); + broker.virtualize_child_env(&mut env); + let restored_dummy = if use_alias { + env[input_key].strip_prefix("Bearer ").unwrap() + } else { + &env[input_key] + }; + assert_ne!(restored_dummy, token); + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {restored_dummy}")).unwrap(), + )]); + broker.inject_request_headers("https://restored.example/", &mut headers); + assert_eq!(headers[AUTHORIZATION], format!("Bearer {token}")); + assert!(!broker.host_requires_mitm("first.example", /*port*/ 443)); + assert!(!broker.host_requires_mitm("second.example", /*port*/ 443)); + assert!(!broker.host_requires_mitm("api.github.com", /*port*/ 443)); + env.clear(); + env.insert("GH_TOKEN".to_string(), token.to_string()); + broker.virtualize_child_env(&mut env); + let mut headers = HeaderMap::from_iter([( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", env["GH_TOKEN"])).unwrap(), + )]); + broker.inject_request_headers("https://api.github.com/", &mut headers); + assert_eq!(headers[AUTHORIZATION], format!("Bearer {token}")); + } + } + } + } + } +} + +#[test] +fn configured_provider_limits_injection_to_url_prefixes() { + let token = "provider_abcdefghijklmnopqrstuvwx"; + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["provider_[a-z]{24}".to_string()], + url_prefixes: vec![ + "https://root.provider.example".to_string(), + "https://api.provider.example/v1".to_string(), + "enterprise.example/v2/".to_string(), + "https://*.provider.example:8443/private".to_string(), + "http://localhost:443/v1".to_string(), + "127.0.0.1:443/v1".to_string(), + "http://[::1]:443/v1".to_string(), + "http://localhost:8080/v1".to_string(), + "localhost/v2".to_string(), + "https://localhost:443/v3".to_string(), + "https://localhost:80/v4".to_string(), + ], + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("PROVIDER_TOKEN".to_string(), token.to_string())]); + broker.virtualize_child_env(&mut env); + let dummy = &env["PROVIDER_TOKEN"]; + assert!(broker.host_requires_mitm("team.provider.example", /*port*/ 8443)); + assert!(!broker.host_requires_mitm("team.provider.example", /*port*/ 443)); + + for (destination, injected) in [ + ("root.provider.example", false), + ("https://root.provider.example/models", true), + ("https://api.provider.example/v1", true), + ("https://api.provider.example/v1/models?limit=1", true), + ("https://api.provider.example/v10/models", false), + ("https://api.provider.example/private", false), + ( + "https://api.provider.example/public/%2e%2e/v1/models", + false, + ), + ("https://api.provider.example/v1%2f../private", false), + ("http://api.provider.example/v1", false), + ("https://enterprise.example/v2/models", true), + ("https://enterprise.example/v2", false), + ("https://team.provider.example:8443/private/models", true), + ("https://team.provider.example/private/models", false), + ("https://provider.example:8443/private/models", false), + ("http://localhost:443/v1/models", true), + ("http://localhost/v1/models", false), + ("https://localhost/v1/models", false), + ("http://127.0.0.1:443/v1/models", true), + ("http://127.0.0.1/v1/models", false), + ("http://[::1]:443/v1/models", true), + ("http://[::1]/v1/models", false), + ("http://localhost:8080/v1/models", true), + ("http://localhost/v2/models", true), + ("http://localhost:443/v2/models", false), + ("https://localhost/v3/models", true), + ("http://localhost:443/v3/models", false), + ("https://localhost:80/v4/models", true), + ("https://localhost/v4/models", false), + ] { + let dummy_header = format!("Bearer {dummy}"); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&dummy_header).expect("valid dummy authentication"), + ); + + broker.inject_request_headers(destination, &mut headers); + + let expected = if injected { + format!("Bearer {token}") + } else { + dummy_header + }; + assert_eq!( + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(expected.as_str()), + "destination: {destination}" + ); + } +} + +#[test] +fn configured_provider_accepts_hostname_or_https_url_from_one_environment_key() { + let token = "provider_abcdefghijklmnopqrstuvwx"; + for (host_value, expected_host) in [ + ("enterprise.example", Some("enterprise.example")), + ("https://gateway.example/v1", Some("gateway.example")), + ("http://plaintext.example/v1", None), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["provider_[a-z]{24}".to_string()], + url_prefix_from_env: Some("PROVIDER_HOST".to_string()), + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([ + ("PROVIDER_TOKEN".to_string(), token.to_string()), + ("PROVIDER_HOST".to_string(), host_value.to_string()), + ]); + + broker.virtualize_child_env(&mut env); + + match expected_host { + Some(host) => { + assert_ne!(env["PROVIDER_TOKEN"], token); + assert!(broker.host_requires_mitm(host, /*port*/ 443)); + } + None => { + assert_eq!(env["PROVIDER_TOKEN"], token); + let mut snapshot = token.to_string(); + assert!(broker.virtualize_text(&mut snapshot, &env)); + assert_eq!(snapshot, token); + } + } + } +} + +#[test] +fn configured_provider_rejects_unsafe_destinations_and_unusable_dummy_patterns() { + let impossible_assertion = CredentialProviderConfig { + env: vec!["PROVIDER_PASSWORD".to_string()], + patterns: vec![r"^pass_\b[a-z]{24}$".to_string()], + url_prefixes: vec!["api.example".to_string()], + ..CredentialProviderConfig::default() + }; + assert!( + super::configured::ConfiguredCredentialProvider::compile("custom", &impossible_assertion) + .is_err() + ); + + for (pattern, url_prefixes, first, second) in [ + ( + "provider_[a-z]{24}", + vec!["*"], + "provider_abcdefghijklmnopqrstuvwx", + None, + ), + ( + "token_[01]", + vec!["api.example"], + "token_0", + Some("token_1"), + ), + ( + "only_one_token", + vec!["api.example"], + "only_one_token", + None, + ), + ] { + let broker = broker_for(CredentialProviderConfig { + env: vec!["FIRST_TOKEN".to_string(), "SECOND_TOKEN".to_string()], + patterns: vec![pattern.to_string()], + url_prefixes: url_prefixes.into_iter().map(str::to_string).collect(), + ..CredentialProviderConfig::default() + }); + let mut env = HashMap::from([("FIRST_TOKEN".to_string(), first.to_string())]); + if let Some(second) = second { + env.insert("SECOND_TOKEN".to_string(), second.to_string()); + } + + broker.virtualize_child_env(&mut env); + + assert_eq!(env["FIRST_TOKEN"], first); + if let Some(second) = second { + assert_eq!(env["SECOND_TOKEN"], second); + } + let mut snapshot = first.to_string(); + assert!(broker.virtualize_text(&mut snapshot, &env)); + assert_eq!(snapshot, first); + } +} diff --git a/codex-rs/network-proxy/src/credential_broker/destination.rs b/codex-rs/network-proxy/src/credential_broker/destination.rs new file mode 100644 index 0000000000..a72db716ba --- /dev/null +++ b/codex-rs/network-proxy/src/credential_broker/destination.rs @@ -0,0 +1,158 @@ +use crate::authorization_path::is_safe_for_authorization; +use crate::policy::Host; +use crate::policy::is_loopback_host; +use crate::policy::normalize_host; +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use anyhow::ensure; +use url::Url; + +#[derive(Clone, PartialEq, Eq)] +pub(super) struct CredentialDestination { + scheme: CredentialDestinationScheme, + host: String, + wildcard: bool, + port: u16, + path_prefix: String, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CredentialDestinationScheme { + Http, + Https, +} + +impl CredentialDestinationScheme { + fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } + + fn default_port(self) -> u16 { + match self { + Self::Http => 80, + Self::Https => 443, + } + } +} + +impl CredentialDestination { + pub(super) fn parse(value: &str) -> Result { + let value = value.trim(); + let (explicit_scheme, authority) = value + .split_once("://") + .map_or((None, value), |(scheme, authority)| { + (Some(scheme), authority) + }); + let (wildcard, authority) = authority + .strip_prefix("*.") + .map_or((false, authority), |suffix| (true, suffix)); + let authority = if wildcard { + format!("wildcard.{authority}") + } else { + authority.to_string() + }; + let url = Url::parse(&format!("https://{authority}"))?; + ensure!( + url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(), + "credential destination cannot include user information, a query, or a fragment" + ); + let host = url + .host_str() + .context("credential destination has no hostname")?; + let host = if wildcard { + host.strip_prefix("wildcard.") + .context("credential destination has an invalid wildcard")? + } else { + host + }; + let host = normalize_host(host); + ensure!( + !host.is_empty() && !host.contains('*') && !host.contains('/'), + "credential destination must have an exact hostname or scoped wildcard" + ); + let loopback = !wildcard && is_loopback_host(&Host::parse(&host)?); + let scheme = match explicit_scheme { + None if loopback => CredentialDestinationScheme::Http, + None => CredentialDestinationScheme::Https, + Some(scheme) if scheme.eq_ignore_ascii_case("https") => { + CredentialDestinationScheme::Https + } + Some(scheme) if scheme.eq_ignore_ascii_case("http") && loopback => { + CredentialDestinationScheme::Http + } + Some(_) => { + bail!("credential destination must use HTTPS unless it targets loopback over HTTP") + } + }; + // HTTPS parsing removes an explicit :443, which HTTP must retain. + let url = match scheme { + CredentialDestinationScheme::Http => Url::parse(&format!("http://{authority}"))?, + CredentialDestinationScheme::Https => url, + }; + + Ok(Self { + scheme, + host, + wildcard, + port: url.port().unwrap_or_else(|| scheme.default_port()), + path_prefix: url.path().to_string(), + }) + } + + pub(super) fn is_wildcard(&self) -> bool { + self.wildcard + } + + pub(super) fn bypasses_proxy_with_local_binding(&self) -> bool { + (!self.wildcard || self.host.parse::().is_err()) + && crate::policy::is_default_proxy_bypass_host(&self.host) + } + + pub(super) fn matches_host(&self, host: &str, port: u16) -> bool { + if self.port != port { + return false; + } + if self.wildcard { + host.strip_suffix(&self.host) + .is_some_and(|prefix| prefix.ends_with('.')) + } else { + host == self.host + } + } + + pub(super) fn requires_mitm(&self, host: &str, port: u16) -> bool { + self.scheme == CredentialDestinationScheme::Https && self.matches_host(host, port) + } + + pub(super) fn matches_request(&self, host: &str, request: Option<&Url>) -> bool { + let Some(request) = request else { + return false; + }; + if request.scheme() != self.scheme.as_str() + || !self.matches_host( + host, + request + .port_or_known_default() + .unwrap_or_else(|| self.scheme.default_port()), + ) + { + return false; + } + let path = request.path(); + if !is_safe_for_authorization(path) { + return false; + } + self.path_prefix == "/" + || path == self.path_prefix + || path + .strip_prefix(&self.path_prefix) + .is_some_and(|suffix| self.path_prefix.ends_with('/') || suffix.starts_with('/')) + } +} diff --git a/codex-rs/network-proxy/src/credential_broker/environment.rs b/codex-rs/network-proxy/src/credential_broker/environment.rs index 212374e855..01d2592c80 100644 --- a/codex-rs/network-proxy/src/credential_broker/environment.rs +++ b/codex-rs/network-proxy/src/credential_broker/environment.rs @@ -3,6 +3,7 @@ use super::BROKERED_CREDENTIALS_ENV_KEY; use super::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; use super::CredentialBrokerState; use super::MIN_EMBEDDED_CREDENTIAL_LENGTH; +use super::configured::valid_environment_key; use super::env_entry; use super::env_key_matches; use super::env_value; @@ -99,7 +100,7 @@ pub fn brokered_credential_dummy_env_keys(env: &HashMap) -> Vec< } /// Returns canonical credential and known alias keys recorded for an active brokered child, -/// including keys that are currently absent. +/// including configured provider keys and keys that are currently absent. pub fn brokered_credential_marker_env_keys(env: &HashMap) -> Vec { if env_value(env, CREDENTIAL_BROKER_ACTIVE_ENV_KEY) != Some("1") { return Vec::new(); @@ -109,16 +110,14 @@ pub fn brokered_credential_marker_env_keys(env: &HashMap) -> Vec .unwrap_or_default(); let dummy_values = entries .iter() - .filter(|(key, _)| { - providers::credential_env_keys().any(|candidate| env_key_matches(key, candidate)) - }) + .filter(|(key, value)| valid_environment_key(key) && !value.is_empty()) .map(|(_, dummy_value)| dummy_value.clone()) .collect::>(); let mut keys = entries .into_iter() .filter_map(|(key, value)| { - if providers::credential_env_keys().any(|candidate| env_key_matches(&key, candidate)) { - return Some(key); + if valid_environment_key(&key) { + return (!value.is_empty()).then_some(key); } let alias_key = key.strip_prefix(BROKERED_CREDENTIAL_ALIAS_MARKER_PREFIX)?; let known_alias = dummy_values.iter().any(|dummy_value| { @@ -126,7 +125,7 @@ pub fn brokered_credential_marker_env_keys(env: &HashMap) -> Vec || dummy_value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH && value.contains(dummy_value) }); - known_alias.then(|| alias_key.to_string()) + (known_alias && valid_environment_key(alias_key)).then(|| alias_key.to_string()) }) .collect::>(); keys.sort_unstable(); @@ -145,9 +144,7 @@ pub fn brokered_credential_value_env_keys(env: &HashMap) -> Vec< .unwrap_or_default() .into_iter() .filter_map(|(key, dummy_value)| { - providers::credential_env_keys() - .any(|candidate| env_key_matches(&key, candidate)) - .then_some(dummy_value) + (valid_environment_key(&key) && !dummy_value.is_empty()).then_some(dummy_value) }) .collect::>(); let mut keys = env diff --git a/codex-rs/network-proxy/src/credential_broker/matching.rs b/codex-rs/network-proxy/src/credential_broker/matching.rs index 328805bfa3..a95149e3b7 100644 --- a/codex-rs/network-proxy/src/credential_broker/matching.rs +++ b/codex-rs/network-proxy/src/credential_broker/matching.rs @@ -34,7 +34,7 @@ pub(super) fn virtualize_text( } let replacement = credentials.iter().copied().find(|candidate| { - std::ptr::eq(candidate.provider, credential.provider) + candidate.provider.same_provider(&credential.provider) && candidate.real_value == credential.real_value && (allowed_keys.iter().any(|key| { env_key_matches(key, &candidate.env_var) diff --git a/codex-rs/network-proxy/src/credential_broker/provider_config.rs b/codex-rs/network-proxy/src/credential_broker/provider_config.rs new file mode 100644 index 0000000000..7c9cb17bd7 --- /dev/null +++ b/codex-rs/network-proxy/src/credential_broker/provider_config.rs @@ -0,0 +1,32 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +/// Declarative description of an environment-backed credential family. +#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct CredentialProviderConfig { + pub env: Vec, + pub patterns: Vec, + /// URL prefixes authorized for injection. Bare loopback hosts imply HTTP; other bare hosts + /// imply HTTPS. + pub url_prefixes: Vec, + /// Environment variable containing an additional URL prefix or hostname. + #[serde(skip_serializing_if = "Option::is_none")] + pub url_prefix_from_env: Option, + pub auth: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub header: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix: Option, +} + +/// Authentication formats supported by declarative credential providers. +#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialAuthMethod { + Bearer, + Token, + Basic, + Header, +} diff --git a/codex-rs/network-proxy/src/credential_broker/providers.rs b/codex-rs/network-proxy/src/credential_broker/providers.rs index be22d60481..aff11dc1bc 100644 --- a/codex-rs/network-proxy/src/credential_broker/providers.rs +++ b/codex-rs/network-proxy/src/credential_broker/providers.rs @@ -1,10 +1,12 @@ mod github; mod openai; +use super::destination::CredentialDestination; use rama_http::HeaderMap; use rama_http::HeaderValue; use rand::Rng as _; use std::collections::HashMap; +use url::Url; const DUMMY_ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -36,11 +38,14 @@ pub(super) enum CredentialHostBinding { exact_hosts: &'static [&'static str], suffixes: &'static [&'static str], }, + ConfiguredHosts(Vec), } pub(super) struct CredentialSource { pub(super) env_vars: &'static [&'static str], pub(super) binding_env_vars: &'static [&'static str], + // An explicit invalid hint can clear dynamic destinations while leaving a static fallback. + pub(super) invalidates_host_binding: fn(&HashMap) -> bool, pub(super) host_binding: fn(&HashMap, Option<&str>) -> Option, } @@ -75,7 +80,22 @@ impl CredentialProvider { } impl CredentialHostBinding { - pub(super) fn matches_host(&self, host: &str) -> bool { + pub(super) fn bypasses_proxy_with_local_binding(&self) -> bool { + use crate::policy::is_default_proxy_bypass_host; + + match self { + Self::ExactHost(host) => is_default_proxy_bypass_host(host), + Self::ExactHosts(hosts) => hosts.iter().any(|host| is_default_proxy_bypass_host(host)), + Self::HostPattern { exact_hosts, .. } => exact_hosts + .iter() + .any(|host| is_default_proxy_bypass_host(host)), + Self::ConfiguredHosts(destinations) => destinations + .iter() + .any(CredentialDestination::bypasses_proxy_with_local_binding), + } + } + + pub(super) fn matches_host(&self, host: &str, port: u16) -> bool { match self { Self::ExactHost(expected_host) => host == expected_host, Self::ExactHosts(expected_hosts) => { @@ -87,6 +107,35 @@ impl CredentialHostBinding { } => { exact_hosts.contains(&host) || suffixes.iter().any(|suffix| host.ends_with(suffix)) } + Self::ConfiguredHosts(destinations) => destinations + .iter() + .any(|destination| destination.matches_host(host, port)), + } + } + + pub(super) fn requires_mitm(&self, host: &str, port: u16) -> bool { + match self { + Self::ConfiguredHosts(destinations) => destinations + .iter() + .any(|destination| destination.requires_mitm(host, port)), + Self::ExactHost(_) | Self::ExactHosts(_) | Self::HostPattern { .. } => { + self.matches_host(host, port) + } + } + } + + pub(super) fn matches_request(&self, host: &str, request: Option<&Url>) -> bool { + match self { + Self::ConfiguredHosts(destinations) => destinations + .iter() + .any(|destination| destination.matches_request(host, request)), + Self::ExactHost(_) | Self::ExactHosts(_) | Self::HostPattern { .. } => { + request.is_none_or(|request| request.scheme() == "https") + && self.matches_host( + host, + request.and_then(Url::port_or_known_default).unwrap_or(443), + ) + } } } } @@ -132,6 +181,14 @@ pub(super) fn credential_providers() -> impl Iterator Option { + github::translate_request_header(headers, expected_value, replacement_value) +} + fn shaped_dummy_value(real_value: &str, prefix: &str, minimum_len: usize) -> String { let target_len = real_value.len().max(minimum_len).max(prefix.len() + 16); let mut rng = rand::rng(); diff --git a/codex-rs/network-proxy/src/credential_broker/providers/github.rs b/codex-rs/network-proxy/src/credential_broker/providers/github.rs index f538a8bfde..e79dc56d74 100644 --- a/codex-rs/network-proxy/src/credential_broker/providers/github.rs +++ b/codex-rs/network-proxy/src/credential_broker/providers/github.rs @@ -29,11 +29,16 @@ pub(super) static PROVIDER: CredentialProvider = CredentialProvider { CredentialSource { env_vars: GITHUB_CLOUD_TOKEN_ENV_VARS, binding_env_vars: &[], + invalidates_host_binding: |_| false, host_binding: github_cloud_binding, }, CredentialSource { env_vars: GITHUB_ENTERPRISE_TOKEN_ENV_VARS, binding_env_vars: &[GH_HOST_ENV_VAR], + invalidates_host_binding: |env| { + env_value(env, GH_HOST_ENV_VAR).is_some() + && github_host_hint(env).is_none_or(|host| github_cloud_host(&host)) + }, host_binding: github_enterprise_binding, }, ], @@ -60,7 +65,7 @@ fn insert_request_header(headers: &mut HeaderMap, value: HeaderValue) { headers.insert(AUTHORIZATION, value); } -fn translate_request_header( +pub(super) fn translate_request_header( headers: &HeaderMap, expected_value: &str, replacement_value: &str, @@ -72,16 +77,27 @@ fn translate_request_header( let decoded = base64::engine::general_purpose::STANDARD .decode(value) .ok()?; - let separator = decoded.iter().position(|byte| *byte == b':')?; let mut translated = Vec::with_capacity(decoded.len() + replacement_value.len()); - if &decoded[..separator] == expected_value.as_bytes() { - translated.extend_from_slice(replacement_value.as_bytes()); - translated.extend_from_slice(&decoded[separator..]); - } else if &decoded[separator + 1..] == expected_value.as_bytes() { - translated.extend_from_slice(&decoded[..=separator]); + if decoded == expected_value.as_bytes() { translated.extend_from_slice(replacement_value.as_bytes()); } else { - return None; + let separator = decoded.iter().position(|byte| *byte == b':')?; + let username = &decoded[..separator]; + let password = &decoded[separator + 1..]; + if username != expected_value.as_bytes() && password != expected_value.as_bytes() { + return None; + } + translated.extend_from_slice(if username == expected_value.as_bytes() { + replacement_value.as_bytes() + } else { + username + }); + translated.push(b':'); + translated.extend_from_slice(if password == expected_value.as_bytes() { + replacement_value.as_bytes() + } else { + password + }); } let encoded = base64::engine::general_purpose::STANDARD.encode(translated); HeaderValue::from_str(&format!("{scheme} {encoded}")).ok() @@ -129,7 +145,27 @@ fn github_token_prefix(value: &str) -> &str { } fn github_host_hint(env: &HashMap) -> Option { - env_value(env, GH_HOST_ENV_VAR) - .map(normalize_host) - .filter(|host| !host.is_empty()) + let value = env_value(env, GH_HOST_ENV_VAR)?.trim(); + if value.is_empty() || value.contains(['/', '\\', '@', '?', '#', '*']) { + return None; + } + let authority = if value.parse::().is_ok() + || crate::policy::unscoped_ip_literal(value).is_some() + { + format!("[{value}]") + } else { + value.to_string() + }; + let parsed = authority.parse::().ok()?; + let suffix = authority.strip_prefix(parsed.host())?; + if !suffix.is_empty() { + suffix.strip_prefix(':')?.parse::().ok()?; + } + let host = normalize_host(parsed.host()); + let ip_literal = host.parse::().is_ok() + || crate::policy::unscoped_ip_literal(&host).is_some(); + if !ip_literal && (parsed.host().starts_with('[') || url::Host::parse(&host).is_err()) { + return None; + } + Some(host) } diff --git a/codex-rs/network-proxy/src/credential_broker/providers/openai.rs b/codex-rs/network-proxy/src/credential_broker/providers/openai.rs index 5e0178b1e2..3b6808aaf7 100644 --- a/codex-rs/network-proxy/src/credential_broker/providers/openai.rs +++ b/codex-rs/network-proxy/src/credential_broker/providers/openai.rs @@ -24,6 +24,10 @@ pub(super) static PROVIDER: CredentialProvider = CredentialProvider { sources: &[CredentialSource { env_vars: OPENAI_API_KEY_ENV_VARS, binding_env_vars: &[OPENAI_BASE_URL_ENV_VAR], + invalidates_host_binding: |env| { + env_value(env, OPENAI_BASE_URL_ENV_VAR) + .is_some_and(|value| trusted_credential_broker_host(value).is_none()) + }, host_binding, }], reset_on_configuration_change: true, diff --git a/codex-rs/network-proxy/src/credential_broker/registry.rs b/codex-rs/network-proxy/src/credential_broker/registry.rs index 1dfcb270fa..4044137f85 100644 --- a/codex-rs/network-proxy/src/credential_broker/registry.rs +++ b/codex-rs/network-proxy/src/credential_broker/registry.rs @@ -1,8 +1,264 @@ use super::CredentialBrokerState; use super::CredentialRecord; +use super::MIN_EMBEDDED_CREDENTIAL_LENGTH; +use super::configured::ConfiguredCredentialProvider; use super::env_entry; +use super::env_value; +use super::providers; +use base64::Engine; use rama_http::HeaderMap; +use rama_http::HeaderName; +use rama_http::HeaderValue; +use rama_http::header::AUTHORIZATION; use std::collections::HashMap; +use std::sync::Arc; +use url::Url; + +#[derive(Clone)] +pub(super) enum BrokeredCredentialProvider { + Builtin(&'static providers::CredentialProvider), + Configured(Arc), +} + +pub(super) struct ActiveCredentialSource { + pub(super) provider: BrokeredCredentialProvider, + pub(super) host_binding: providers::CredentialHostBinding, + pub(super) env_vars: Vec, +} + +impl BrokeredCredentialProvider { + pub(super) fn same_provider(&self, other: &Self) -> bool { + match (self, other) { + (Self::Builtin(left), Self::Builtin(right)) => std::ptr::eq(*left, *right), + (Self::Configured(left), Self::Configured(right)) => Arc::ptr_eq(left, right), + (Self::Builtin(_), Self::Configured(_)) | (Self::Configured(_), Self::Builtin(_)) => { + false + } + } + } + + pub(super) fn dummy_value(&self, real_value: &str) -> Option { + match self { + Self::Builtin(provider) => Some(provider.dummy_value(real_value)), + Self::Configured(provider) => provider.dummy_value(real_value), + } + } + + pub(super) fn contains_embedded_value(&self, text: &str, value: &str) -> bool { + value.len() >= MIN_EMBEDDED_CREDENTIAL_LENGTH && text.contains(value) + } + + pub(super) fn replace_embedded_value( + &self, + text: &str, + value: &str, + replacement: &str, + ) -> String { + text.replace(value, replacement) + } + + pub(super) fn request_header_value(&self, value: &str) -> Option { + match self { + Self::Builtin(provider) => provider.request_header_value(value), + Self::Configured(provider) => provider + .matches_value(value) + .then(|| provider.request_header_value(value)) + .flatten(), + } + } + + pub(super) fn translate_request_headers( + &self, + headers: &HeaderMap, + expected_value: &str, + replacement_value: &str, + ) -> Vec<(HeaderName, HeaderValue)> { + match self { + Self::Builtin(provider) => provider + .translate_request_header(headers, expected_value, replacement_value) + .map(|value| (AUTHORIZATION, value)), + Self::Configured(provider) => { + return provider.translate_request_headers( + headers, + expected_value, + replacement_value, + ); + } + } + .into_iter() + .collect() + } + + pub(super) fn insert_request_header( + &self, + headers: &mut HeaderMap, + name: HeaderName, + value: HeaderValue, + ) { + match self { + Self::Builtin(provider) => provider.insert_request_header(headers, value), + Self::Configured(_) => { + headers.insert(name, value); + } + } + } +} + +pub(super) fn select_credentials<'a>( + headers: &HeaderMap, + host: &str, + request: Option<&Url>, + credentials: &'a [CredentialRecord], + environment_id: Option<&str>, +) -> Vec<(&'a CredentialRecord, HeaderName, HeaderValue)> { + let mut translated_matches = Vec::<(&CredentialRecord, HeaderName, HeaderValue)>::new(); + for credential in credentials.iter().filter(|credential| { + credential.belongs_to_environment(environment_id) + && credential + .host_bindings() + .any(|binding| binding.matches_request(host, request)) + }) { + for candidate in credentials.iter().filter(|candidate| { + candidate.belongs_to_environment(environment_id) + && candidate.provider.same_provider(&credential.provider) + && candidate.real_value == credential.real_value + && candidate + .host_bindings() + .any(|binding| binding.matches_request(host, request)) + }) { + for (name, value) in credential.provider.translate_request_headers( + headers, + &candidate.dummy_value, + &credential.real_value, + ) { + if !translated_matches + .iter() + .any(|(existing, header, translated)| { + existing.provider.same_provider(&credential.provider) + && existing.real_value == credential.real_value + && *header == name + && *translated == value + }) + { + translated_matches.push((credential, name, value)); + } + } + } + } + let mut selected = Vec::<(&CredentialRecord, HeaderName, HeaderValue)>::new(); + let mut ambiguous_headers = Vec::::new(); + for (credential, header_name, header_value) in translated_matches { + if ambiguous_headers.contains(&header_name) { + continue; + } + let Some(existing) = selected + .iter() + .position(|(_, selected_header, _)| selected_header == header_name) + else { + selected.push((credential, header_name, header_value)); + continue; + }; + if !credential + .provider + .same_provider(&selected[existing].0.provider) + || credential.real_value != selected[existing].0.real_value + || header_value != selected[existing].2 + { + if header_name == AUTHORIZATION + && let Some(merged) = headers.get(AUTHORIZATION).and_then(|original| { + merge_basic_auth_fields(original, &selected[existing].2, &header_value) + }) + { + // Keep the caller's configured-provider raw-path validation for either field. + if matches!( + credential.provider, + BrokeredCredentialProvider::Configured(_) + ) { + selected[existing].0 = credential; + } + selected[existing].2 = merged; + continue; + } + selected.swap_remove(existing); + ambiguous_headers.push(header_name); + } + } + selected +} + +fn merge_basic_auth_fields( + original: &HeaderValue, + left: &HeaderValue, + right: &HeaderValue, +) -> Option { + let decode = |header: &HeaderValue| { + let (scheme, value) = header.to_str().ok()?.split_once(' ')?; + scheme.eq_ignore_ascii_case("basic").then_some(())?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(value.trim()) + .ok()?; + let separator = decoded.iter().position(|byte| *byte == b':')?; + Some(( + decoded[..separator].to_vec(), + decoded[separator + 1..].to_vec(), + )) + }; + let (original_user, original_password) = decode(original)?; + let (left_user, left_password) = decode(left)?; + let (right_user, right_password) = decode(right)?; + // Only combine independent fields. Overlapping translations remain ambiguous. + let (user, password) = if left_user != original_user + && left_password == original_password + && right_user == original_user + && right_password != original_password + { + (left_user, right_password) + } else if right_user != original_user + && right_password == original_password + && left_user == original_user + && left_password != original_password + { + (right_user, left_password) + } else { + return None; + }; + let encoded = + base64::engine::general_purpose::STANDARD.encode([user, vec![b':'], password].concat()); + HeaderValue::from_str(&format!("Basic {encoded}")).ok() +} + +pub(super) fn active_credential_sources( + state: &CredentialBrokerState, + env: &HashMap, +) -> Vec { + let mut sources = Vec::new(); + for provider in providers::credential_providers() { + for source in provider.sources() { + if let Some(host_binding) = (source.host_binding)(env, state.openai_api_host.as_deref()) + { + sources.push(ActiveCredentialSource { + provider: BrokeredCredentialProvider::Builtin(provider), + host_binding, + env_vars: source + .env_vars + .iter() + .map(|key| (*key).to_string()) + .collect(), + }); + } + } + } + for provider in &state.configured_providers { + if let Some(host_binding) = provider.host_binding(env) { + sources.push(ActiveCredentialSource { + provider: BrokeredCredentialProvider::Configured(provider.clone()), + host_binding, + env_vars: provider.config.env.clone(), + }); + } + } + sources +} pub(super) fn prioritized_credentials<'a>( state: &'a CredentialBrokerState, @@ -12,11 +268,25 @@ pub(super) fn prioritized_credentials<'a>( credentials.sort_unstable_by_key(|credential| { let active = env_entry(env, &credential.env_var) .is_some_and(|(_, value)| value == credential.dummy_value); - let has_matching_host_binding = credential.provider.sources().iter().any(|source| { - !source.binding_env_vars.is_empty() - && (source.host_binding)(env, state.openai_api_host.as_deref()) - .is_some_and(|binding| binding == credential.host_binding) - }); + let has_matching_host_binding = match &credential.provider { + BrokeredCredentialProvider::Builtin(provider) => { + provider.sources().iter().any(|source| { + !source.binding_env_vars.is_empty() + && (source.host_binding)(env, state.openai_api_host.as_deref()) + .is_some_and(|binding| binding == credential.host_binding) + }) + } + BrokeredCredentialProvider::Configured(provider) => provider + .config + .url_prefix_from_env + .as_deref() + .is_some_and(|key| { + env_value(env, key).is_some() + && provider + .host_binding(env) + .is_some_and(|binding| binding == credential.host_binding) + }), + }; ( std::cmp::Reverse(credential.real_value.len()), std::cmp::Reverse(active), @@ -25,37 +295,3 @@ pub(super) fn prioritized_credentials<'a>( }); credentials } - -pub(super) fn select_credential<'a>( - headers: &HeaderMap, - host: &str, - credentials: &'a [CredentialRecord], -) -> Option<(&'a CredentialRecord, rama_http::HeaderValue)> { - let mut translated_matches = credentials - .iter() - .filter(|credential| credential.matches_host(host)) - .filter_map(|credential| { - credentials - .iter() - .filter(|candidate| { - std::ptr::eq(candidate.provider, credential.provider) - && candidate.real_value == credential.real_value - && candidate.matches_host(host) - }) - .find_map(|candidate| { - credential.provider.translate_request_header( - headers, - &candidate.dummy_value, - &credential.real_value, - ) - }) - .map(|header_value| (credential, header_value)) - }); - let matched = translated_matches.next()?; - translated_matches - .all(|(credential, _)| { - std::ptr::eq(credential.provider, matched.0.provider) - && credential.real_value == matched.0.real_value - }) - .then_some(matched) -} diff --git a/codex-rs/network-proxy/src/credential_broker_tests.rs b/codex-rs/network-proxy/src/credential_broker_tests.rs index 34e4244a81..b6b374e3c7 100644 --- a/codex-rs/network-proxy/src/credential_broker_tests.rs +++ b/codex-rs/network-proxy/src/credential_broker_tests.rs @@ -783,6 +783,10 @@ fn brokered_credential_value_env_keys_include_dummy_aliases() { "@alias:AUTH_HEADER".to_string(), env["OPENAI_API_KEY"].clone() ), + ( + "@alias:OPENAI_API_KEY".to_string(), + env["OPENAI_API_KEY"].clone() + ), ("OPENAI_API_KEY".to_string(), env["OPENAI_API_KEY"].clone()), ] ); @@ -904,7 +908,7 @@ fn virtualize_child_env_keeps_unbound_enterprise_token_out_of_persisted_text() { assert_eq!(env["GH_ENTERPRISE_TOKEN"], token); assert_eq!(headers, headers_with_bearer(token)); - assert!(!broker.host_requires_mitm("attacker.example")); + assert!(!broker.host_requires_mitm("attacker.example", /*port*/ 443)); env.insert("GH_HOST".to_string(), "github.example.com".to_string()); broker.virtualize_child_env(&mut env); @@ -999,6 +1003,117 @@ fn inject_request_headers_requires_dummy_and_preserves_explicit_authorization() assert_eq!(authorization(&explicit_headers), Some("Bearer sk-explicit")); } +#[test] +fn concurrent_commands_preserve_discovered_credential_destinations() { + for (key, host_key, token) in [ + ( + "GH_ENTERPRISE_TOKEN", + "GH_HOST", + "ghp_abcdefghijklmnopqrstuvwxyz1234567890", + ), + ( + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + ), + ( + "PROVIDER_TOKEN", + "PROVIDER_ENDPOINT", + "provider_abcdefghijklmnopqrstuvwx", + ), + ] { + let broker = CredentialBroker::new(/*enabled*/ true); + broker.configure(&NetworkProxyConfig { + credential_broker: true, + credential_providers: BTreeMap::from([( + "custom".to_string(), + CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["^provider_[a-z]{24}$".to_string()], + url_prefix_from_env: Some("PROVIDER_ENDPOINT".to_string()), + ..CredentialProviderConfig::default() + }, + )]), + ..NetworkProxyConfig::default() + }); + let mut commands = ["first.example", "second.example"].map(|host| { + let destination = if host_key == "GH_HOST" { + host.to_string() + } else { + format!("https://{host}/v1") + }; + env_map([ + (key, token), + (host_key, &destination), + ("AUTH_HEADER", &format!("Bearer {token}")), + ]) + }); + for env in &mut commands { + broker.virtualize_child_env_for_environment(env, Some("shared-environment")); + } + let dummy = commands[0][key].clone(); + assert_ne!(dummy, token); + assert_eq!(commands[1][key], dummy); + let assert_destinations = || { + for (environment_id, host, injected) in [ + ("shared-environment", "first.example", true), + ("shared-environment", "second.example", true), + ("shared-environment", "unrelated.example", false), + ("other-environment", "first.example", false), + ("other-environment", "second.example", false), + ] { + let mut headers = headers_with_bearer(&dummy); + broker.inject_request_headers_for_environment( + &format!("https://{host}/v1/models"), + &mut headers, + Some(environment_id), + ); + assert_eq!( + headers, + headers_with_bearer(if injected { token } else { &dummy }), + "{key}, {environment_id}, {host}" + ); + assert_eq!( + broker.host_requires_mitm_for_environment( + host, + /*port*/ 443, + Some(environment_id), + ), + injected, + "{key}, {environment_id}, {host}" + ); + } + }; + assert_destinations(); + for env in &mut commands { + env.remove(key); + broker.virtualize_child_env_for_environment(env, Some("shared-environment")); + assert_eq!(env["AUTH_HEADER"], format!("Bearer {dummy}")); + assert_destinations(); + } + + commands[0].insert(key.to_string(), token.to_string()); + broker.virtualize_child_env_for_environment(&mut commands[0], Some("shared-environment")); + assert_eq!(commands[0][key], dummy); + assert_destinations(); + + let mut inherited = env_map([(key, token), (host_key, &commands[0][host_key])]); + broker.virtualize_child_env_for_environment(&mut inherited, Some("parent-environment")); + let parent_dummy = inherited[key].clone(); + assert_ne!(parent_dummy, dummy); + broker.virtualize_child_env_for_environment(&mut inherited, Some("shared-environment")); + assert_eq!(inherited[key], dummy); + assert_destinations(); + let mut headers = headers_with_bearer(&parent_dummy); + broker.inject_request_headers_for_environment( + "https://second.example/v1/models", + &mut headers, + Some("parent-environment"), + ); + assert_eq!(headers, headers_with_bearer(&parent_dummy)); + } +} + #[test] fn openai_credentials_bind_only_to_default_and_configured_trusted_hosts() { let broker = CredentialBroker::new(/*enabled*/ true); @@ -1077,12 +1192,42 @@ fn github_cloud_credentials_do_not_bind_to_ghes_host_hint() { authorization(&headers), Some(expected_authorization.as_str()) ); - assert!(!broker.host_requires_mitm("github.example.com")); - assert!(broker.host_requires_mitm("api.github.com")); + assert!(!broker.host_requires_mitm("github.example.com", /*port*/ 443)); + assert!(broker.host_requires_mitm("api.github.com", /*port*/ 443)); } #[test] fn github_enterprise_credentials_bind_to_gh_host() { + for (hint, host) in [ + (" GitHub.Example.Com.:8443 ", "github.example.com"), + ("127.0.0.1:8443", "127.0.0.1"), + ("[::1]:8443", "::1"), + ("::1", "::1"), + ("[fe80::1%en0]:8443", "fe80::1%en0"), + ("fe80::1%en0", "fe80::1%en0"), + ] { + let broker = CredentialBroker::new(/*enabled*/ true); + let mut env = env_map([ + ("GH_HOST", hint), + ("GH_ENTERPRISE_TOKEN", "ghp-enterprise-real"), + ]); + broker.virtualize_child_env(&mut env); + let dummy = env["GH_ENTERPRISE_TOKEN"].clone(); + assert_ne!(dummy, "ghp-enterprise-real", "{hint}"); + env.remove("GH_HOST"); + env.insert( + "GH_ENTERPRISE_TOKEN".to_string(), + "ghp-enterprise-real".to_string(), + ); + broker.virtualize_child_env(&mut env); + let mut headers = headers_with_bearer(&dummy); + broker.inject_request_headers(host, &mut headers); + assert_eq!( + authorization(&headers), + Some("Bearer ghp-enterprise-real"), + "{hint}" + ); + } let broker = CredentialBroker::new(/*enabled*/ true); let mut env = env_map([ ("GH_HOST", "github.example.com"), @@ -1130,8 +1275,8 @@ fn github_enterprise_credentials_bind_to_gh_host() { ]); broker.virtualize_child_env(&mut cloud_only); assert_eq!(cloud_only["AUTH_HEADER"], format!("Bearer {github_dummy}")); - assert!(broker.host_requires_mitm("github.example.com")); - assert!(broker.host_requires_mitm("api.github.com")); + assert!(broker.host_requires_mitm("github.example.com", /*port*/ 443)); + assert!(broker.host_requires_mitm("api.github.com", /*port*/ 443)); env.insert("GH_HOST".to_string(), "attacker.example".to_string()); env.insert("GH_ENTERPRISE_TOKEN".to_string(), github_dummy.clone()); @@ -1139,7 +1284,7 @@ fn github_enterprise_credentials_bind_to_gh_host() { let mut attacker_headers = headers_with_bearer(&github_dummy); broker.inject_request_headers("attacker.example", &mut attacker_headers); assert_eq!(attacker_headers, headers_with_bearer(&github_dummy)); - assert!(!broker.host_requires_mitm("attacker.example")); + assert!(!broker.host_requires_mitm("attacker.example", /*port*/ 443)); let mut alternate_enterprise_key = env_map([ ("GH_HOST", "github.alternate.example"), diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index da9bc494a8..d460310b32 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -300,7 +300,7 @@ async fn http_connect_accept( return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); } }; - let host_mitm_requirement = match app_state.host_mitm_requirement(&host).await { + let host_mitm_requirement = match app_state.host_mitm_requirement(&host, authority.port).await { Ok(requirement) => requirement, Err(err) => { error!("failed to inspect MITM requirements for {host}: {err}"); @@ -785,7 +785,7 @@ async fn http_plain_proxy( } } - let host_mitm_requirement = match app_state.host_mitm_requirement(&host).await { + let host_mitm_requirement = match app_state.host_mitm_requirement(&host, port).await { Ok(requirement) => requirement, Err(err) => { return Ok(internal_error("failed to inspect MITM requirements", err)); @@ -880,7 +880,7 @@ async fn http_plain_proxy( } if let Err(err) = - inject_plaintext_credentials_if_enabled(app_state.as_ref(), &host, req.headers_mut()).await + inject_forward_request_credentials(app_state.as_ref(), &request_ctx, &mut req).await { return Ok(internal_error( "failed to read plaintext credential injection config", @@ -917,13 +917,28 @@ async fn http_plain_proxy( } } -async fn inject_plaintext_credentials_if_enabled( +async fn inject_forward_request_credentials( app_state: &NetworkProxyState, - host: &str, - headers: &mut HeaderMap, + context: &RequestContext, + req: &mut Request, ) -> Result<()> { - if app_state.plaintext_credential_injection_enabled().await? { - app_state.inject_request_credentials(host, headers); + let authority = context.host_with_port(); + let scheme = req.uri().scheme_str().unwrap_or("http"); + // Server-wide OPTIONS may use root-scoped credentials without changing its wire target. + let request_path = req + .uri() + .path_and_query() + .map(rama_http::uri::PathAndQuery::as_str) + .filter(|path| *path != "*") + .unwrap_or("/"); + let destination = format!("{scheme}://{authority}{request_path}"); + let unrestricted_plaintext = app_state.plaintext_credential_injection_enabled().await?; + app_state.inject_request_credentials(&destination, req.headers_mut()); + if unrestricted_plaintext { + app_state.inject_request_credentials( + &normalize_host(&authority.host.to_string()), + req.headers_mut(), + ); } Ok(()) } @@ -1167,12 +1182,14 @@ struct BlockedResponse<'a> { mod tests { use super::*; + use crate::CredentialProviderConfig; use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::runtime::network_proxy_state_for_policy; use pretty_assertions::assert_eq; use rama_http::Method; use rama_http::Request; + use std::collections::BTreeMap; use std::collections::HashMap; use std::net::Ipv4Addr; use std::net::TcpListener as StdTcpListener; @@ -1314,62 +1331,117 @@ mod tests { #[tokio::test] async fn plaintext_credential_injection_requires_explicit_opt_in() { let real_token = "ghp-real"; - let mut disabled_network = NetworkProxyConfig { - credential_broker: true, - mitm: true, - ..NetworkProxyConfig::default() - }; - disabled_network.set_allowed_domains(vec!["api.github.com".to_string()]); - let disabled_state = Arc::new(network_proxy_state_for_policy(disabled_network)); - let mut disabled_env = HashMap::from([("GH_TOKEN".to_string(), real_token.to_string())]); - disabled_state.virtualize_child_credentials(&mut disabled_env); - let dummy_token = disabled_env.get("GH_TOKEN").expect("dummy GitHub token"); - let mut disabled_headers = HeaderMap::from_iter([( - header::AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {dummy_token}")) - .expect("valid authorization header"), - )]); + for enabled in [false, true] { + let state = network_proxy_state_for_policy(NetworkProxyConfig { + credential_broker: true, + dangerously_allow_plaintext_credential_injection: enabled, + mitm: true, + ..NetworkProxyConfig::default() + }); + let mut env = HashMap::from([("GH_TOKEN".to_string(), real_token.to_string())]); + state.virtualize_child_credentials(&mut env); + let dummy = env.get("GH_TOKEN").expect("dummy GitHub token"); + let mut req = Request::builder() + .uri("http://api.github.com/") + .header(header::AUTHORIZATION, format!("Bearer {dummy}")) + .body(Body::empty()) + .unwrap(); + let context = RequestContext::try_from(&req).unwrap(); - inject_plaintext_credentials_if_enabled( - disabled_state.as_ref(), - "api.github.com", - &mut disabled_headers, - ) - .await - .expect("disabled plaintext injection check should succeed"); - assert_eq!( - disabled_headers.get(header::AUTHORIZATION), - Some(&HeaderValue::from_str(&format!("Bearer {dummy_token}")).unwrap()) - ); + inject_forward_request_credentials(&state, &context, &mut req) + .await + .unwrap(); - let mut enabled_network = NetworkProxyConfig { - credential_broker: true, - dangerously_allow_plaintext_credential_injection: true, - mitm: true, - ..NetworkProxyConfig::default() - }; - enabled_network.set_allowed_domains(vec!["api.github.com".to_string()]); - let enabled_state = Arc::new(network_proxy_state_for_policy(enabled_network)); - let mut enabled_env = HashMap::from([("GH_TOKEN".to_string(), real_token.to_string())]); - enabled_state.virtualize_child_credentials(&mut enabled_env); - let enabled_dummy = enabled_env.get("GH_TOKEN").expect("dummy GitHub token"); - let mut enabled_headers = HeaderMap::from_iter([( - header::AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {enabled_dummy}")) - .expect("valid authorization header"), - )]); + let expected = if enabled { real_token } else { dummy }; + assert_eq!( + req.headers()[header::AUTHORIZATION], + format!("Bearer {expected}") + ); + } + } - inject_plaintext_credentials_if_enabled( - enabled_state.as_ref(), - "api.github.com", - &mut enabled_headers, - ) - .await - .expect("enabled plaintext injection check should succeed"); - assert_eq!( - enabled_headers.get(header::AUTHORIZATION), - Some(&HeaderValue::from_str(&format!("Bearer {real_token}")).unwrap()) - ); + #[tokio::test] + async fn configured_forward_requests_match_destination_scheme_and_port() { + let real_token = "provider_abcdefghijklmnopqrstuvwx"; + for (host, port, prefix, scope) in [ + ("localhost", 8080, ""), + ("127.0.0.1", 8081, ""), + ("api.provider.example", 443, "https://"), + ("api.provider.example", 8443, "https://"), + ] + .into_iter() + .flat_map(|(host, port, prefix)| ["/v1", "/"].map(move |scope| (host, port, prefix, scope))) + { + let state = network_proxy_state_for_policy(NetworkProxyConfig { + credential_broker: true, + dangerously_allow_plaintext_credential_injection: true, + credential_providers: BTreeMap::from([( + "custom".to_string(), + CredentialProviderConfig { + env: vec!["PROVIDER_TOKEN".to_string()], + patterns: vec!["^provider_[a-z]{24}$".to_string()], + url_prefix_from_env: Some("PROVIDER_URL".to_string()), + ..CredentialProviderConfig::default() + }, + )]), + ..NetworkProxyConfig::default() + }); + let mut env = HashMap::from([ + ("PROVIDER_TOKEN".to_string(), real_token.to_string()), + ( + "PROVIDER_URL".to_string(), + format!("{prefix}{host}:{port}{scope}"), + ), + ]); + state.virtualize_child_credentials(&mut env); + let dummy = env.get("PROVIDER_TOKEN").expect("dummy provider token"); + assert_ne!(dummy, real_token); + for (uri, inject) in [ + (format!("http://{host}:{port}/v1/models"), prefix.is_empty()), + (format!("http://{host}:{}/v1/models", port + 1), false), + (format!("https://{host}:{}/v1/models", port + 1), false), + ( + format!("https://{host}:{port}/v1/models"), + !prefix.is_empty(), + ), + ("/v1/models".to_string(), prefix.is_empty()), + ( + format!("https://{host}:{port}/other"), + !prefix.is_empty() && scope == "/", + ), + ("*".to_string(), prefix.is_empty() && scope == "/"), + ] { + let request_uri: rama_http::Uri = uri.parse().unwrap(); + let host_header = request_uri + .authority() + .map(|authority| authority.as_str().to_string()) + .unwrap_or_else(|| format!("{host}:{port}")); + let mut req = Request::builder() + .method(if uri == "*" { + Method::OPTIONS + } else { + Method::GET + }) + .uri(request_uri) + .header(header::HOST, host_header) + .header(header::AUTHORIZATION, format!("Bearer {dummy}")) + .body(Body::empty()) + .unwrap(); + let context = RequestContext::try_from(&req).unwrap(); + + inject_forward_request_credentials(&state, &context, &mut req) + .await + .unwrap(); + + let expected = if inject { real_token } else { dummy }; + assert_eq!( + req.headers()[header::AUTHORIZATION], + format!("Bearer {expected}"), + "request: {uri}, configured scope: {scope}" + ); + assert_eq!(req.uri().to_string(), uri); + } + } } #[tokio::test] diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 52543f5842..94dc89dc1d 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -44,6 +44,8 @@ pub use config::NetworkUnixSocketPermissions; pub use config::host_and_port_from_network_addr; pub use config::managed_proxy_ports; pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; +pub use credential_broker::CredentialAuthMethod; +pub use credential_broker::CredentialProviderConfig; pub use credential_broker::brokered_credential_binding_env_keys; pub use credential_broker::brokered_credential_dummy_env_keys; pub use credential_broker::brokered_credential_env_keys; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 28457f573d..def083b036 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -295,13 +295,13 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu let log_path = path_for_log(req.uri()); let (mut parts, body) = req.into_parts(); + let authority = authority_header_value(&target_host, target_port); + parts.uri = build_https_uri(&authority, &path)?; request_ctx .policy .app_state - .inject_request_credentials(&target_host, &mut parts.headers); + .inject_request_credentials(&parts.uri.to_string(), &mut parts.headers); apply_mitm_hook_actions(&mut parts.headers, hook_actions.as_ref()); - let authority = authority_header_value(&target_host, target_port); - parts.uri = build_https_uri(&authority, &path)?; parts .headers .insert(HOST, HeaderValue::from_str(&authority)?); diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs index f83863ded6..ede4c319e4 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -42,6 +42,16 @@ pub fn is_loopback_host(host: &Host) -> bool { false } +/// Matches the destinations in `DEFAULT_NO_PROXY_VALUE`, without resolving DNS. +pub(crate) fn is_default_proxy_bypass_host(host: &str) -> bool { + let host = normalize_host(host); + match host.parse::() { + Ok(IpAddr::V4(ip)) => ip == Ipv4Addr::LOCALHOST || ip.is_private(), + Ok(IpAddr::V6(ip)) => ip == Ipv6Addr::LOCALHOST, + Err(_) => host == "localhost" || host.ends_with(".localhost"), + } +} + pub fn is_non_public_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(ip) => is_non_public_ipv4(ip), diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index d002b6fe0f..48bfd79fe4 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -894,6 +894,7 @@ impl NetworkProxy { // Proxy enablement and credential brokerage remain controller-owned. let mut broker_only_config = config::NetworkProxyConfig { enabled: config.enabled, + credential_providers: config.credential_providers.clone(), credential_broker_openai_host: config.credential_broker_openai_host.clone(), dangerously_allow_plaintext_credential_injection: config .dangerously_allow_plaintext_credential_injection, @@ -1031,6 +1032,7 @@ impl NetworkProxy { addrs: EnvironmentProxyAddrs, #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] client: EnvironmentProxyClient, + environment_id: Option<&str>, ) -> PreparedManagedNetwork { #[cfg(target_os = "windows")] let shared_socks_addr = (client == EnvironmentProxyClient::SandboxedProcess) @@ -1056,7 +1058,13 @@ impl NetworkProxy { runtime_settings.allow_local_binding, runtime_settings.mitm_ca_trust_bundle.as_ref(), ); - self.state.virtualize_child_credentials(&mut env); + let credential_environment_id = environment_id.or_else(|| { + self.execution_scope + .as_ref() + .map(|scope| scope.environment_id.as_str()) + }); + self.state + .virtualize_child_credentials_for_environment(&mut env, credential_environment_id); if let Some(execution_scope) = self.execution_scope.as_ref() { env.insert( PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(), @@ -1105,11 +1113,13 @@ impl NetworkProxy { &self, env: &mut HashMap, addrs: EnvironmentProxyAddrs, + environment_id: Option<&str>, ) { let prepared = self.prepare_for_addrs( std::mem::take(env), addrs, EnvironmentProxyClient::SandboxedProcess, + environment_id, ); *env = prepared.env; } @@ -1121,9 +1131,40 @@ impl NetworkProxy { http_addr: self.http_addr, socks_addr: self.socks_addr, }, + /*environment_id*/ None, ); } + /// Prepares a snapshot for redaction, retaining dummies even for destinations that bypass + /// the proxy. Actual child execution must use `apply_to_env` instead. + pub fn apply_to_env_for_snapshot(&self, env: &mut HashMap) { + self.apply_to_env(env); + let environment_id = self + .execution_scope + .as_ref() + .map(|scope| scope.environment_id.as_str()); + self.state + .virtualize_snapshot_credentials(env, environment_id); + } + + /// Checks a captured alias against registered child values, including scope-specific dummies + /// and credentials restored for direct destinations. This does not authorize new destinations. + pub fn child_credential_alias_matches( + &self, + key: &str, + value: &str, + snapshot_value: &str, + environment_id: Option<&str>, + ) -> bool { + let environment_id = environment_id.or_else(|| { + self.execution_scope + .as_ref() + .map(|scope| scope.environment_id.as_str()) + }); + self.state + .child_credential_alias_matches(key, value, snapshot_value, environment_id) + } + /// Restores known dummy credentials before a child intentionally leaves managed networking. pub fn restore_brokered_credentials( &self, @@ -1165,7 +1206,7 @@ impl NetworkProxy { ) -> Result<()> { let addrs = self.environment_proxy_addrs(environment_id, EnvironmentProxyClient::SandboxedProcess)?; - self.apply_to_env_for_addrs(env, addrs); + self.apply_to_env_for_addrs(env, addrs, Some(environment_id)); Ok(()) } @@ -1200,7 +1241,12 @@ impl NetworkProxy { socks_addr: self.socks_addr, }, }; - Ok(self.prepare_for_addrs(env, addrs, EnvironmentProxyClient::SandboxedProcess)) + Ok(self.prepare_for_addrs( + env, + addrs, + EnvironmentProxyClient::SandboxedProcess, + environment_id, + )) } /// Prepares proxy settings for a remote executor whose connection reaches this process through @@ -1212,7 +1258,12 @@ impl NetworkProxy { ) -> Result { let addrs = self.environment_proxy_addrs(environment_id, EnvironmentProxyClient::TrustedBridge)?; - Ok(self.prepare_for_addrs(env, addrs, EnvironmentProxyClient::TrustedBridge)) + Ok(self.prepare_for_addrs( + env, + addrs, + EnvironmentProxyClient::TrustedBridge, + Some(environment_id), + )) } fn environment_proxy_addrs( diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 4cfd2bdd87..700437daef 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -408,6 +408,15 @@ impl NetworkProxyState { }) } + pub(crate) fn for_environment_id(&self, environment_id: Option<&str>) -> Self { + Self { + environment_id: environment_id + .map(Arc::from) + .or_else(|| self.environment_id.clone()), + ..self.clone() + } + } + pub(crate) fn environment_id(&self) -> Option<&str> { self.environment_id.as_deref() } @@ -444,12 +453,21 @@ impl NetworkProxyState { } pub fn virtualize_child_credentials(&self, env: &mut HashMap) { + self.virtualize_child_credentials_for_environment(env, self.environment_id()); + } + + pub(crate) fn virtualize_child_credentials_for_environment( + &self, + env: &mut HashMap, + environment_id: Option<&str>, + ) { let parent_env = std::env::vars_os() .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) .collect(); self.credential_broker - .discover_parent_credentials(&parent_env, env); - self.credential_broker.virtualize_child_env(env); + .discover_parent_credentials_for_environment(&parent_env, env, environment_id); + self.credential_broker + .virtualize_child_env_for_environment(env, environment_id); } pub(crate) fn restore_child_credentials( @@ -460,6 +478,26 @@ impl NetworkProxyState { self.credential_broker.restore_child_env(env, command); } + pub(crate) fn virtualize_snapshot_credentials( + &self, + env: &mut HashMap, + environment_id: Option<&str>, + ) { + self.credential_broker + .virtualize_snapshot_env(env, environment_id); + } + + pub(crate) fn child_credential_alias_matches( + &self, + key: &str, + value: &str, + snapshot_value: &str, + environment_id: Option<&str>, + ) -> bool { + self.credential_broker + .child_alias_matches(key, value, snapshot_value, environment_id) + } + pub(crate) fn restore_and_disable_child_credentials( &self, env: &mut HashMap, @@ -482,7 +520,8 @@ impl NetworkProxyState { } pub fn inject_request_credentials(&self, host: &str, headers: &mut rama_http::HeaderMap) { - self.credential_broker.inject_request_headers(host, headers); + self.credential_broker + .inject_request_headers_for_environment(host, headers, self.environment_id()); } pub async fn plaintext_credential_injection_enabled(&self) -> Result { @@ -814,7 +853,11 @@ impl NetworkProxyState { Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req)) } - pub(crate) async fn host_mitm_requirement(&self, host: &str) -> Result { + pub(crate) async fn host_mitm_requirement( + &self, + host: &str, + port: u16, + ) -> Result { self.reload_if_needed().await?; let normalized_host = normalize_host(host); let host_has_mitm_hooks = { @@ -823,7 +866,11 @@ impl NetworkProxyState { }; Ok(if host_has_mitm_hooks { HostMitmRequirement::Always - } else if self.credential_broker.host_requires_mitm(&normalized_host) { + } else if self.credential_broker.host_requires_mitm_for_environment( + &normalized_host, + port, + self.environment_id(), + ) { HostMitmRequirement::Tls } else { HostMitmRequirement::None diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 098d3feb27..610f7ba1b4 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -364,7 +364,7 @@ async fn handle_socks5_tcp( } } - let host_mitm_requirement = match app_state.host_mitm_requirement(&host).await { + let host_mitm_requirement = match app_state.host_mitm_requirement(&host, port).await { Ok(requirement) => requirement, Err(err) => { error!("failed to inspect MITM requirements for {host}: {err}");