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:
viyatb-oai
2026-09-21 23:43:52 +00:00
committed by copyberry
parent cf6754e682
commit e4dba902ab
13 changed files with 470 additions and 68 deletions
+1
View File
@@ -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]
+2 -1
View File
@@ -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.
+315 -45
View File
@@ -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)]
+67 -2
View File
@@ -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();
+1
View File
@@ -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;
+29 -5
View File
@@ -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)
);
}
}
+10 -3
View File
@@ -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,
+8 -2
View File
@@ -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;
}
}
+7 -6
View File
@@ -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 {
+5
View File
@@ -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
+3 -4
View File
@@ -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()
}