mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Support caller-provided MITM CAs in the network proxy (#47132)
## What changed - Add optional `network.mitm_ca` configuration with `certificate_file` and `private_key_file` for trusted proxy configuration. Require MITM to be enabled and retain the generated CA when no external CA is configured. - Validate absolute paths, regular files, file sizes, certificate/key matching, and Unix private-key permissions. Reject symlinks on Unix, private keys embedded in certificate files, and external private keys on non-Unix platforms. - Store derived external-CA trust bundles in the proxy artifact directory, include only certificates, and skip inherited generated bundles to avoid retaining stale roots. - Preserve MITM for external CAs when disabling credential brokerage. Reject external CA configuration for remote execution, even when the proxy is disabled, and reject unknown fields in `NetworkProxyConfig`. - Default the upstream request version adapter to HTTP/1.1 and remove the request URI from upstream request failure context. ## Testing Add regression coverage for external CA configuration, mismatched keys, embedded private keys, non-Unix rejection, trust-bundle placement and contents, stale-root exclusion, credential-broker toggling, and remote configuration rejection. GitOrigin-RevId: cb209295c19fbe53a9fa0194f11ff06acccabef6
This commit is contained in:
@@ -57,6 +57,7 @@ pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[target.'cfg(target_family = "unix")'.dependencies]
|
||||
libc = { workspace = true }
|
||||
rama-unix = { version = "=0.3.0-alpha.4" }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -104,7 +104,8 @@ standalone JSON file:
|
||||
}
|
||||
```
|
||||
|
||||
HTTPS MITM is enabled automatically for limited mode or configured `mitm_hooks`. Set `mitm` to
|
||||
`mitm` defaults to `false`. `mitm_ca` defaults to `None` and is omitted from serialized JSON when
|
||||
unset. HTTPS MITM is enabled automatically for limited mode or configured `mitm_hooks`. Set `mitm` to
|
||||
`true` to enable it explicitly. The proxy requires `network.enabled = true` and rejects unknown
|
||||
fields, including fields nested inside MITM hooks.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::config::NetworkMitmCaConfig;
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
@@ -27,6 +28,7 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read as _;
|
||||
use std::io::Write;
|
||||
use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
@@ -42,11 +44,12 @@ use tracing::warn;
|
||||
pub(super) struct ManagedMitmCa {
|
||||
issuer: Issuer<'static, KeyPair>,
|
||||
certificate_path: PathBuf,
|
||||
_artifact_lease: File,
|
||||
_artifact_lease: Option<File>,
|
||||
}
|
||||
|
||||
static MANAGED_MITM_CAS: LazyLock<Mutex<HashMap<PathBuf, Arc<ManagedMitmCa>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
const MAX_EXTERNAL_CA_FILE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
impl ManagedMitmCa {
|
||||
pub(super) fn load_or_create() -> Result<Arc<Self>> {
|
||||
@@ -89,11 +92,62 @@ impl ManagedMitmCa {
|
||||
Ok(Self {
|
||||
issuer,
|
||||
certificate_path,
|
||||
_artifact_lease: artifact_lease,
|
||||
_artifact_lease: Some(artifact_lease),
|
||||
})
|
||||
}
|
||||
|
||||
fn certificate_path(&self) -> &Path {
|
||||
pub(super) fn load_from_files(config: &NetworkMitmCaConfig) -> Result<Arc<Self>> {
|
||||
let certificate_path = Path::new(&config.certificate_file);
|
||||
let private_key_path = Path::new(&config.private_key_file);
|
||||
anyhow::ensure!(
|
||||
certificate_path.is_absolute(),
|
||||
"network.mitm_ca.certificate_file must be an absolute path"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
private_key_path.is_absolute(),
|
||||
"network.mitm_ca.private_key_file must be an absolute path"
|
||||
);
|
||||
let certificate_pem = read_external_ca_file(
|
||||
certificate_path,
|
||||
"network.mitm_ca.certificate_file",
|
||||
/*private*/ false,
|
||||
)?;
|
||||
anyhow::ensure!(
|
||||
!certificate_pem
|
||||
.lines()
|
||||
.any(|line| line.trim().starts_with("-----BEGIN ")
|
||||
&& line.contains("PRIVATE KEY-----")),
|
||||
"network.mitm_ca.certificate_file must not contain a private key"
|
||||
);
|
||||
let private_key_pem = read_external_ca_file(
|
||||
private_key_path,
|
||||
"network.mitm_ca.private_key_file",
|
||||
/*private*/ true,
|
||||
)?;
|
||||
let certificate = CertificateDer::from_pem_slice(certificate_pem.as_bytes())
|
||||
.context("failed to parse network.mitm_ca.certificate_file")?;
|
||||
let private_key_der = PrivateKeyDer::from_pem_slice(private_key_pem.as_bytes())
|
||||
.context("failed to parse network.mitm_ca.private_key_file")?;
|
||||
let provider = rustls::crypto::CryptoProvider::get_default()
|
||||
.context("rustls crypto provider is not installed")?;
|
||||
let certified_key =
|
||||
rustls::sign::CertifiedKey::from_der(vec![certificate], private_key_der, provider)
|
||||
.context("network.mitm_ca certificate and private key do not match")?;
|
||||
certified_key
|
||||
.keys_match()
|
||||
.context("network.mitm_ca certificate and private key do not match")?;
|
||||
let private_key = KeyPair::from_pem(&private_key_pem)
|
||||
.context("failed to parse network.mitm_ca.private_key_file")?;
|
||||
let issuer = Issuer::from_ca_cert_pem(&certificate_pem, private_key)
|
||||
.context("failed to parse network.mitm_ca certificate and private key")?;
|
||||
Ok(Arc::new(Self {
|
||||
issuer,
|
||||
certificate_path: certificate_path.to_path_buf(),
|
||||
_artifact_lease: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn certificate_path(&self) -> &Path {
|
||||
&self.certificate_path
|
||||
}
|
||||
|
||||
@@ -117,6 +171,54 @@ impl ManagedMitmCa {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_external_ca_file(path: &Path, field: &str, private: bool) -> Result<String> {
|
||||
// Do not accept a caller-provided signing key where its access controls cannot be verified.
|
||||
#[cfg(not(unix))]
|
||||
anyhow::ensure!(
|
||||
!private,
|
||||
"{field} is not supported on this platform because private-file permissions cannot be verified"
|
||||
);
|
||||
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = options
|
||||
.open(path)
|
||||
.with_context(|| format!("failed to open {field}"))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.with_context(|| format!("failed to inspect {field}"))?;
|
||||
anyhow::ensure!(
|
||||
metadata.file_type().is_file(),
|
||||
"{field} must be a regular file"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
metadata.len() <= MAX_EXTERNAL_CA_FILE_BYTES,
|
||||
"{field} exceeds {MAX_EXTERNAL_CA_FILE_BYTES} bytes"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
anyhow::ensure!(
|
||||
!private || metadata.permissions().mode() & 0o077 == 0,
|
||||
"{field} must not be accessible by group or other users"
|
||||
);
|
||||
}
|
||||
let mut pem = String::new();
|
||||
file.take(MAX_EXTERNAL_CA_FILE_BYTES + 1)
|
||||
.read_to_string(&mut pem)
|
||||
.with_context(|| format!("failed to read {field}"))?;
|
||||
anyhow::ensure!(
|
||||
pem.len() <= MAX_EXTERNAL_CA_FILE_BYTES as usize,
|
||||
"{field} exceeds {MAX_EXTERNAL_CA_FILE_BYTES} bytes"
|
||||
);
|
||||
Ok(pem)
|
||||
}
|
||||
|
||||
fn issue_host_certificate_pem(
|
||||
host: &str,
|
||||
issuer: &Issuer<'_, KeyPair>,
|
||||
@@ -150,6 +252,7 @@ const MANAGED_MITM_CA_DIR: &str = "proxy";
|
||||
const MANAGED_MITM_CA_ARTIFACT_LOCK: &str = ".artifacts.lock";
|
||||
const MANAGED_MITM_CA_CERT_PREFIX: &str = "ca";
|
||||
const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle";
|
||||
const MANAGED_MITM_EXTERNAL_CA_DIR: &str = "external";
|
||||
pub(crate) const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR";
|
||||
|
||||
// Best-effort compatibility set for common child toolchains that accept a CA bundle path.
|
||||
@@ -196,9 +299,28 @@ pub(crate) fn managed_ca_trust_bundle(
|
||||
managed_ca_trust_bundle_for_cert_path(ca.certificate_path(), env)
|
||||
}
|
||||
|
||||
fn managed_ca_trust_bundle_for_cert_path(
|
||||
pub(crate) fn managed_ca_trust_bundle_for_cert_path(
|
||||
cert_path: &Path,
|
||||
env: &HashMap<&'static str, String>,
|
||||
) -> Result<ManagedMitmCaTrustBundle> {
|
||||
let proxy_dir = cert_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("managed MITM CA cert path is missing a parent"))?;
|
||||
managed_ca_trust_bundle_for_cert_path_in_dir(cert_path, proxy_dir, env)
|
||||
}
|
||||
|
||||
pub(crate) fn managed_ca_trust_bundle_for_external_cert_path(
|
||||
cert_path: &Path,
|
||||
env: &HashMap<&'static str, String>,
|
||||
) -> Result<ManagedMitmCaTrustBundle> {
|
||||
let proxy_dir = managed_ca_dir()?.join(MANAGED_MITM_EXTERNAL_CA_DIR);
|
||||
managed_ca_trust_bundle_for_cert_path_in_dir(cert_path, &proxy_dir, env)
|
||||
}
|
||||
|
||||
fn managed_ca_trust_bundle_for_cert_path_in_dir(
|
||||
cert_path: &Path,
|
||||
proxy_dir: &Path,
|
||||
env: &HashMap<&'static str, String>,
|
||||
) -> Result<ManagedMitmCaTrustBundle> {
|
||||
let startup_env_values = startup_ca_file_env_values(env);
|
||||
let startup_cert_dir = env
|
||||
@@ -206,8 +328,8 @@ fn managed_ca_trust_bundle_for_cert_path(
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(String::as_str);
|
||||
let trust_bundle =
|
||||
build_managed_ca_trust_bundle(cert_path, &startup_env_values, startup_cert_dir)?;
|
||||
let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?;
|
||||
build_managed_ca_trust_bundle(cert_path, proxy_dir, &startup_env_values, startup_cert_dir)?;
|
||||
let path = persist_managed_ca_trust_bundle(proxy_dir, &trust_bundle)?;
|
||||
|
||||
Ok(ManagedMitmCaTrustBundle {
|
||||
path,
|
||||
@@ -215,13 +337,6 @@ fn managed_ca_trust_bundle_for_cert_path(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn upstream_tls_root_store(
|
||||
env: &HashMap<&'static str, String>,
|
||||
) -> Result<Arc<rustls::RootCertStore>> {
|
||||
let ca = ManagedMitmCa::load_or_create()?;
|
||||
upstream_tls_root_store_for_cert_path(ca.certificate_path(), env)
|
||||
}
|
||||
|
||||
pub(crate) fn upstream_tls_root_store_for_cert_path(
|
||||
managed_ca_cert_path: &Path,
|
||||
env: &HashMap<&'static str, String>,
|
||||
@@ -231,8 +346,10 @@ pub(crate) fn upstream_tls_root_store_for_cert_path(
|
||||
.get(SSL_CERT_DIR_ENV_KEY)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(String::as_str);
|
||||
let external_trust_bundle_dir = managed_ca_dir()?.join(MANAGED_MITM_EXTERNAL_CA_DIR);
|
||||
let certificates = load_platform_and_startup_root_certificates(
|
||||
managed_ca_cert_path,
|
||||
&external_trust_bundle_dir,
|
||||
&startup_env_values,
|
||||
startup_cert_dir,
|
||||
)?;
|
||||
@@ -262,23 +379,28 @@ fn startup_ca_file_env_values(
|
||||
|
||||
fn build_managed_ca_trust_bundle(
|
||||
managed_ca_cert_path: &Path,
|
||||
trust_bundle_dir: &Path,
|
||||
startup_env_values: &HashMap<&'static str, String>,
|
||||
startup_cert_dir: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let mut trust_bundle = String::new();
|
||||
for cert in load_platform_and_startup_root_certificates(
|
||||
managed_ca_cert_path,
|
||||
trust_bundle_dir,
|
||||
startup_env_values,
|
||||
startup_cert_dir,
|
||||
)? {
|
||||
push_certificate_pem(&mut trust_bundle, cert.as_ref());
|
||||
}
|
||||
append_pem_file(&mut trust_bundle, managed_ca_cert_path)?;
|
||||
for cert in read_ca_certificates(managed_ca_cert_path)? {
|
||||
push_certificate_pem(&mut trust_bundle, cert.as_ref());
|
||||
}
|
||||
Ok(trust_bundle)
|
||||
}
|
||||
|
||||
fn load_platform_and_startup_root_certificates(
|
||||
managed_ca_cert_path: &Path,
|
||||
trust_bundle_dir: &Path,
|
||||
startup_env_values: &HashMap<&'static str, String>,
|
||||
startup_cert_dir: Option<&str>,
|
||||
) -> Result<Vec<CertificateDer<'static>>> {
|
||||
@@ -306,7 +428,11 @@ fn load_platform_and_startup_root_certificates(
|
||||
.map(PathBuf::from)
|
||||
{
|
||||
if path != managed_ca_cert_path
|
||||
&& !is_current_generated_trust_bundle_path(&path, managed_ca_cert_path)
|
||||
&& !is_current_generated_trust_bundle_path(
|
||||
&path,
|
||||
managed_ca_cert_path,
|
||||
trust_bundle_dir,
|
||||
)
|
||||
&& appended_startup_paths.insert(path.clone())
|
||||
{
|
||||
certificates.extend(read_ca_certificates(&path)?);
|
||||
@@ -399,7 +525,14 @@ fn der_item_length(der: &[u8]) -> Option<usize> {
|
||||
.filter(|length| *length <= der.len())
|
||||
}
|
||||
|
||||
fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Path) -> bool {
|
||||
fn is_current_generated_trust_bundle_path(
|
||||
path: &Path,
|
||||
managed_ca_cert_path: &Path,
|
||||
trust_bundle_dir: &Path,
|
||||
) -> bool {
|
||||
if is_generated_trust_bundle_path(path, trust_bundle_dir) {
|
||||
return true;
|
||||
}
|
||||
let Some(proxy_dir) = managed_ca_cert_path.parent() else {
|
||||
return false;
|
||||
};
|
||||
@@ -459,16 +592,12 @@ pub fn is_managed_mitm_ca_trust_bundle_path(path: &str) -> bool {
|
||||
let Ok(proxy_dir) = managed_ca_dir() else {
|
||||
return false;
|
||||
};
|
||||
is_generated_trust_bundle_path(Path::new(path), &proxy_dir)
|
||||
let path = Path::new(path);
|
||||
is_generated_trust_bundle_path(path, &proxy_dir)
|
||||
|| is_generated_trust_bundle_path(path, &proxy_dir.join(MANAGED_MITM_EXTERNAL_CA_DIR))
|
||||
}
|
||||
|
||||
fn persist_managed_ca_trust_bundle(
|
||||
managed_ca_cert_path: &Path,
|
||||
trust_bundle: &str,
|
||||
) -> Result<PathBuf> {
|
||||
let proxy_dir = managed_ca_cert_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("managed MITM CA cert path is missing a parent"))?;
|
||||
fn persist_managed_ca_trust_bundle(proxy_dir: &Path, trust_bundle: &str) -> Result<PathBuf> {
|
||||
fs::create_dir_all(proxy_dir)
|
||||
.with_context(|| format!("failed to create {}", proxy_dir.display()))?;
|
||||
let hash = Sha256::digest(trust_bundle.as_bytes());
|
||||
@@ -489,19 +618,6 @@ fn persist_managed_ca_trust_bundle(
|
||||
Ok(trust_bundle_path)
|
||||
}
|
||||
|
||||
fn append_pem_file(bundle: &mut String, path: &Path) -> Result<()> {
|
||||
if !bundle.ends_with('\n') {
|
||||
bundle.push('\n');
|
||||
}
|
||||
let pem = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read CA bundle {}", path.display()))?;
|
||||
bundle.push_str(&pem);
|
||||
if !bundle.ends_with('\n') {
|
||||
bundle.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_certificate_pem(bundle: &mut String, der: &[u8]) {
|
||||
bundle.push_str("-----BEGIN CERTIFICATE-----\n");
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(der);
|
||||
@@ -835,6 +951,152 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_ca_certificate_rejects_private_key_material() {
|
||||
ensure_rustls_crypto_provider();
|
||||
let dir = tempdir().unwrap();
|
||||
let certificate_path = dir.path().join("certificate.pem");
|
||||
let private_key_path = dir.path().join("private-key.pem");
|
||||
let (certificate, private_key) = generate_ca().unwrap();
|
||||
let private_key = private_key.serialize_pem();
|
||||
fs::write(&certificate_path, format!("{certificate}{private_key}")).unwrap();
|
||||
fs::write(&private_key_path, private_key).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::set_permissions(&private_key_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
|
||||
let error = ManagedMitmCa::load_from_files(&NetworkMitmCaConfig {
|
||||
certificate_file: certificate_path.display().to_string(),
|
||||
private_key_file: private_key_path.display().to_string(),
|
||||
})
|
||||
.err()
|
||||
.expect("combined certificate and private key must be rejected");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("certificate_file must not contain a private key")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_ca_rejects_mismatched_private_key() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
ensure_rustls_crypto_provider();
|
||||
let dir = tempdir().unwrap();
|
||||
let certificate_path = dir.path().join("certificate.pem");
|
||||
let private_key_path = dir.path().join("private-key.pem");
|
||||
let (certificate, _) = generate_ca().unwrap();
|
||||
let (_, private_key) = generate_ca().unwrap();
|
||||
fs::write(&certificate_path, certificate).unwrap();
|
||||
fs::write(&private_key_path, private_key.serialize_pem()).unwrap();
|
||||
fs::set_permissions(&private_key_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
|
||||
let error = ManagedMitmCa::load_from_files(&NetworkMitmCaConfig {
|
||||
certificate_file: certificate_path.display().to_string(),
|
||||
private_key_file: private_key_path.display().to_string(),
|
||||
})
|
||||
.err()
|
||||
.expect("a mismatched external CA private key must be rejected");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("certificate and private key do not match")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[test]
|
||||
fn external_ca_private_key_fails_closed_without_unix_permissions() {
|
||||
let error = read_external_ca_file(
|
||||
Path::new("external-ca-key.pem"),
|
||||
"network.mitm_ca.private_key_file",
|
||||
/*private*/ true,
|
||||
)
|
||||
.expect_err("an external CA private key must fail closed on non-Unix platforms");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("private-file permissions cannot be verified")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_ca_trust_bundle_uses_the_proxy_artifact_directory() {
|
||||
ensure_rustls_crypto_provider();
|
||||
let certificate_dir = tempdir().unwrap();
|
||||
let artifact_dir = tempdir().unwrap();
|
||||
let certificate_path = certificate_dir.path().join("certificate.pem");
|
||||
let (certificate, private_key) = generate_ca().unwrap();
|
||||
let private_key = private_key.serialize_pem();
|
||||
fs::write(&certificate_path, format!("{certificate}{private_key}")).unwrap();
|
||||
|
||||
let trust_bundle = managed_ca_trust_bundle_for_cert_path_in_dir(
|
||||
&certificate_path,
|
||||
artifact_dir.path(),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(trust_bundle.path.parent(), Some(artifact_dir.path()));
|
||||
assert!(
|
||||
!fs::read_to_string(&trust_bundle.path)
|
||||
.unwrap()
|
||||
.contains("PRIVATE KEY"),
|
||||
"a derived trust bundle must contain certificates only"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(certificate_dir.path()).unwrap().count(),
|
||||
1,
|
||||
"building the trust bundle must not write next to the external CA"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_ca_trust_bundle_skips_inherited_generated_bundle() {
|
||||
let certificate_dir = tempdir().unwrap();
|
||||
let artifact_dir = tempdir().unwrap();
|
||||
let certificate_path = certificate_dir.path().join("certificate.pem");
|
||||
let (certificate, _) = generate_ca().unwrap();
|
||||
let (stale_certificate, _) = generate_ca().unwrap();
|
||||
fs::write(&certificate_path, &certificate).unwrap();
|
||||
let inherited_bundle_path = persist_managed_ca_trust_bundle(
|
||||
artifact_dir.path(),
|
||||
&format!("{stale_certificate}{certificate}"),
|
||||
)
|
||||
.unwrap();
|
||||
let env = HashMap::from([(
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
inherited_bundle_path.display().to_string(),
|
||||
)]);
|
||||
|
||||
let trust_bundle = managed_ca_trust_bundle_for_cert_path_in_dir(
|
||||
&certificate_path,
|
||||
artifact_dir.path(),
|
||||
&env,
|
||||
)
|
||||
.unwrap();
|
||||
let trust_bundle = fs::read_to_string(trust_bundle.path).unwrap();
|
||||
let certificates = CertificateDer::pem_slice_iter(trust_bundle.as_bytes())
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
let certificate = CertificateDer::from_pem_slice(certificate.as_bytes()).unwrap();
|
||||
let stale_certificate =
|
||||
CertificateDer::from_pem_slice(stale_certificate.as_bytes()).unwrap();
|
||||
|
||||
assert!(certificates.contains(&certificate));
|
||||
assert!(
|
||||
!certificates.contains(&stale_certificate),
|
||||
"an inherited generated bundle must not preserve stale roots"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_ca_artifact_pruning_preserves_only_active_certificates() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -850,11 +1112,9 @@ mod tests {
|
||||
} else {
|
||||
drop(lease);
|
||||
}
|
||||
let bundle_path = persist_managed_ca_trust_bundle(
|
||||
&certificate_path,
|
||||
&format!("roots\n{certificate}"),
|
||||
)
|
||||
.unwrap();
|
||||
let bundle_path =
|
||||
persist_managed_ca_trust_bundle(dir.path(), &format!("roots\n{certificate}"))
|
||||
.unwrap();
|
||||
artifacts.push((certificate_path, bundle_path));
|
||||
}
|
||||
let unrelated_path = dir.path().join("ca-user.pem");
|
||||
@@ -893,15 +1153,15 @@ mod tests {
|
||||
assert!(!is_current_generated_trust_bundle_path(
|
||||
&trust_bundle_path,
|
||||
&managed_ca_cert_path,
|
||||
dir.path(),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_trust_bundle_path_requires_matching_content_hash() {
|
||||
let dir = tempdir().unwrap();
|
||||
let managed_ca_cert_path = dir.path().join("ca.pem");
|
||||
let trust_bundle_path =
|
||||
persist_managed_ca_trust_bundle(&managed_ca_cert_path, "trusted roots").unwrap();
|
||||
persist_managed_ca_trust_bundle(dir.path(), "trusted roots").unwrap();
|
||||
|
||||
assert!(is_generated_trust_bundle_path(
|
||||
&trust_bundle_path,
|
||||
@@ -988,8 +1248,18 @@ mod tests {
|
||||
let trust_bundle =
|
||||
managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap();
|
||||
let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap();
|
||||
let baseline_certs = CertificateDer::pem_slice_iter(baseline_bundle.as_bytes())
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
let managed_ca_cert = CertificateDer::from_pem_slice(managed_ca_cert.as_bytes()).unwrap();
|
||||
|
||||
assert_eq!(baseline_bundle.matches(&managed_ca_cert).count(), 1);
|
||||
assert_eq!(
|
||||
baseline_certs
|
||||
.iter()
|
||||
.filter(|cert| *cert == &managed_ca_cert)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -116,8 +116,17 @@ pub struct NetworkUnixSocketPermissions {
|
||||
pub entries: BTreeMap<String, NetworkUnixSocketPermission>,
|
||||
}
|
||||
|
||||
/// A caller-provided MITM CA for trusted proxy configuration, not sandbox `config.toml`.
|
||||
/// The private key file is readable only by the proxy process.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NetworkMitmCaConfig {
|
||||
pub certificate_file: String,
|
||||
pub private_key_file: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct NetworkProxyConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
@@ -144,6 +153,8 @@ pub struct NetworkProxyConfig {
|
||||
pub allow_local_binding: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub mitm: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mitm_ca: Option<NetworkMitmCaConfig>,
|
||||
#[serde(default)]
|
||||
pub credential_broker: bool,
|
||||
/// Whether brokerage enabled MITM rather than inheriting an explicit setting.
|
||||
@@ -179,6 +190,7 @@ impl Default for NetworkProxyConfig {
|
||||
unix_sockets: None,
|
||||
allow_local_binding: None,
|
||||
mitm: false,
|
||||
mitm_ca: None,
|
||||
credential_broker: false,
|
||||
credential_broker_enabled_mitm: false,
|
||||
credential_providers: BTreeMap::new(),
|
||||
@@ -201,7 +213,7 @@ impl NetworkProxyConfig {
|
||||
self.credential_broker_enabled_mitm |= !self.mitm;
|
||||
self.mitm = true;
|
||||
} else if self.credential_broker_enabled_mitm {
|
||||
self.mitm = !self.mitm_hooks.is_empty();
|
||||
self.mitm = !self.mitm_hooks.is_empty() || self.mitm_ca.is_some();
|
||||
self.credential_broker_enabled_mitm = false;
|
||||
}
|
||||
}
|
||||
@@ -718,6 +730,7 @@ mod tests {
|
||||
unix_sockets: None,
|
||||
allow_local_binding: None,
|
||||
mitm: false,
|
||||
mitm_ca: None,
|
||||
credential_broker: false,
|
||||
credential_broker_enabled_mitm: false,
|
||||
credential_providers: BTreeMap::new(),
|
||||
@@ -760,6 +773,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_credential_broker_preserves_external_ca_added_while_enabled() {
|
||||
let mut config = NetworkProxyConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
config.set_credential_broker_enabled(/*enabled*/ true);
|
||||
let ca = NetworkMitmCaConfig {
|
||||
certificate_file: "/run/proxy/ca.pem".to_string(),
|
||||
private_key_file: "/run/proxy/key.pem".to_string(),
|
||||
};
|
||||
config.mitm_ca = Some(ca.clone());
|
||||
|
||||
config.set_credential_broker_enabled(/*enabled*/ false);
|
||||
|
||||
assert_eq!(
|
||||
config,
|
||||
NetworkProxyConfig {
|
||||
enabled: true,
|
||||
mitm: true,
|
||||
mitm_ca: Some(ca),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn ambiguous_credential_environment_preserves_remote_proxy_support() {
|
||||
@@ -873,6 +912,32 @@ mod tests {
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_proxy_config_supports_external_mitm_ca() {
|
||||
let config: NetworkProxyConfig = serde_json::from_str(
|
||||
r#"{
|
||||
"mitm": true,
|
||||
"mitm_ca": {
|
||||
"certificate_file": "/run/proxy/ca.pem",
|
||||
"private_key_file": "/run/proxy/key.pem"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config,
|
||||
NetworkProxyConfig {
|
||||
mitm: true,
|
||||
mitm_ca: Some(NetworkMitmCaConfig {
|
||||
certificate_file: "/run/proxy/ca.pem".to_string(),
|
||||
private_key_file: "/run/proxy/key.pem".to_string(),
|
||||
}),
|
||||
..NetworkProxyConfig::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_allowed_domains_preserves_existing_deny_for_same_pattern() {
|
||||
let mut settings = NetworkProxyConfig::default();
|
||||
|
||||
@@ -42,6 +42,7 @@ pub use codex_utils_path_uri::Platform;
|
||||
pub use config::NetworkDomainPermission;
|
||||
pub use config::NetworkDomainPermissionEntry;
|
||||
pub use config::NetworkDomainPermissions;
|
||||
pub use config::NetworkMitmCaConfig;
|
||||
pub use config::NetworkMode;
|
||||
pub use config::NetworkProxyConfig;
|
||||
pub use config::NetworkUnixSocketPermission;
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
use super::parse_standalone_config;
|
||||
use codex_network_proxy::NetworkMitmCaConfig;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn enables_mitm_for_limited_mode_and_hooks() {
|
||||
for (network, expected_mitm) in [
|
||||
(json!({"enabled": true, "mode": "full"}), false),
|
||||
(json!({"enabled": true, "mode": "full", "mitm": true}), true),
|
||||
(json!({"enabled": true, "mode": "limited"}), true),
|
||||
for (network, expected_mitm, expected_ca) in [
|
||||
(json!({"enabled": true, "mode": "full"}), false, None),
|
||||
(
|
||||
json!({"enabled": true, "mode": "full", "mitm": true}),
|
||||
true,
|
||||
None,
|
||||
),
|
||||
(json!({"enabled": true, "mode": "limited"}), true, None),
|
||||
(
|
||||
json!({
|
||||
"enabled": true,
|
||||
"mitm_hooks": [{"host": "api.example.com"}]
|
||||
}),
|
||||
true,
|
||||
None,
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"enabled": true,
|
||||
"mode": "limited",
|
||||
"mitm_ca": {
|
||||
"certificate_file": "/run/proxy/ca.pem",
|
||||
"private_key_file": "/run/proxy/key.pem"
|
||||
}
|
||||
}),
|
||||
true,
|
||||
Some(NetworkMitmCaConfig {
|
||||
certificate_file: "/run/proxy/ca.pem".to_string(),
|
||||
private_key_file: "/run/proxy/key.pem".to_string(),
|
||||
}),
|
||||
),
|
||||
] {
|
||||
let bytes = serde_json::to_vec(&json!({"network": network})).unwrap();
|
||||
let config = parse_standalone_config(&bytes).unwrap();
|
||||
assert_eq!(config.network.mitm, expected_mitm);
|
||||
assert_eq!(
|
||||
(config.network.mitm, config.network.mitm_ca),
|
||||
(expected_mitm, expected_ca)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::certs::ManagedMitmCa;
|
||||
use crate::config::NetworkMitmCaConfig;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::mitm_hook::HookEvaluation;
|
||||
use crate::mitm_hook::MitmHookActions;
|
||||
@@ -63,6 +64,7 @@ pub struct MitmState {
|
||||
|
||||
pub(crate) struct MitmUpstreamConfig {
|
||||
pub(crate) allow_upstream_proxy: bool,
|
||||
pub(crate) external_ca: Option<NetworkMitmCaConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -108,9 +110,14 @@ impl MitmState {
|
||||
// and host-specific hooks both need visibility after CONNECT is established. We
|
||||
// generate a process-local CA and issue per-host leaf certs so we can terminate TLS and
|
||||
// apply policy.
|
||||
let ca = ManagedMitmCa::load_or_create()?;
|
||||
let upstream_tls_root_store =
|
||||
crate::certs::upstream_tls_root_store(&crate::certs::ca_env_from_process())?;
|
||||
let ca = match config.external_ca.as_ref() {
|
||||
Some(ca) => ManagedMitmCa::load_from_files(ca)?,
|
||||
None => ManagedMitmCa::load_or_create()?,
|
||||
};
|
||||
let upstream_tls_root_store = crate::certs::upstream_tls_root_store_for_cert_path(
|
||||
ca.certificate_path(),
|
||||
&crate::certs::ca_env_from_process(),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
ca,
|
||||
|
||||
@@ -450,7 +450,13 @@ impl NetworkProxyRuntimeSettings {
|
||||
fn from_config(config: &config::NetworkProxyConfig) -> Result<Self> {
|
||||
let mitm_ca_trust_bundle = if config.mitm {
|
||||
let env = crate::certs::ca_env_from_process();
|
||||
Some(crate::certs::managed_ca_trust_bundle(&env)?)
|
||||
Some(match config.mitm_ca.as_ref() {
|
||||
Some(ca) => crate::certs::managed_ca_trust_bundle_for_external_cert_path(
|
||||
std::path::Path::new(&ca.certificate_file),
|
||||
&env,
|
||||
)?,
|
||||
None => crate::certs::managed_ca_trust_bundle(&env)?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -969,7 +975,7 @@ impl NetworkProxy {
|
||||
config.enabled &= !broker_only;
|
||||
config.credential_broker = false;
|
||||
config.dangerously_allow_plaintext_credential_injection = false;
|
||||
if config.mitm && config.mitm_hooks.is_empty() {
|
||||
if config.mitm && config.mitm_hooks.is_empty() && config.mitm_ca.is_none() {
|
||||
config.mitm = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +75,13 @@ pub struct RemoteNetworkProxyConfig {
|
||||
impl RemoteNetworkProxyConfig {
|
||||
pub fn from_effective_config(config: &NetworkProxyConfig) -> Result<Self> {
|
||||
ensure!(
|
||||
!config.enabled
|
||||
|| (!config.mitm
|
||||
&& !config.credential_broker
|
||||
&& !config.dangerously_allow_plaintext_credential_injection
|
||||
&& config.mitm_hooks.is_empty()),
|
||||
"remote exec-server network proxy does not support MITM, credential injection, or MITM hooks"
|
||||
config.mitm_ca.is_none()
|
||||
&& (!config.enabled
|
||||
|| (!config.mitm
|
||||
&& !config.credential_broker
|
||||
&& !config.dangerously_allow_plaintext_credential_injection
|
||||
&& config.mitm_hooks.is_empty())),
|
||||
"remote exec-server network proxy does not support MITM, external CAs, credential injection, or MITM hooks"
|
||||
);
|
||||
Ok(Self {
|
||||
enabled: config.enabled,
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::RemoteNetworkProxyLaunchConfig;
|
||||
use crate::LocalBindingPolicy::DefaultFalse;
|
||||
use crate::LocalBindingPolicy::RequireTrue;
|
||||
use crate::MitmHookConfig;
|
||||
use crate::NetworkMitmCaConfig;
|
||||
use crate::NetworkMode;
|
||||
use crate::NetworkProxy;
|
||||
use crate::NetworkProxyAuditMetadata;
|
||||
@@ -166,6 +167,26 @@ fn accepts_unsupported_configuration_when_proxy_is_disabled() {
|
||||
assert!(!remote.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_external_ca_even_when_proxy_is_disabled() {
|
||||
for (enabled, mitm_enabled) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let config = NetworkProxyConfig {
|
||||
enabled,
|
||||
mitm: mitm_enabled,
|
||||
mitm_ca: Some(NetworkMitmCaConfig {
|
||||
certificate_file: "/run/proxy/ca.pem".to_string(),
|
||||
private_key_file: "/run/proxy/key.pem".to_string(),
|
||||
}),
|
||||
..NetworkProxyConfig::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
RemoteNetworkProxyConfig::from_effective_config(&config).is_err(),
|
||||
"external CA configuration must not cross the remote executor boundary (proxy enabled={enabled}, MITM enabled={mitm_enabled})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_config_materializes_audit_and_execution_attribution() {
|
||||
let proxy = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig {
|
||||
|
||||
@@ -82,6 +82,10 @@ pub fn build_config_state(
|
||||
!config.credential_broker || config.mitm,
|
||||
"network.credential_broker requires network.mitm = true"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
config.mitm_ca.is_none() || config.mitm,
|
||||
"network.mitm_ca requires network.mitm = true"
|
||||
);
|
||||
let allowed_domains = config.allowed_domains().unwrap_or_default();
|
||||
let denied_domains = config.denied_domains().unwrap_or_default();
|
||||
validate_non_global_wildcard_domain_patterns("network.denied_domains", &denied_domains)
|
||||
@@ -92,6 +96,7 @@ pub fn build_config_state(
|
||||
let mitm = if config.mitm {
|
||||
Some(Arc::new(MitmState::new(MitmUpstreamConfig {
|
||||
allow_upstream_proxy: config.allow_upstream_proxy,
|
||||
external_ca: config.mitm_ca.clone(),
|
||||
})?))
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -13,6 +13,7 @@ use rama_core::service::BoxService;
|
||||
use rama_http::Body;
|
||||
use rama_http::Request;
|
||||
use rama_http::Response;
|
||||
use rama_http::Version;
|
||||
use rama_http::layer::version_adapter::RequestVersionAdapter;
|
||||
use rama_http_backend::client::HttpClientService;
|
||||
use rama_http_backend::client::HttpConnector;
|
||||
@@ -199,7 +200,6 @@ impl Service<Request<Body>> for UpstreamClient {
|
||||
req.extensions_mut().insert(proxy);
|
||||
}
|
||||
|
||||
let uri = req.uri().clone();
|
||||
let connect_started_at = Instant::now();
|
||||
let EstablishedClientConnection {
|
||||
input: mut req,
|
||||
@@ -238,8 +238,7 @@ impl Service<Request<Body>> for UpstreamClient {
|
||||
"HTTP upstream response headers failed (target={authority}, elapsed_ms={})",
|
||||
request_started_at.elapsed().as_millis()
|
||||
);
|
||||
Err(OpaqueError::from_boxed(err)
|
||||
.context(format!("http request failure for uri: {uri}")))
|
||||
Err(OpaqueError::from_boxed(err).context("HTTP upstream request failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +263,7 @@ fn build_http_connector(
|
||||
let tls = TlsConnectorLayer::auto()
|
||||
.with_connector_data(tls_config)
|
||||
.into_layer(proxy);
|
||||
let tls = RequestVersionAdapter::new(tls);
|
||||
let tls = RequestVersionAdapter::new(tls).with_default_version(Version::HTTP_11);
|
||||
let connector = HttpConnector::new(tls);
|
||||
connector.boxed()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user