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:
viyatb-oai
2026-10-26 18:22:40 +00:00
committed by copyberry
parent b8f6d25ce7
commit 457441bca9
7 changed files with 319 additions and 124 deletions
+4
View File
@@ -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());
}
}
}
+1
View File
@@ -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;
+28 -44
View File
@@ -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(),
});
+30 -72
View File
@@ -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()
);
}
+4 -6
View File
@@ -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());
}
}