mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Add constraint-aware standalone network proxy configuration (#57180)
## What changed - Export `StaticNetworkProxyConfig` to parse and serialize network policy with constraints, and validate them before building proxy state. - Add `--strict` and `from_slice_strict` for managed callers, requiring explicit `constraints` and rejecting unknown fields at every typed level. Default parsing permits top-level `metadata` and omitted constraints while still rejecting unknown policy fields. - Match allowed Unix socket paths case-sensitively when validating constraints. - Bound configuration file reads to the size limit plus one byte. ## Testing Add coverage for constraint round trips, weakened policy rejection, exact socket path matching, parsing modes, and CLI flag selection. Exercise automatic MITM enablement through both parsers. GitOrigin-RevId: c45d094624240f23dd4659128e72a4208d5d5f1c
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
mod standalone;
|
||||
|
||||
pub use standalone::StaticNetworkProxyConfig;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
use super::NetworkMode;
|
||||
use super::NetworkProxyConfig;
|
||||
use crate::ConfigState;
|
||||
use crate::NetworkProxyConstraints;
|
||||
use crate::Platform;
|
||||
use crate::build_config_state;
|
||||
use crate::validate_policy_against_constraints;
|
||||
use anyhow::Result;
|
||||
use anyhow::ensure;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A resolved policy, including the ceilings applied by managed requirements.
|
||||
///
|
||||
/// Keep this document outside the workload's writable filesystem. It is not an
|
||||
/// attestation of enforcement: the host must separately ensure that all workload
|
||||
/// egress traverses the proxy and bind readiness to the current environment.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct StaticNetworkProxyConfig {
|
||||
pub network: NetworkProxyConfig,
|
||||
/// Standalone configurations may omit constraints. Managed callers must export
|
||||
/// the resolved requirements and use `from_slice_strict` to require this field.
|
||||
pub constraints: NetworkProxyConstraints,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for StaticNetworkProxyConfig {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct Document {
|
||||
network: NetworkProxyConfig,
|
||||
#[serde(default)]
|
||||
constraints: NetworkProxyConstraints,
|
||||
}
|
||||
|
||||
let config: Document = deserialize_document(deserializer, /*allow_metadata*/ true)?;
|
||||
Ok(Self {
|
||||
network: config.network,
|
||||
constraints: config.constraints,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Only the top-level metadata object is extensible. Ignoring a matcher key can widen access.
|
||||
fn deserialize_document<'de, D, T>(
|
||||
deserializer: D,
|
||||
allow_metadata: bool,
|
||||
) -> std::result::Result<T, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
let mut unknown = None;
|
||||
let config = serde_ignored::deserialize(deserializer, |path| {
|
||||
let path = path.to_string();
|
||||
if unknown.is_none() && !(allow_metadata && path == "metadata") {
|
||||
unknown = Some(path);
|
||||
}
|
||||
})?;
|
||||
if let Some(path) = unknown {
|
||||
return Err(serde::de::Error::custom(format!("unknown field: {path}")));
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
impl StaticNetworkProxyConfig {
|
||||
/// Parse standalone policy, allowing optional constraints and top-level metadata.
|
||||
pub fn from_slice(bytes: &[u8]) -> Result<Self> {
|
||||
Ok(serde_json::from_slice::<Self>(bytes)?.normalize())
|
||||
}
|
||||
|
||||
/// Require explicit constraints and reject unknown fields at every typed level.
|
||||
pub fn from_slice_strict(bytes: &[u8]) -> Result<Self> {
|
||||
#[derive(Deserialize)]
|
||||
struct RequiredConstraints {
|
||||
network: NetworkProxyConfig,
|
||||
constraints: NetworkProxyConstraints,
|
||||
}
|
||||
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
|
||||
let config: RequiredConstraints =
|
||||
deserialize_document(&mut deserializer, /*allow_metadata*/ false)?;
|
||||
deserializer.end()?;
|
||||
Ok(Self {
|
||||
network: config.network,
|
||||
constraints: config.constraints,
|
||||
}
|
||||
.normalize())
|
||||
}
|
||||
|
||||
fn normalize(mut self) -> Self {
|
||||
self.network.mitm |=
|
||||
self.network.mode == NetworkMode::Limited || !self.network.mitm_hooks.is_empty();
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate before creating listeners or loading credential/CA material.
|
||||
pub fn build_state(self, platform: Platform) -> Result<ConfigState> {
|
||||
let config = self.normalize();
|
||||
ensure!(
|
||||
config.network.enabled,
|
||||
"standalone network proxy requires network.enabled = true"
|
||||
);
|
||||
validate_policy_against_constraints(&config.network, &config.constraints)?;
|
||||
build_config_state(config.network, config.constraints, platform)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn static_config_preserves_constraints_and_rejects_weakened_policy() {
|
||||
let bytes = serde_json::to_vec(&json!({
|
||||
"network": {
|
||||
"enabled": true,
|
||||
"mode": "limited",
|
||||
"allow_upstream_proxy": false,
|
||||
"domains": {"*.example.com": "allow", "blocked.example.com": "deny"}
|
||||
},
|
||||
"constraints": {
|
||||
"enabled": true,
|
||||
"allow_upstream_proxy": false,
|
||||
"allowed_domains": ["*.example.com"],
|
||||
"allowlist_expansion_enabled": false,
|
||||
"denied_domains": ["blocked.example.com"],
|
||||
"denylist_expansion_enabled": true
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let config = StaticNetworkProxyConfig::from_slice(&bytes).unwrap();
|
||||
let round_trip =
|
||||
StaticNetworkProxyConfig::from_slice(&serde_json::to_vec(&config).unwrap()).unwrap();
|
||||
assert_eq!(round_trip, config);
|
||||
let direct: StaticNetworkProxyConfig = serde_json::from_slice(&bytes).unwrap();
|
||||
assert!(!direct.network.mitm);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ca = crate::certs::ManagedMitmCa::for_test(dir.path()).unwrap();
|
||||
let state = crate::certs::TEST_MITM_CA
|
||||
.sync_scope(ca, || direct.build_state(Platform::Linux))
|
||||
.unwrap();
|
||||
assert!(state.mitm.is_some());
|
||||
assert_eq!(state.config, config.network);
|
||||
assert_eq!(state.constraints, config.constraints);
|
||||
|
||||
let mut weakened = config;
|
||||
weakened.network.allow_upstream_proxy = true;
|
||||
assert!(weakened.build_state(Platform::Linux).is_err());
|
||||
|
||||
for bytes in [
|
||||
br#"{"network":{"enabled":true},"constraints":{"enabled":false}}"#.as_slice(),
|
||||
br#"{"network":{"enabled":false},"constraints":{}}"#.as_slice(),
|
||||
] {
|
||||
assert!(
|
||||
StaticNetworkProxyConfig::from_slice(bytes)
|
||||
.unwrap()
|
||||
.build_state(Platform::Linux)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_socket_paths_match_exactly() {
|
||||
let mut policy = StaticNetworkProxyConfig::from_slice_strict(
|
||||
br#"{"network":{"enabled":true,"unix_sockets":{"/run/Secret.sock":"allow"}},
|
||||
"constraints":{"allow_unix_sockets":["/run/Secret.sock"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_policy_against_constraints(&policy.network, &policy.constraints).is_ok());
|
||||
policy
|
||||
.network
|
||||
.set_allow_unix_sockets(vec!["/run/secret.sock".to_string()]);
|
||||
assert!(validate_policy_against_constraints(&policy.network, &policy.constraints).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_modes_handle_constraints_and_unknown_fields() {
|
||||
let without_constraints = br#"{"network":{"enabled":true}}"#;
|
||||
assert!(StaticNetworkProxyConfig::from_slice(without_constraints).is_ok());
|
||||
assert!(StaticNetworkProxyConfig::from_slice_strict(without_constraints).is_err());
|
||||
|
||||
let baseline = json!({
|
||||
"network": {
|
||||
"enabled": true,
|
||||
"mitm_hooks": [{
|
||||
"host": "example.com",
|
||||
"match": {"methods": ["POST"]},
|
||||
"actions": {"strip_request_headers": ["x-debug"]}
|
||||
}]
|
||||
},
|
||||
"constraints": {"allow_upstream_proxy": false}
|
||||
});
|
||||
let expected =
|
||||
StaticNetworkProxyConfig::from_slice_strict(&serde_json::to_vec(&baseline).unwrap())
|
||||
.unwrap();
|
||||
let mut annotated = baseline.clone();
|
||||
annotated["metadata"] = json!({"producer_version": 2});
|
||||
let bytes = serde_json::to_vec(&annotated).unwrap();
|
||||
assert_eq!(
|
||||
StaticNetworkProxyConfig::from_slice(&bytes).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert!(StaticNetworkProxyConfig::from_slice_strict(&bytes).is_err());
|
||||
|
||||
for pointer in [
|
||||
"",
|
||||
"/network",
|
||||
"/constraints",
|
||||
"/network/mitm_hooks/0/match",
|
||||
"/network/mitm_hooks/0/actions",
|
||||
] {
|
||||
let mut config = baseline.clone();
|
||||
config.pointer_mut(pointer).unwrap()["future_setting"] = json!(true);
|
||||
let bytes = serde_json::to_vec(&config).unwrap();
|
||||
for parse in [
|
||||
StaticNetworkProxyConfig::from_slice,
|
||||
StaticNetworkProxyConfig::from_slice_strict,
|
||||
] {
|
||||
let error = parse(&bytes).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("future_setting"),
|
||||
"{pointer}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for bytes in [
|
||||
br#"{"constraints":{}}"#.as_slice(),
|
||||
br#"{"network":{"mitm_hooks":[{"host":"example.com","match":{"header":{}}}]},"constraints":{}}"#,
|
||||
br#"{"network":{},"constraint":{}}"#,
|
||||
br#"{"network":{"enabled":"true"},"constraints":{}}"#,
|
||||
br#"{"network":{},"constraints":null}"#,
|
||||
br#"{"network":{},"constraints":{}} {}"#,
|
||||
] {
|
||||
assert!(StaticNetworkProxyConfig::from_slice(bytes).is_err());
|
||||
assert!(StaticNetworkProxyConfig::from_slice_strict(bytes).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ pub use config::NetworkMode;
|
||||
pub use config::NetworkProxyConfig;
|
||||
pub use config::NetworkUnixSocketPermission;
|
||||
pub use config::NetworkUnixSocketPermissions;
|
||||
pub use config::StaticNetworkProxyConfig;
|
||||
pub use config::host_and_port_from_network_addr;
|
||||
pub use config::managed_proxy_ports;
|
||||
pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY;
|
||||
|
||||
@@ -7,14 +7,11 @@ use clap::Parser;
|
||||
use codex_network_proxy::ConfigReloader;
|
||||
use codex_network_proxy::ConfigReloaderFuture;
|
||||
use codex_network_proxy::ConfigState;
|
||||
use codex_network_proxy::NetworkMode;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_network_proxy::NetworkProxyConstraints;
|
||||
use codex_network_proxy::NetworkProxyState;
|
||||
use codex_network_proxy::Platform;
|
||||
use codex_network_proxy::build_config_state;
|
||||
use serde::Deserialize;
|
||||
use codex_network_proxy::StaticNetworkProxyConfig;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
@@ -27,29 +24,19 @@ struct Args {
|
||||
/// Standalone JSON configuration containing a `network` object.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
config: PathBuf,
|
||||
/// Reject unknown config fields and require explicit managed constraints.
|
||||
#[arg(long)]
|
||||
strict: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StandaloneConfig {
|
||||
network: NetworkProxyConfig,
|
||||
}
|
||||
|
||||
fn parse_standalone_config(bytes: &[u8]) -> Result<StandaloneConfig> {
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(bytes);
|
||||
let mut ignored_fields = Vec::new();
|
||||
let mut config: StandaloneConfig = serde_ignored::deserialize(&mut deserializer, |path| {
|
||||
ignored_fields.push(path.to_string());
|
||||
})?;
|
||||
deserializer.end()?;
|
||||
ensure!(
|
||||
ignored_fields.is_empty(),
|
||||
"unknown fields in network proxy config: {}",
|
||||
ignored_fields.join(", ")
|
||||
);
|
||||
config.network.mitm |=
|
||||
config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty();
|
||||
Ok(config)
|
||||
impl Args {
|
||||
fn parse_config(&self, bytes: &[u8]) -> Result<StaticNetworkProxyConfig> {
|
||||
if self.strict {
|
||||
StaticNetworkProxyConfig::from_slice_strict(bytes)
|
||||
} else {
|
||||
StaticNetworkProxyConfig::from_slice(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticConfigReloader {
|
||||
@@ -76,29 +63,26 @@ async fn main() -> Result<()> {
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
let args = Args::parse();
|
||||
let bytes = std::fs::read(&args.config).with_context(|| {
|
||||
format!(
|
||||
"failed to read network proxy config: {}",
|
||||
args.config.display()
|
||||
)
|
||||
})?;
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(&args.config)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to read network proxy config: {}",
|
||||
args.config.display()
|
||||
)
|
||||
})?
|
||||
.take((MAX_CONFIG_BYTES + 1) as u64)
|
||||
.read_to_end(&mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() <= MAX_CONFIG_BYTES,
|
||||
"network proxy config exceeds {MAX_CONFIG_BYTES} bytes"
|
||||
);
|
||||
let config = parse_standalone_config(&bytes)
|
||||
let config = args
|
||||
.parse_config(&bytes)
|
||||
.with_context(|| format!("invalid network proxy config: {}", args.config.display()))?;
|
||||
ensure!(
|
||||
config.network.enabled,
|
||||
"standalone network proxy requires network.enabled = true"
|
||||
);
|
||||
|
||||
let config_state = build_config_state(
|
||||
config.network,
|
||||
NetworkProxyConstraints::default(),
|
||||
Platform::native(),
|
||||
)
|
||||
.context("failed to initialize network proxy policy")?;
|
||||
let config_state = config
|
||||
.build_state(Platform::native())
|
||||
.context("failed to initialize network proxy policy")?;
|
||||
let reloader = Arc::new(StaticConfigReloader {
|
||||
state: config_state.clone(),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::parse_standalone_config;
|
||||
use clap::Parser;
|
||||
use codex_network_proxy::NetworkMitmCaConfig;
|
||||
use codex_network_proxy::StaticNetworkProxyConfig;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -37,12 +38,17 @@ fn enables_mitm_for_limited_mode_and_hooks() {
|
||||
}),
|
||||
),
|
||||
] {
|
||||
let bytes = serde_json::to_vec(&json!({"network": network})).unwrap();
|
||||
let config = parse_standalone_config(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
(config.network.mitm, config.network.mitm_ca),
|
||||
(expected_mitm, expected_ca)
|
||||
);
|
||||
let bytes = serde_json::to_vec(&json!({"network": network, "constraints": {}})).unwrap();
|
||||
for parse in [
|
||||
StaticNetworkProxyConfig::from_slice,
|
||||
StaticNetworkProxyConfig::from_slice_strict,
|
||||
] {
|
||||
let config = parse(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
(config.network.mitm, config.network.mitm_ca),
|
||||
(expected_mitm, expected_ca.clone())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +66,12 @@ fn accepts_dynamic_domain_socket_and_hook_matcher_keys() {
|
||||
"headers": {"authorization": ["Bearer token"]}
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
"constraints": {}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let config = parse_standalone_config(&bytes).unwrap();
|
||||
let config = StaticNetworkProxyConfig::from_slice_strict(&bytes).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.network.allowed_domains(),
|
||||
@@ -77,69 +84,20 @@ fn accepts_dynamic_domain_socket_and_hook_matcher_keys() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_fields_at_every_mitm_hook_level() {
|
||||
for (network, unknown_field) in [
|
||||
(
|
||||
json!({"enabled": true, "unexpected_network": true}),
|
||||
"unexpected_network",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"enabled": true,
|
||||
"mitm_hooks": [{
|
||||
"host": "api.example.com",
|
||||
"unexpected_hook": true
|
||||
}]
|
||||
}),
|
||||
"unexpected_hook",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"enabled": true,
|
||||
"mitm_hooks": [{
|
||||
"host": "api.example.com",
|
||||
"match": {"unexpected_matcher": true}
|
||||
}]
|
||||
}),
|
||||
"unexpected_matcher",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"enabled": true,
|
||||
"mitm_hooks": [{
|
||||
"host": "api.example.com",
|
||||
"actions": {"unexpected_action": true}
|
||||
}]
|
||||
}),
|
||||
"unexpected_action",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"enabled": true,
|
||||
"mitm_hooks": [{
|
||||
"host": "api.example.com",
|
||||
"actions": {
|
||||
"inject_request_headers": [{
|
||||
"name": "authorization",
|
||||
"unexpected_header": true
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}),
|
||||
"unexpected_header",
|
||||
),
|
||||
fn strict_flag_controls_config_parsing() {
|
||||
let base = ["codex-network-proxy", "--config", "policy.json"];
|
||||
let lenient = super::Args::try_parse_from(base).unwrap();
|
||||
let strict = super::Args::try_parse_from(base.into_iter().chain(["--strict"])).unwrap();
|
||||
for bytes in [
|
||||
br#"{"network":{"enabled":true}}"#.as_slice(),
|
||||
br#"{"network":{"enabled":true},"metadata":{"future_setting":true},"constraints":{}}"#,
|
||||
] {
|
||||
let bytes = serde_json::to_vec(&json!({"network": network})).unwrap();
|
||||
let error = parse_standalone_config(&bytes).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(unknown_field),
|
||||
"expected {unknown_field} in {error}"
|
||||
);
|
||||
assert!(lenient.parse_config(bytes).is_ok());
|
||||
assert!(strict.parse_config(bytes).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_trailing_json_values() {
|
||||
let error = parse_standalone_config(br#"{"network":{"enabled":true}} {}"#).unwrap_err();
|
||||
assert!(error.to_string().contains("trailing characters"));
|
||||
assert!(
|
||||
strict
|
||||
.parse_config(br#"{"network":{"enabled":true},"constraints":{}}"#)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::policy::compile_denylist;
|
||||
use crate::policy::is_global_wildcard_domain_pattern;
|
||||
use crate::runtime::ConfigState;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -23,7 +24,7 @@ pub use crate::runtime::NetworkProxyState;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::runtime::network_proxy_state_for_policy;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct NetworkProxyConstraints {
|
||||
pub enabled: Option<bool>,
|
||||
pub mode: Option<NetworkMode>,
|
||||
@@ -383,14 +384,11 @@ pub fn validate_policy_against_constraints(
|
||||
}
|
||||
|
||||
if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets {
|
||||
let allowed_set: HashSet<String> = allow_unix_sockets
|
||||
.iter()
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect();
|
||||
let allowed_set: HashSet<&str> = allow_unix_sockets.iter().map(String::as_str).collect();
|
||||
validate(config_allow_unix_sockets, move |candidate| {
|
||||
let mut invalid = Vec::new();
|
||||
for entry in candidate {
|
||||
if !allowed_set.contains(&entry.to_ascii_lowercase()) {
|
||||
if !allowed_set.contains(entry.as_str()) {
|
||||
invalid.push(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user