tighten escape mechanisms

This commit is contained in:
viyatb-oai
2025-12-23 23:18:47 -08:00
parent 2d7980340d
commit 10abb38b53
8 changed files with 476 additions and 96 deletions

View File

@@ -3,6 +3,7 @@ use serde::Serialize;
use std::net::IpAddr;
use std::net::SocketAddr;
use std::path::PathBuf;
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
@@ -19,6 +20,10 @@ pub struct NetworkProxyConfig {
#[serde(default = "default_admin_url")]
pub admin_url: String,
#[serde(default)]
pub dangerously_allow_non_loopback: bool,
#[serde(default)]
pub dangerously_allow_non_loopback_admin: bool,
#[serde(default)]
pub mode: NetworkMode,
#[serde(default)]
pub policy: NetworkPolicy,
@@ -32,6 +37,8 @@ impl Default for NetworkProxyConfig {
enabled: false,
proxy_url: default_proxy_url(),
admin_url: default_admin_url(),
dangerously_allow_non_loopback: false,
dangerously_allow_non_loopback_admin: false,
mode: NetworkMode::default(),
policy: NetworkPolicy::default(),
mitm: MitmConfig::default(),
@@ -105,6 +112,23 @@ fn default_mitm_max_body_bytes() -> usize {
4096
}
fn clamp_non_loopback(addr: SocketAddr, allow_non_loopback: bool, name: &str) -> SocketAddr {
if addr.ip().is_loopback() {
return addr;
}
if allow_non_loopback {
warn!("DANGEROUS: {name} listening on non-loopback address {addr}");
return addr;
}
warn!(
"{name} requested non-loopback bind ({addr}); clamping to 127.0.0.1:{port} (set the corresponding dangerously_allow_non_loopback* flag to override)",
port = addr.port()
);
SocketAddr::from(([127, 0, 0, 1], addr.port()))
}
pub struct RuntimeConfig {
pub http_addr: SocketAddr,
pub socks_addr: SocketAddr,
@@ -114,6 +138,39 @@ pub struct RuntimeConfig {
pub fn resolve_runtime(cfg: &Config) -> RuntimeConfig {
let http_addr = resolve_addr(&cfg.network_proxy.proxy_url, 3128);
let admin_addr = resolve_addr(&cfg.network_proxy.admin_url, 8080);
let http_addr = clamp_non_loopback(
http_addr,
cfg.network_proxy.dangerously_allow_non_loopback,
"HTTP proxy",
);
let admin_addr = clamp_non_loopback(
admin_addr,
cfg.network_proxy.dangerously_allow_non_loopback_admin,
"admin API",
);
let (http_addr, admin_addr) = if cfg.network_proxy.policy.allow_unix_sockets.is_empty() {
(http_addr, admin_addr)
} else {
// `x-unix-socket` is intentionally a local escape hatch. If the proxy (or admin API) is
// reachable from outside the machine, it can become a remote bridge into local daemons
// (e.g. docker.sock). To avoid footguns, enforce loopback binding whenever unix sockets
// are enabled.
if cfg.network_proxy.dangerously_allow_non_loopback && !http_addr.ip().is_loopback() {
warn!(
"unix socket proxying is enabled; ignoring dangerously_allow_non_loopback and clamping HTTP proxy to loopback"
);
}
if cfg.network_proxy.dangerously_allow_non_loopback_admin && !admin_addr.ip().is_loopback()
{
warn!(
"unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_admin and clamping admin API to loopback"
);
}
(
SocketAddr::from(([127, 0, 0, 1], http_addr.port())),
SocketAddr::from(([127, 0, 0, 1], admin_addr.port())),
)
};
let socks_addr = SocketAddr::from(([127, 0, 0, 1], 8081));
RuntimeConfig {

View File

@@ -1,6 +1,7 @@
use crate::config::NetworkMode;
use crate::mitm;
use crate::policy::normalize_host;
use crate::responses::blocked_header_value;
use crate::state::AppState;
use crate::state::BlockedRequest;
use anyhow::Context;
@@ -86,7 +87,7 @@ async fn http_connect_accept(
let app_state = ctx.state().clone();
let client = client_addr(&ctx);
match app_state.host_blocked(&host).await {
match app_state.host_blocked(&host, authority.port()).await {
Ok((true, reason)) => {
let _ = app_state
.record_blocked(BlockedRequest::new(
@@ -279,8 +280,9 @@ async fn http_plain_proxy(mut ctx: ProxyContext, req: Request) -> Result<Respons
}
};
let host = normalize_host(&authority.host().to_string());
let port = authority.port();
match app_state.host_blocked(&host).await {
match app_state.host_blocked(&host, port).await {
Ok((true, reason)) => {
let _ = app_state
.record_blocked(BlockedRequest::new(
@@ -391,12 +393,7 @@ fn json_blocked(host: &str, reason: &str) -> Response {
}
fn blocked_text(reason: &str) -> Response {
Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "text/plain")
.header("x-proxy-error", blocked_header_value(reason))
.body(Body::from(blocked_message(reason)))
.unwrap_or_else(|_| Response::new(Body::from("blocked")))
crate::responses::blocked_text_response(reason)
}
fn text_response(status: StatusCode, body: &str) -> Response {
@@ -406,24 +403,3 @@ fn text_response(status: StatusCode, body: &str) -> Response {
.body(Body::from(body.to_string()))
.unwrap_or_else(|_| Response::new(Body::from(body.to_string())))
}
fn blocked_header_value(reason: &str) -> &'static str {
match reason {
"not_allowed" | "not_allowed_local" => "blocked-by-allowlist",
"denied" => "blocked-by-denylist",
"method_not_allowed" => "blocked-by-method-policy",
"mitm_required" => "blocked-by-mitm-required",
_ => "blocked-by-policy",
}
}
fn blocked_message(reason: &str) -> &'static str {
match reason {
"not_allowed" => "Codex blocked this request: domain not in allowlist.",
"not_allowed_local" => "Codex blocked this request: local addresses not allowed.",
"denied" => "Codex blocked this request: domain denied by policy.",
"method_not_allowed" => "Codex blocked this request: method not allowed in limited mode.",
"mitm_required" => "Codex blocked this request: MITM required for limited HTTPS.",
_ => "Codex blocked this request by network policy.",
}
}

View File

@@ -2,6 +2,7 @@ use crate::config::MitmConfig;
use crate::config::NetworkMode;
use crate::policy::method_allowed;
use crate::policy::normalize_host;
use crate::responses::blocked_text_response;
use crate::state::AppState;
use crate::state::BlockedRequest;
use anyhow::Context;
@@ -34,12 +35,17 @@ use rama::tls::rustls::server::TlsAcceptorData;
use rama::tls::rustls::server::TlsAcceptorDataBuilder;
use rama::tls::rustls::server::TlsAcceptorLayer;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::BufReader;
use std::io::Write;
use std::net::IpAddr;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Context as TaskContext;
use std::task::Poll;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use tracing::info;
use tracing::warn;
@@ -453,9 +459,20 @@ fn load_or_create_ca(cfg: &MitmConfig) -> Result<(String, String)> {
}
let (cert_pem, key_pem) = generate_ca()?;
// The CA key is a high-value secret. Ensure it is not world-readable. The cert can be.
write_private_file(cert_path, cert_pem.as_bytes(), 0o644)?;
write_private_file(key_path, key_pem.as_bytes(), 0o600)?;
// The CA key is a high-value secret. Create it atomically with restrictive permissions.
// The cert can be world-readable, but we still write it atomically to avoid partial writes.
//
// We intentionally use create-new semantics: if a key already exists, we should not overwrite
// it silently (that would invalidate previously-trusted cert chains).
write_atomic_create_new(key_path, key_pem.as_bytes(), 0o600)
.with_context(|| format!("failed to persist CA key {}", key_path.display()))?;
if let Err(err) = write_atomic_create_new(cert_path, cert_pem.as_bytes(), 0o644)
.with_context(|| format!("failed to persist CA cert {}", cert_path.display()))
{
// Avoid leaving a partially-created CA around (cert missing) if the second write fails.
let _ = fs::remove_file(key_path);
return Err(err);
}
let cert_path = cert_path.display();
let key_path = key_path.display();
info!("generated MITM CA (cert_path={cert_path}, key_path={key_path})");
@@ -482,33 +499,73 @@ fn generate_ca() -> Result<(String, String)> {
Ok((cert.pem(), key_pair.serialize_pem()))
}
fn write_private_file(path: &std::path::Path, contents: &[u8], mode: u32) -> Result<()> {
fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?;
set_permissions(path, mode)?;
fn write_atomic_create_new(path: &std::path::Path, contents: &[u8], mode: u32) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow!("missing parent directory"))?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id();
let file_name = path.file_name().unwrap_or_default().to_string_lossy();
let tmp_path = parent.join(format!(".{file_name}.tmp.{pid}.{nanos}"));
let mut file = open_create_new_with_mode(&tmp_path, mode)?;
file.write_all(contents)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("failed to fsync {}", tmp_path.display()))?;
drop(file);
if path.exists() {
let _ = fs::remove_file(&tmp_path);
return Err(anyhow!(
"refusing to overwrite existing file {}",
path.display()
));
}
fs::rename(&tmp_path, path).with_context(|| {
format!(
"failed to rename {} -> {}",
tmp_path.display(),
path.display()
)
})?;
// Best-effort durability: ensure the directory entry is persisted too.
let dir = File::open(parent).with_context(|| format!("failed to open {}", parent.display()))?;
dir.sync_all()
.with_context(|| format!("failed to fsync {}", parent.display()))?;
Ok(())
}
#[cfg(unix)]
fn set_permissions(path: &std::path::Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
fn open_create_new_with_mode(path: &std::path::Path, mode: u32) -> Result<File> {
use std::os::unix::fs::OpenOptionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode))
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
Ok(())
OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(path)
.with_context(|| format!("failed to create {}", path.display()))
}
#[cfg(not(unix))]
fn set_permissions(_path: &std::path::Path, _mode: u32) -> Result<()> {
Ok(())
fn open_create_new_with_mode(path: &std::path::Path, _mode: u32) -> Result<File> {
OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.with_context(|| format!("failed to create {}", path.display()))
}
fn blocked_text(reason: &str) -> Response {
Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "text/plain")
.header("x-proxy-error", blocked_header_value(reason))
.body(Body::from(blocked_message(reason)))
.unwrap_or_else(|_| Response::new(Body::from("blocked")))
blocked_text_response(reason)
}
fn text_response(status: StatusCode, body: &str) -> Response {
@@ -518,20 +575,3 @@ fn text_response(status: StatusCode, body: &str) -> Response {
.body(Body::from(body.to_string()))
.unwrap_or_else(|_| Response::new(Body::from(body.to_string())))
}
fn blocked_header_value(reason: &str) -> &'static str {
match reason {
"not_allowed" | "not_allowed_local" => "blocked-by-allowlist",
"denied" => "blocked-by-denylist",
"method_not_allowed" => "blocked-by-method-policy",
"mitm_required" => "blocked-by-mitm-required",
_ => "blocked-by-policy",
}
}
fn blocked_message(reason: &str) -> &'static str {
match reason {
"method_not_allowed" => "Codex blocked this request: method not allowed in limited mode.",
_ => "Codex blocked this request by network policy.",
}
}

View File

@@ -1,5 +1,7 @@
use crate::config::NetworkMode;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
pub fn method_allowed(mode: NetworkMode, method: &str) -> bool {
match mode {
@@ -19,6 +21,37 @@ pub fn is_loopback_host(host: &str) -> bool {
false
}
pub fn is_non_public_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => is_non_public_ipv4(ip),
IpAddr::V6(ip) => is_non_public_ipv6(ip),
}
}
fn is_non_public_ipv4(ip: Ipv4Addr) -> bool {
// Use the standard library classification helpers where possible; they encode the intent more
// clearly than hand-rolled range checks.
ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_unspecified()
|| ip.is_multicast()
}
fn is_non_public_ipv6(ip: Ipv6Addr) -> bool {
// Treat anything that isn't globally routable as "local" for SSRF prevention. In particular:
// - `::1` loopback
// - `fc00::/7` unique-local (RFC 4193)
// - `fe80::/10` link-local
// - `::` unspecified
// - multicast ranges
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
}
pub fn normalize_host(host: &str) -> String {
let host = host.trim();
if host.starts_with('[')
@@ -78,6 +111,18 @@ mod tests {
assert!(!is_loopback_host("1.2.3.4"));
}
#[test]
fn is_non_public_ip_rejects_private_and_loopback_ranges() {
assert!(is_non_public_ip("127.0.0.1".parse().unwrap()));
assert!(is_non_public_ip("10.0.0.1".parse().unwrap()));
assert!(is_non_public_ip("192.168.0.1".parse().unwrap()));
assert!(!is_non_public_ip("8.8.8.8".parse().unwrap()));
assert!(is_non_public_ip("::1".parse().unwrap()));
assert!(is_non_public_ip("fe80::1".parse().unwrap()));
assert!(is_non_public_ip("fc00::1".parse().unwrap()));
}
#[test]
fn normalize_host_lowercases_and_trims() {
assert_eq!(normalize_host(" ExAmPlE.CoM "), "example.com");

View File

@@ -22,3 +22,33 @@ pub fn json_response<T: Serialize>(value: &T) -> Response {
.body(Body::from(body))
.unwrap_or_else(|_| Response::new(Body::from("{}")))
}
pub fn blocked_header_value(reason: &str) -> &'static str {
match reason {
"not_allowed" | "not_allowed_local" => "blocked-by-allowlist",
"denied" => "blocked-by-denylist",
"method_not_allowed" => "blocked-by-method-policy",
"mitm_required" => "blocked-by-mitm-required",
_ => "blocked-by-policy",
}
}
pub fn blocked_message(reason: &str) -> &'static str {
match reason {
"not_allowed" => "Codex blocked this request: domain not in allowlist.",
"not_allowed_local" => "Codex blocked this request: local/private addresses not allowed.",
"denied" => "Codex blocked this request: domain denied by policy.",
"method_not_allowed" => "Codex blocked this request: method not allowed in limited mode.",
"mitm_required" => "Codex blocked this request: MITM required for limited HTTPS.",
_ => "Codex blocked this request by network policy.",
}
}
pub fn blocked_text_response(reason: &str) -> Response {
Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "text/plain")
.header("x-proxy-error", blocked_header_value(reason))
.body(Body::from(blocked_message(reason)))
.unwrap_or_else(|_| Response::new(Body::from("blocked")))
}

View File

@@ -78,7 +78,7 @@ pub async fn run_socks5(state: Arc<AppState>, addr: SocketAddr) -> Result<()> {
}
}
match app_state.host_blocked(&host).await {
match app_state.host_blocked(&host, port).await {
Ok((true, reason)) => {
let _ = app_state
.record_blocked(BlockedRequest::new(

View File

@@ -3,6 +3,7 @@ use crate::config::MitmConfig;
use crate::config::NetworkMode;
use crate::mitm::MitmState;
use crate::policy::is_loopback_host;
use crate::policy::is_non_public_ip;
use crate::policy::method_allowed;
use anyhow::Context;
use anyhow::Result;
@@ -18,11 +19,13 @@ use serde::Deserialize;
use serde::Serialize;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::net::IpAddr;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
use time::OffsetDateTime;
use tokio::net::lookup_host;
use tokio::sync::RwLock;
use tracing::info;
use tracing::warn;
@@ -125,26 +128,43 @@ impl AppState {
}
}
pub async fn host_blocked(&self, host: &str) -> Result<(bool, String)> {
pub async fn host_blocked(&self, host: &str, port: u16) -> Result<(bool, String)> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
let (deny_set, allow_set, allow_local_binding, allowed_domains_empty) = {
let guard = self.state.read().await;
(
guard.deny_set.clone(),
guard.allow_set.clone(),
guard.config.network_proxy.policy.allow_local_binding,
guard.config.network_proxy.policy.allowed_domains.is_empty(),
)
};
// Decision order matters:
// 1) explicit deny always wins
// 2) local/loopback is opt-in (defense-in-depth)
// 2) local/private networking is opt-in (defense-in-depth)
// 3) allowlist is enforced when configured
if guard.deny_set.is_match(host) {
if deny_set.is_match(host) {
return Ok((true, "denied".to_string()));
}
let is_loopback = is_loopback_host(host);
if is_loopback
&& !guard.config.network_proxy.policy.allow_local_binding
&& !guard.allow_set.is_match(host)
{
return Ok((true, "not_allowed_local".to_string()));
if allowed_domains_empty {
return Ok((true, "not_allowed".to_string()));
}
if guard.config.network_proxy.policy.allowed_domains.is_empty()
|| !guard.allow_set.is_match(host)
{
if !allow_local_binding && !allow_set.is_match(host) {
// If the intent is "prevent access to local/internal networks", we must not rely solely
// on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or
// public suffix services that map hostnames onto private IPs.
//
// We therefore do a best-effort DNS + IP classification check before allowing the
// request. This closes the obvious bypass where the hostname itself isn't loopback.
if is_loopback_host(host) || host_resolves_to_non_public_ip(host, port).await? {
return Ok((true, "not_allowed_local".to_string()));
}
}
if !allow_set.is_match(host) {
return Ok((true, "not_allowed".to_string()));
}
Ok((false, String::new()))
@@ -169,14 +189,35 @@ impl AppState {
pub async fn is_unix_socket_allowed(&self, path: &str) -> Result<bool> {
self.reload_if_needed().await?;
if cfg!(not(target_os = "macos")) {
return Ok(false);
}
// We only support absolute unix socket paths (a relative path would be ambiguous with
// respect to the proxy process's CWD and can lead to confusing allowlist behavior).
if !Path::new(path).is_absolute() {
return Ok(false);
}
let guard = self.state.read().await;
Ok(guard
.config
.network_proxy
.policy
.allow_unix_sockets
.iter()
.any(|p| p == path))
let requested_canonical = std::fs::canonicalize(path).ok();
for allowed in &guard.config.network_proxy.policy.allow_unix_sockets {
if allowed == path {
return Ok(true);
}
// Best-effort canonicalization to reduce surprises with symlinks.
// If canonicalization fails (e.g., socket not created yet), fall back to raw comparison.
let Some(requested_canonical) = &requested_canonical else {
continue;
};
if let Ok(allowed_canonical) = std::fs::canonicalize(allowed)
&& &allowed_canonical == requested_canonical
{
return Ok(true);
}
}
Ok(false)
}
pub async fn method_allowed(&self, method: &str) -> Result<bool> {
@@ -230,6 +271,28 @@ impl AppState {
}
}
async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> Result<bool> {
if let Ok(ip) = host.parse::<IpAddr>() {
return Ok(is_non_public_ip(ip));
}
// If DNS lookup fails, default to "not local/private" rather than blocking. In practice, the
// subsequent connect attempt will fail anyway, and blocking on transient resolver issues would
// make the proxy fragile. The allowlist/denylist remains the primary control plane.
let addrs = match lookup_host((host, port)).await {
Ok(addrs) => addrs,
Err(_) => return Ok(false),
};
for addr in addrs {
if is_non_public_ip(addr.ip()) {
return Ok(true);
}
}
Ok(false)
}
async fn build_config_state() -> Result<ConfigState> {
// Load config through `codex-core` so we inherit the same layer ordering and semantics as the
// rest of Codex (system/managed layers, user layers, session flags, etc.).
@@ -297,6 +360,8 @@ struct PartialConfig {
struct PartialNetworkProxyConfig {
enabled: Option<bool>,
mode: Option<NetworkMode>,
dangerously_allow_non_loopback: Option<bool>,
dangerously_allow_non_loopback_admin: Option<bool>,
#[serde(default)]
policy: PartialNetworkPolicy,
}
@@ -317,6 +382,8 @@ struct PartialNetworkPolicy {
struct NetworkProxyConstraints {
enabled: Option<bool>,
mode: Option<NetworkMode>,
dangerously_allow_non_loopback: Option<bool>,
dangerously_allow_non_loopback_admin: Option<bool>,
allowed_domains: Option<Vec<String>>,
denied_domains: Option<Vec<String>>,
allow_unix_sockets: Option<Vec<String>>,
@@ -358,6 +425,17 @@ fn network_proxy_constraints_from_trusted_layers(
if let Some(mode) = partial.network_proxy.mode {
constraints.mode = Some(mode);
}
if let Some(dangerously_allow_non_loopback) =
partial.network_proxy.dangerously_allow_non_loopback
{
constraints.dangerously_allow_non_loopback = Some(dangerously_allow_non_loopback);
}
if let Some(dangerously_allow_non_loopback_admin) =
partial.network_proxy.dangerously_allow_non_loopback_admin
{
constraints.dangerously_allow_non_loopback_admin =
Some(dangerously_allow_non_loopback_admin);
}
if let Some(allowed_domains) = partial.network_proxy.policy.allowed_domains {
constraints.allowed_domains = Some(allowed_domains);
@@ -415,6 +493,42 @@ fn validate_policy_against_constraints(
})?;
}
let allow_non_loopback_admin = constraints.dangerously_allow_non_loopback_admin;
let _ = Constrained::new(
config.network_proxy.dangerously_allow_non_loopback_admin,
move |candidate| match allow_non_loopback_admin {
Some(true) | None => Ok(()),
Some(false) => {
if *candidate {
Err(ConstraintError::invalid_value(
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
}
},
)?;
let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback;
let _ = Constrained::new(
config.network_proxy.dangerously_allow_non_loopback,
move |candidate| match allow_non_loopback_proxy {
Some(true) | None => Ok(()),
Some(false) => {
if *candidate {
Err(ConstraintError::invalid_value(
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
}
},
)?;
if let Some(allow_local_binding) = constraints.allow_local_binding {
let _ = Constrained::new(
config.network_proxy.policy.allow_local_binding,
@@ -634,7 +748,7 @@ mod tests {
});
assert_eq!(
state.host_blocked("example.com").await.unwrap(),
state.host_blocked("example.com", 80).await.unwrap(),
(true, "denied".to_string())
);
}
@@ -647,11 +761,11 @@ mod tests {
});
assert_eq!(
state.host_blocked("example.com").await.unwrap(),
state.host_blocked("example.com", 80).await.unwrap(),
(false, String::new())
);
assert_eq!(
state.host_blocked("not-example.com").await.unwrap(),
state.host_blocked("not-example.com", 80).await.unwrap(),
(true, "not_allowed".to_string())
);
}
@@ -664,11 +778,11 @@ mod tests {
});
assert_eq!(
state.host_blocked("openai.com").await.unwrap(),
state.host_blocked("openai.com", 80).await.unwrap(),
(false, String::new())
);
assert_eq!(
state.host_blocked("api.openai.com").await.unwrap(),
state.host_blocked("api.openai.com", 80).await.unwrap(),
(false, String::new())
);
}
@@ -682,11 +796,11 @@ mod tests {
});
assert_eq!(
state.host_blocked("127.0.0.1").await.unwrap(),
state.host_blocked("127.0.0.1", 80).await.unwrap(),
(true, "not_allowed_local".to_string())
);
assert_eq!(
state.host_blocked("localhost").await.unwrap(),
state.host_blocked("localhost", 80).await.unwrap(),
(true, "not_allowed_local".to_string())
);
}
@@ -700,11 +814,25 @@ mod tests {
});
assert_eq!(
state.host_blocked("localhost").await.unwrap(),
state.host_blocked("localhost", 80).await.unwrap(),
(false, String::new())
);
}
#[tokio::test]
async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() {
let state = app_state_for_policy(NetworkPolicy {
allowed_domains: vec!["example.com".to_string()],
allow_local_binding: false,
..NetworkPolicy::default()
});
assert_eq!(
state.host_blocked("10.0.0.1", 80).await.unwrap(),
(true, "not_allowed_local".to_string())
);
}
#[test]
fn validate_policy_against_constraints_disallows_widening_allowed_domains() {
let constraints = NetworkProxyConstraints {
@@ -785,6 +913,42 @@ mod tests {
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_disallows_non_loopback_admin_without_managed_opt_in() {
let constraints = NetworkProxyConstraints {
dangerously_allow_non_loopback_admin: Some(false),
..NetworkProxyConstraints::default()
};
let config = Config {
network_proxy: NetworkProxyConfig {
enabled: true,
dangerously_allow_non_loopback_admin: true,
..NetworkProxyConfig::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
}
#[test]
fn validate_policy_against_constraints_allows_non_loopback_admin_with_managed_opt_in() {
let constraints = NetworkProxyConstraints {
dangerously_allow_non_loopback_admin: Some(true),
..NetworkProxyConstraints::default()
};
let config = Config {
network_proxy: NetworkProxyConfig {
enabled: true,
dangerously_allow_non_loopback_admin: true,
..NetworkProxyConfig::default()
},
};
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
}
#[test]
fn compile_globset_is_case_insensitive() {
let patterns = vec!["ExAmPle.CoM".to_string()];
@@ -836,6 +1000,39 @@ mod tests {
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn unix_socket_allowlist_resolves_symlinks() {
use std::os::unix::fs::symlink;
let unique = OffsetDateTime::now_utc().unix_timestamp_nanos();
let dir = std::env::temp_dir().join(format!("codex-network-proxy-test-{unique}"));
std::fs::create_dir_all(&dir).unwrap();
let real = dir.join("real.sock");
let link = dir.join("link.sock");
// The allowlist mechanism is path-based; for test purposes we don't need an actual unix
// domain socket. Any filesystem entry works for canonicalization.
std::fs::write(&real, b"not a socket").unwrap();
symlink(&real, &link).unwrap();
let real_s = real.to_str().unwrap().to_string();
let link_s = link.to_str().unwrap().to_string();
let state = app_state_for_policy(NetworkPolicy {
allowed_domains: vec!["example.com".to_string()],
allow_unix_sockets: vec![real_s],
..NetworkPolicy::default()
});
assert!(state.is_unix_socket_allowed(&link_s).await.unwrap());
let _ = std::fs::remove_file(&link);
let _ = std::fs::remove_file(&real);
let _ = std::fs::remove_dir(&dir);
}
#[cfg(not(target_os = "macos"))]
#[tokio::test]
async fn unix_socket_allowlist_is_rejected_on_non_macos() {