mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
feat: introducing a network sandbox proxy (#8442)
This add a new crate, `codex-network-proxy`, a local network proxy service used by Codex to enforce fine-grained network policy (domain allow/deny) and to surface blocked network events for interactive approvals. - New crate: `codex-rs/network-proxy/` (`codex-network-proxy` binary + library) - Core capabilities: - HTTP proxy support (including CONNECT tunneling) - SOCKS5 proxy support (in the later PR) - policy evaluation (allowed/denied domain lists; denylist wins; wildcard support) - small admin API for polling/reload/mode changes - optional MITM support for HTTPS CONNECT to enforce “limited mode” method restrictions (later PR) Will follow up integration with codex in subsequent PRs. ## Testing - `cd codex-rs && cargo build -p codex-network-proxy` - `cd codex-rs && cargo run -p codex-network-proxy -- proxy`
This commit is contained in:
433
codex-rs/network-proxy/src/config.rs
Normal file
433
codex-rs/network-proxy/src/config.rs
Normal file
@@ -0,0 +1,433 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::net::IpAddr;
|
||||
use std::net::SocketAddr;
|
||||
use tracing::warn;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct NetworkProxyConfig {
|
||||
#[serde(default)]
|
||||
pub network_proxy: NetworkProxySettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkProxySettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_proxy_url")]
|
||||
pub proxy_url: String,
|
||||
#[serde(default = "default_admin_url")]
|
||||
pub admin_url: String,
|
||||
#[serde(default)]
|
||||
pub allow_upstream_proxy: bool,
|
||||
#[serde(default)]
|
||||
pub dangerously_allow_non_loopback_proxy: bool,
|
||||
#[serde(default)]
|
||||
pub dangerously_allow_non_loopback_admin: bool,
|
||||
#[serde(default)]
|
||||
pub mode: NetworkMode,
|
||||
#[serde(default)]
|
||||
pub policy: NetworkPolicy,
|
||||
}
|
||||
|
||||
impl Default for NetworkProxySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
proxy_url: default_proxy_url(),
|
||||
admin_url: default_admin_url(),
|
||||
allow_upstream_proxy: false,
|
||||
dangerously_allow_non_loopback_proxy: false,
|
||||
dangerously_allow_non_loopback_admin: false,
|
||||
mode: NetworkMode::default(),
|
||||
policy: NetworkPolicy::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct NetworkPolicy {
|
||||
#[serde(default)]
|
||||
pub allowed_domains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub denied_domains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_unix_sockets: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allow_local_binding: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkMode {
|
||||
/// Limited (read-only) access: only GET/HEAD/OPTIONS are allowed for HTTP. HTTPS CONNECT is
|
||||
/// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests.
|
||||
Limited,
|
||||
/// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without
|
||||
/// MITM interception.
|
||||
#[default]
|
||||
Full,
|
||||
}
|
||||
|
||||
impl NetworkMode {
|
||||
pub fn allows_method(self, method: &str) -> bool {
|
||||
match self {
|
||||
Self::Full => true,
|
||||
Self::Limited => matches!(method, "GET" | "HEAD" | "OPTIONS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_proxy_url() -> String {
|
||||
"http://127.0.0.1:3128".to_string()
|
||||
}
|
||||
|
||||
fn default_admin_url() -> String {
|
||||
"http://127.0.0.1:8080".to_string()
|
||||
}
|
||||
|
||||
/// Clamp non-loopback bind addresses to loopback unless explicitly allowed.
|
||||
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 dangerously_allow_non_loopback_proxy or dangerously_allow_non_loopback_admin to override)",
|
||||
port = addr.port()
|
||||
);
|
||||
SocketAddr::from(([127, 0, 0, 1], addr.port()))
|
||||
}
|
||||
|
||||
pub(crate) fn clamp_bind_addrs(
|
||||
http_addr: SocketAddr,
|
||||
admin_addr: SocketAddr,
|
||||
cfg: &NetworkProxySettings,
|
||||
) -> (SocketAddr, SocketAddr) {
|
||||
let http_addr = clamp_non_loopback(
|
||||
http_addr,
|
||||
cfg.dangerously_allow_non_loopback_proxy,
|
||||
"HTTP proxy",
|
||||
);
|
||||
let admin_addr = clamp_non_loopback(
|
||||
admin_addr,
|
||||
cfg.dangerously_allow_non_loopback_admin,
|
||||
"admin API",
|
||||
);
|
||||
if cfg.policy.allow_unix_sockets.is_empty() {
|
||||
return (http_addr, admin_addr);
|
||||
}
|
||||
|
||||
// `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.dangerously_allow_non_loopback_proxy && !http_addr.ip().is_loopback() {
|
||||
warn!(
|
||||
"unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping HTTP proxy to loopback"
|
||||
);
|
||||
}
|
||||
if cfg.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())),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct RuntimeConfig {
|
||||
pub http_addr: SocketAddr,
|
||||
pub admin_addr: SocketAddr,
|
||||
}
|
||||
|
||||
pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result<RuntimeConfig> {
|
||||
let http_addr = resolve_addr(&cfg.network_proxy.proxy_url, 3128).with_context(|| {
|
||||
format!(
|
||||
"invalid network_proxy.proxy_url: {}",
|
||||
cfg.network_proxy.proxy_url
|
||||
)
|
||||
})?;
|
||||
let admin_addr = resolve_addr(&cfg.network_proxy.admin_url, 8080).with_context(|| {
|
||||
format!(
|
||||
"invalid network_proxy.admin_url: {}",
|
||||
cfg.network_proxy.admin_url
|
||||
)
|
||||
})?;
|
||||
let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg.network_proxy);
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
http_addr,
|
||||
admin_addr,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_addr(url: &str, default_port: u16) -> Result<SocketAddr> {
|
||||
let addr_parts = parse_host_port(url, default_port)?;
|
||||
let host = if addr_parts.host.eq_ignore_ascii_case("localhost") {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
addr_parts.host
|
||||
};
|
||||
match host.parse::<IpAddr>() {
|
||||
Ok(ip) => Ok(SocketAddr::new(ip, addr_parts.port)),
|
||||
Err(_) => Ok(SocketAddr::from(([127, 0, 0, 1], addr_parts.port))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SocketAddressParts {
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
fn parse_host_port(url: &str, default_port: u16) -> Result<SocketAddressParts> {
|
||||
let trimmed = url.trim();
|
||||
if trimmed.is_empty() {
|
||||
bail!("missing host in network proxy address: {url}");
|
||||
}
|
||||
|
||||
// Avoid treating unbracketed IPv6 literals like "2001:db8::1" as scheme-prefixed URLs.
|
||||
if matches!(trimmed.parse::<IpAddr>(), Ok(IpAddr::V6(_))) && !trimmed.starts_with('[') {
|
||||
return Ok(SocketAddressParts {
|
||||
host: trimmed.to_string(),
|
||||
port: default_port,
|
||||
});
|
||||
}
|
||||
|
||||
// Prefer the standard URL parser when the input is URL-like. Prefix a scheme when absent so
|
||||
// we still accept loose host:port inputs.
|
||||
let candidate = if trimmed.contains("://") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("http://{trimmed}")
|
||||
};
|
||||
if let Ok(parsed) = Url::parse(&candidate)
|
||||
&& let Some(host) = parsed.host_str()
|
||||
{
|
||||
let host = host.trim_matches(|c| c == '[' || c == ']');
|
||||
if host.is_empty() {
|
||||
bail!("missing host in network proxy address: {url}");
|
||||
}
|
||||
return Ok(SocketAddressParts {
|
||||
host: host.to_string(),
|
||||
port: parsed.port().unwrap_or(default_port),
|
||||
});
|
||||
}
|
||||
|
||||
parse_host_port_fallback(trimmed, default_port)
|
||||
}
|
||||
|
||||
fn parse_host_port_fallback(input: &str, default_port: u16) -> Result<SocketAddressParts> {
|
||||
let without_scheme = input
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(input);
|
||||
let host_port = without_scheme.split('/').next().unwrap_or(without_scheme);
|
||||
let host_port = host_port
|
||||
.rsplit_once('@')
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(host_port);
|
||||
|
||||
if host_port.starts_with('[')
|
||||
&& let Some(end) = host_port.find(']')
|
||||
{
|
||||
let host = &host_port[1..end];
|
||||
let port = host_port[end + 1..]
|
||||
.strip_prefix(':')
|
||||
.and_then(|port| port.parse::<u16>().ok())
|
||||
.unwrap_or(default_port);
|
||||
if host.is_empty() {
|
||||
bail!("missing host in network proxy address: {input}");
|
||||
}
|
||||
return Ok(SocketAddressParts {
|
||||
host: host.to_string(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
// Only treat `host:port` as such when there's a single `:`. This avoids
|
||||
// accidentally interpreting unbracketed IPv6 addresses as `host:port`.
|
||||
if host_port.bytes().filter(|b| *b == b':').count() == 1
|
||||
&& let Some((host, port)) = host_port.rsplit_once(':')
|
||||
&& let Ok(port) = port.parse::<u16>()
|
||||
{
|
||||
if host.is_empty() {
|
||||
bail!("missing host in network proxy address: {input}");
|
||||
}
|
||||
return Ok(SocketAddressParts {
|
||||
host: host.to_string(),
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
if host_port.is_empty() {
|
||||
bail!("missing host in network proxy address: {input}");
|
||||
}
|
||||
Ok(SocketAddressParts {
|
||||
host: host_port.to_string(),
|
||||
port: default_port,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_defaults_for_empty_string() {
|
||||
assert!(parse_host_port("", 1234).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_defaults_for_whitespace() {
|
||||
assert!(parse_host_port(" ", 5555).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_parses_host_port_without_scheme() {
|
||||
assert_eq!(
|
||||
parse_host_port("127.0.0.1:8080", 3128).unwrap(),
|
||||
SocketAddressParts {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_parses_host_port_with_scheme_and_path() {
|
||||
assert_eq!(
|
||||
parse_host_port("http://example.com:8080/some/path", 3128).unwrap(),
|
||||
SocketAddressParts {
|
||||
host: "example.com".to_string(),
|
||||
port: 8080,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_strips_userinfo() {
|
||||
assert_eq!(
|
||||
parse_host_port("http://user:pass@host.example:5555", 3128).unwrap(),
|
||||
SocketAddressParts {
|
||||
host: "host.example".to_string(),
|
||||
port: 5555,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_parses_ipv6_with_brackets() {
|
||||
assert_eq!(
|
||||
parse_host_port("http://[::1]:9999", 3128).unwrap(),
|
||||
SocketAddressParts {
|
||||
host: "::1".to_string(),
|
||||
port: 9999,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_does_not_treat_unbracketed_ipv6_as_host_port() {
|
||||
assert_eq!(
|
||||
parse_host_port("2001:db8::1", 3128).unwrap(),
|
||||
SocketAddressParts {
|
||||
host: "2001:db8::1".to_string(),
|
||||
port: 3128,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_port_falls_back_to_default_port_when_port_is_invalid() {
|
||||
assert_eq!(
|
||||
parse_host_port("example.com:notaport", 3128).unwrap(),
|
||||
SocketAddressParts {
|
||||
host: "example.com:notaport".to_string(),
|
||||
port: 3128,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_addr_maps_localhost_to_loopback() {
|
||||
assert_eq!(
|
||||
resolve_addr("localhost", 3128).unwrap(),
|
||||
"127.0.0.1:3128".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_addr_parses_ip_literals() {
|
||||
assert_eq!(
|
||||
resolve_addr("1.2.3.4", 80).unwrap(),
|
||||
"1.2.3.4:80".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_addr_parses_ipv6_literals() {
|
||||
assert_eq!(
|
||||
resolve_addr("http://[::1]:8080", 3128).unwrap(),
|
||||
"[::1]:8080".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_addr_falls_back_to_loopback_for_hostnames() {
|
||||
assert_eq!(
|
||||
resolve_addr("http://example.com:5555", 3128).unwrap(),
|
||||
"127.0.0.1:5555".parse::<SocketAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_bind_addrs_allows_non_loopback_when_enabled() {
|
||||
let cfg = NetworkProxySettings {
|
||||
dangerously_allow_non_loopback_proxy: true,
|
||||
dangerously_allow_non_loopback_admin: true,
|
||||
..Default::default()
|
||||
};
|
||||
let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
|
||||
let admin_addr = "0.0.0.0:8080".parse::<SocketAddr>().unwrap();
|
||||
|
||||
let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg);
|
||||
|
||||
assert_eq!(http_addr, "0.0.0.0:3128".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(admin_addr, "0.0.0.0:8080".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() {
|
||||
let cfg = NetworkProxySettings {
|
||||
dangerously_allow_non_loopback_proxy: true,
|
||||
dangerously_allow_non_loopback_admin: true,
|
||||
policy: NetworkPolicy {
|
||||
allow_unix_sockets: vec!["/tmp/docker.sock".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let http_addr = "0.0.0.0:3128".parse::<SocketAddr>().unwrap();
|
||||
let admin_addr = "0.0.0.0:8080".parse::<SocketAddr>().unwrap();
|
||||
|
||||
let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg);
|
||||
|
||||
assert_eq!(http_addr, "127.0.0.1:3128".parse::<SocketAddr>().unwrap());
|
||||
assert_eq!(admin_addr, "127.0.0.1:8080".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user