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

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

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

## What changed

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

## Scope

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

## Validation

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

---------

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

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;