mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
## Why Codex child processes can inherit injectable local credentials directly, which lets commands read and exfiltrate the real values. This experimental slice keeps supported workflows working while moving those credentials behind the managed network proxy. This PR contains only the proxy-owned broker implementation. The Codex config and runtime integration is stacked separately in #29752. ## What changed - discover supported credentials during child setup, retain real values only in the in-memory proxy broker, and replace them with shaped dummy values - require a presented dummy to select a stored credential and preserve unrelated explicit authorization headers - bind GitHub cloud, GitHub Enterprise, and OpenAI credentials to their intended hosts - inject credentials only into TLS traffic by default; plaintext injection requires the explicit dangerous opt-in - use TLS ClientHello routing for CONNECT so non-TLS protocols remain opaque tunnels - expose a pure API that identifies environment keys still holding broker-generated dummies without mutating the caller's environment ## Scope - supported credentials: `GH_TOKEN`, `GITHUB_TOKEN`, `GH_ENTERPRISE_TOKEN`, `GITHUB_ENTERPRISE_TOKEN`, and `OPENAI_API_KEY` - GitHub cloud credentials match `github.com`, `api.github.com`, and `*.ghe.com` - GitHub Enterprise credentials match only the normalized non-cloud `GH_HOST` - OpenAI API keys match only `api.openai.com` - this does not cover SSH agents, kube client certificates, filesystem secret discovery, or context-injected secret scrubbing ## Validation - `just test -p codex-network-proxy` (191 passed) - focused opaque CONNECT, plaintext opt-in, dummy-selection, and child-isolation regressions passed - scoped Clippy check for `codex-network-proxy` passed --------- Co-authored-by: viyatb-oai <viyatb@openai.com> Co-authored-by: Codex <noreply@openai.com>
271 lines
8.4 KiB
Rust
271 lines
8.4 KiB
Rust
mod providers;
|
|
|
|
use crate::policy::normalize_host;
|
|
use rama_http::HeaderMap;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::sync::RwLock;
|
|
|
|
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";
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct CredentialBroker {
|
|
state: Arc<RwLock<CredentialBrokerState>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CredentialBrokerState {
|
|
enabled: bool,
|
|
credentials: Vec<CredentialRecord>,
|
|
}
|
|
|
|
struct CredentialRecord {
|
|
env_var: String,
|
|
provider: &'static providers::CredentialProvider,
|
|
host_binding: providers::CredentialHostBinding,
|
|
real_value: String,
|
|
dummy_value: String,
|
|
}
|
|
|
|
impl CredentialBroker {
|
|
pub(crate) fn new(enabled: bool) -> Self {
|
|
Self {
|
|
state: Arc::new(RwLock::new(CredentialBrokerState {
|
|
enabled,
|
|
..CredentialBrokerState::default()
|
|
})),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn enabled(&self) -> bool {
|
|
self.read_state().enabled
|
|
}
|
|
|
|
pub(crate) fn virtualize_child_env(&self, env: &mut HashMap<String, String>) {
|
|
let mut state = self.write_state();
|
|
if !state.enabled {
|
|
env.remove(CREDENTIAL_BROKER_ACTIVE_ENV_KEY);
|
|
env.remove(BROKERED_CREDENTIALS_ENV_KEY);
|
|
return;
|
|
}
|
|
env.insert(
|
|
CREDENTIAL_BROKER_ACTIVE_ENV_KEY.to_string(),
|
|
"1".to_string(),
|
|
);
|
|
|
|
for provider in providers::credential_providers() {
|
|
for source in provider.sources() {
|
|
if let Some(host_binding) = (source.host_binding)(env) {
|
|
for env_var in source.env_vars {
|
|
virtualize_env_var(
|
|
env,
|
|
&mut state,
|
|
env_var,
|
|
provider,
|
|
host_binding.clone(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
update_brokered_credentials_marker(&state, env);
|
|
}
|
|
|
|
pub(crate) fn host_requires_mitm(&self, host: &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))
|
|
}
|
|
|
|
pub(crate) fn inject_request_headers(&self, host: &str, headers: &mut HeaderMap) {
|
|
let normalized_host = normalize_host(host);
|
|
let state = self.read_state();
|
|
if !state.enabled {
|
|
return;
|
|
}
|
|
|
|
let matching_credentials = state
|
|
.credentials
|
|
.iter()
|
|
.filter(|credential| credential.matches_host(&normalized_host))
|
|
.collect::<Vec<_>>();
|
|
let Some(credential) = select_credential(headers, &matching_credentials) else {
|
|
return;
|
|
};
|
|
let Some(header_value) = credential
|
|
.provider
|
|
.request_header_value(&credential.real_value)
|
|
else {
|
|
return;
|
|
};
|
|
credential
|
|
.provider
|
|
.insert_request_header(headers, header_value);
|
|
}
|
|
|
|
fn read_state(&self) -> std::sync::RwLockReadGuard<'_, CredentialBrokerState> {
|
|
self.state
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
|
|
fn write_state(&self) -> std::sync::RwLockWriteGuard<'_, CredentialBrokerState> {
|
|
self.state
|
|
.write()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
}
|
|
}
|
|
|
|
fn virtualize_env_var(
|
|
env: &mut HashMap<String, String>,
|
|
state: &mut CredentialBrokerState,
|
|
env_var: &str,
|
|
provider: &'static providers::CredentialProvider,
|
|
host_binding: providers::CredentialHostBinding,
|
|
) {
|
|
let Some(real_value) = brokerable_credential_value(env, state, env_var, provider) else {
|
|
return;
|
|
};
|
|
|
|
let dummy_value = state.register(env_var, provider, host_binding, real_value);
|
|
env.insert(env_var.to_string(), dummy_value);
|
|
}
|
|
|
|
fn brokerable_credential_value<'a>(
|
|
env: &'a HashMap<String, String>,
|
|
state: &CredentialBrokerState,
|
|
env_var: &str,
|
|
provider: &providers::CredentialProvider,
|
|
) -> Option<&'a str> {
|
|
let real_value = env.get(env_var)?.trim();
|
|
(!real_value.is_empty()
|
|
&& !state.is_dummy_value(real_value)
|
|
&& provider.request_header_value(real_value).is_some())
|
|
.then_some(real_value)
|
|
}
|
|
|
|
impl CredentialBrokerState {
|
|
fn register(
|
|
&mut self,
|
|
env_var: &str,
|
|
provider: &'static providers::CredentialProvider,
|
|
host_binding: providers::CredentialHostBinding,
|
|
real_value: &str,
|
|
) -> String {
|
|
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
|
|
&& credential.real_value == real_value
|
|
}) {
|
|
return 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;
|
|
}
|
|
};
|
|
self.credentials.push(CredentialRecord {
|
|
env_var: env_var.to_string(),
|
|
provider,
|
|
host_binding,
|
|
real_value: real_value.to_string(),
|
|
dummy_value: dummy_value.clone(),
|
|
});
|
|
dummy_value
|
|
}
|
|
|
|
fn is_dummy_value(&self, value: &str) -> bool {
|
|
self.credentials
|
|
.iter()
|
|
.any(|credential| credential.dummy_value == value)
|
|
}
|
|
}
|
|
|
|
impl CredentialRecord {
|
|
fn matches_host(&self, host: &str) -> bool {
|
|
self.host_binding.matches_host(host)
|
|
}
|
|
}
|
|
|
|
fn select_credential<'a>(
|
|
headers: &HeaderMap,
|
|
matching_credentials: &[&'a CredentialRecord],
|
|
) -> Option<&'a CredentialRecord> {
|
|
let dummy_matches = matching_credentials
|
|
.iter()
|
|
.copied()
|
|
.filter(|credential| {
|
|
credential
|
|
.provider
|
|
.request_header(headers)
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value.contains(&credential.dummy_value))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
match dummy_matches.as_slice() {
|
|
[credential] => Some(*credential),
|
|
[] | [_, _, ..] => None,
|
|
}
|
|
}
|
|
|
|
fn update_brokered_credentials_marker(
|
|
state: &CredentialBrokerState,
|
|
env: &mut HashMap<String, String>,
|
|
) {
|
|
let brokered = providers::credential_broker_env_keys()
|
|
.filter_map(|key| {
|
|
let value = env.get(key)?;
|
|
state.is_dummy_value(value).then_some((key, value.as_str()))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
match serde_json::to_string(&brokered) {
|
|
Ok(marker) => {
|
|
env.insert(BROKERED_CREDENTIALS_ENV_KEY.to_string(), marker);
|
|
}
|
|
Err(_) => {
|
|
env.remove(BROKERED_CREDENTIALS_ENV_KEY);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns supported environment keys whose current values still match the child-scoped dummy
|
|
/// values recorded by the credential broker.
|
|
///
|
|
/// The broker marker is treated as untrusted: malformed metadata, unsupported keys, and values
|
|
/// replaced by the user are ignored. The environment is not mutated; callers own the decision to
|
|
/// remove the returned keys.
|
|
pub fn brokered_credential_dummy_env_keys(env: &HashMap<String, String>) -> Vec<String> {
|
|
env.get(BROKERED_CREDENTIALS_ENV_KEY)
|
|
.and_then(|marker| serde_json::from_str::<Vec<(String, String)>>(marker).ok())
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(|(key, dummy_value)| {
|
|
(providers::credential_broker_env_keys().any(|candidate| candidate == key.as_str())
|
|
&& env.get(&key) == Some(&dummy_value))
|
|
.then_some(key)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Returns supported credential keys only for an environment with an active broker.
|
|
pub fn brokered_credential_env_keys(
|
|
env: &HashMap<String, String>,
|
|
) -> impl Iterator<Item = &'static str> {
|
|
let active = env
|
|
.get(CREDENTIAL_BROKER_ACTIVE_ENV_KEY)
|
|
.is_some_and(|value| value == "1");
|
|
providers::credential_broker_env_keys().filter(move |_| active)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "credential_broker_tests.rs"]
|
|
mod tests;
|