use anyhow::Context; use anyhow::Result; use anyhow::bail; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::Serializer; use std::collections::BTreeMap; use std::net::IpAddr; use std::net::SocketAddr; use std::path::Path; use tracing::warn; use url::Url; #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct NetworkProxyConfig { #[serde(default)] pub network: NetworkProxySettings, } /// Variant order encodes effective precedence for duplicate patterns: /// `None < Allow < Deny`, so deny wins over allow when entries conflict. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "lowercase")] pub enum NetworkDomainPermission { None, Allow, Deny, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct NetworkDomainPermissionEntry { pub pattern: String, pub permission: NetworkDomainPermission, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct NetworkDomainPermissions { pub entries: Vec, } impl Serialize for NetworkDomainPermissions { fn serialize(&self, serializer: S) -> std::result::Result where S: Serializer, { self.effective_entries() .into_iter() .map(|entry| (entry.pattern, entry.permission)) .collect::>() .serialize(serializer) } } impl<'de> Deserialize<'de> for NetworkDomainPermissions { fn deserialize(deserializer: D) -> std::result::Result where D: Deserializer<'de>, { let entries = BTreeMap::::deserialize(deserializer)? .into_iter() .map(|(pattern, permission)| NetworkDomainPermissionEntry { pattern, permission, }) .collect(); Ok(Self { entries }) } } impl NetworkDomainPermissions { fn effective_entries(&self) -> Vec { let mut order = Vec::new(); let mut effective_permissions = BTreeMap::new(); for entry in &self.entries { if !effective_permissions.contains_key(&entry.pattern) { order.push(entry.pattern.clone()); } let permission = effective_permissions .entry(entry.pattern.clone()) .or_insert(entry.permission); if entry.permission > *permission { *permission = entry.permission; } } order .into_iter() .filter_map(|pattern| { effective_permissions.remove(&pattern).map(|permission| { NetworkDomainPermissionEntry { pattern, permission, } }) }) .collect() } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum NetworkUnixSocketPermission { Allow, None, } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct NetworkUnixSocketPermissions { #[serde(flatten)] pub entries: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct NetworkProxySettings { #[serde(default)] pub enabled: bool, #[serde(default = "default_proxy_url")] pub proxy_url: String, pub enable_socks5: bool, #[serde(default = "default_socks_url")] pub socks_url: String, pub enable_socks5_udp: bool, pub allow_upstream_proxy: bool, #[serde(default)] pub dangerously_allow_non_loopback_proxy: bool, #[serde(default)] pub dangerously_allow_all_unix_sockets: bool, #[serde(default)] pub mode: NetworkMode, #[serde(default)] pub domains: Option, #[serde(default)] pub unix_sockets: Option, pub allow_local_binding: bool, #[serde(default)] pub mitm: bool, } impl Default for NetworkProxySettings { fn default() -> Self { Self { enabled: false, proxy_url: default_proxy_url(), enable_socks5: true, socks_url: default_socks_url(), enable_socks5_udp: true, allow_upstream_proxy: true, dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::default(), domains: None, unix_sockets: None, allow_local_binding: false, mitm: false, } } } impl NetworkProxySettings { pub fn allowed_domains(&self) -> Option> { self.domain_entries(NetworkDomainPermission::Allow) } pub fn denied_domains(&self) -> Option> { self.domain_entries(NetworkDomainPermission::Deny) } fn domain_entries(&self, permission: NetworkDomainPermission) -> Option> { self.domains .as_ref() .map(|domains| { domains .effective_entries() .iter() .filter(|entry| entry.permission == permission) .map(|entry| entry.pattern.clone()) .collect() }) .filter(|entries: &Vec| !entries.is_empty()) } pub fn allow_unix_sockets(&self) -> Vec { self.unix_sockets .as_ref() .map(|unix_sockets| { unix_sockets .entries .iter() .filter(|(_, permission)| { matches!(permission, NetworkUnixSocketPermission::Allow) }) .map(|(path, _)| path.clone()) .collect() }) .unwrap_or_default() } pub fn set_allowed_domains(&mut self, allowed_domains: Vec) { self.set_domain_entries(allowed_domains, NetworkDomainPermission::Allow); } pub fn set_denied_domains(&mut self, denied_domains: Vec) { self.set_domain_entries(denied_domains, NetworkDomainPermission::Deny); } pub fn upsert_domain_permission( &mut self, host: String, permission: NetworkDomainPermission, normalize: impl Fn(&str) -> String, ) { let mut domains = self.domains.take().unwrap_or_default(); let normalized_host = normalize(&host); domains .entries .retain(|entry| normalize(&entry.pattern) != normalized_host); domains.entries.push(NetworkDomainPermissionEntry { pattern: host, permission, }); self.domains = (!domains.entries.is_empty()).then_some(domains); } pub fn set_allow_unix_sockets(&mut self, allow_unix_sockets: Vec) { self.set_unix_socket_entries(allow_unix_sockets, NetworkUnixSocketPermission::Allow); } fn set_domain_entries(&mut self, entries: Vec, permission: NetworkDomainPermission) { let mut domains = self.domains.take().unwrap_or_default(); domains .entries .retain(|entry| entry.permission != permission); for entry in entries { if !domains .entries .iter() .any(|existing| existing.pattern == entry && existing.permission == permission) { domains.entries.push(NetworkDomainPermissionEntry { pattern: entry, permission, }); } } self.domains = (!domains.entries.is_empty()).then_some(domains); } fn set_unix_socket_entries( &mut self, entries: Vec, permission: NetworkUnixSocketPermission, ) { let mut unix_sockets = self.unix_sockets.take().unwrap_or_default(); unix_sockets .entries .retain(|_, existing| *existing != permission); for entry in entries { unix_sockets.entries.insert(entry, permission); } self.unix_sockets = (!unix_sockets.entries.is_empty()).then_some(unix_sockets); } } #[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. /// SOCKS5 remains blocked in limited mode. 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_socks_url() -> String { "http://127.0.0.1:8081".to_string() } /// Clamp non-loopback bind addresses to loopback unless explicitly allowed. fn clamp_non_loopback( addr: SocketAddr, allow_non_loopback: bool, name: &str, override_setting_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 {override_setting_name} to override)", port = addr.port() ); SocketAddr::from(([127, 0, 0, 1], addr.port())) } pub(crate) fn clamp_bind_addrs( http_addr: SocketAddr, socks_addr: SocketAddr, cfg: &NetworkProxySettings, ) -> (SocketAddr, SocketAddr) { let http_addr = clamp_non_loopback( http_addr, cfg.dangerously_allow_non_loopback_proxy, "HTTP proxy", "dangerously_allow_non_loopback_proxy", ); let socks_addr = clamp_non_loopback( socks_addr, cfg.dangerously_allow_non_loopback_proxy, "SOCKS5 proxy", "dangerously_allow_non_loopback_proxy", ); if cfg.allow_unix_sockets().is_empty() && !cfg.dangerously_allow_all_unix_sockets { return (http_addr, socks_addr); } // `x-unix-socket` is intentionally a local escape hatch. If the proxy 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_proxy && !socks_addr.ip().is_loopback() { warn!( "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping SOCKS5 proxy to loopback" ); } ( SocketAddr::from(([127, 0, 0, 1], http_addr.port())), SocketAddr::from(([127, 0, 0, 1], socks_addr.port())), ) } pub struct RuntimeConfig { pub http_addr: SocketAddr, pub socks_addr: SocketAddr, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct UnixStyleAbsolutePath(String); impl UnixStyleAbsolutePath { fn parse(value: &str) -> Option { value.starts_with('/').then(|| Self(value.to_string())) } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ValidatedUnixSocketPath { Native(AbsolutePathBuf), UnixStyleAbsolute(UnixStyleAbsolutePath), } impl ValidatedUnixSocketPath { pub(crate) fn parse(socket_path: &str) -> Result { let path = Path::new(socket_path); if path.is_absolute() { let path = AbsolutePathBuf::from_absolute_path(path) .with_context(|| format!("failed to normalize unix socket path {socket_path:?}"))?; return Ok(Self::Native(path)); } if let Some(path) = UnixStyleAbsolutePath::parse(socket_path) { return Ok(Self::UnixStyleAbsolute(path)); } bail!("expected an absolute path, got {socket_path:?}"); } } pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> { for (index, socket_path) in cfg.network.allow_unix_sockets().iter().enumerate() { ValidatedUnixSocketPath::parse(socket_path) .with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?; } Ok(()) } pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result { validate_unix_socket_allowlist_paths(cfg)?; let http_addr = resolve_addr(&cfg.network.proxy_url, /*default_port*/ 3128) .with_context(|| format!("invalid network.proxy_url: {}", cfg.network.proxy_url))?; let socks_addr = resolve_addr(&cfg.network.socks_url, /*default_port*/ 8081) .with_context(|| format!("invalid network.socks_url: {}", cfg.network.socks_url))?; let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg.network); Ok(RuntimeConfig { http_addr, socks_addr, }) } fn resolve_addr(url: &str, default_port: u16) -> Result { 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::() { Ok(ip) => Ok(SocketAddr::new(ip, addr_parts.port)), Err(_) => Ok(SocketAddr::from(([127, 0, 0, 1], addr_parts.port))), } } pub fn host_and_port_from_network_addr(value: &str, default_port: u16) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return "".to_string(); } let parts = match parse_host_port(trimmed, default_port) { Ok(parts) => parts, Err(_) => { return format_host_and_port(trimmed, default_port); } }; format_host_and_port(&parts.host, parts.port) } fn format_host_and_port(host: &str, port: u16) -> String { if host.contains(':') { format!("[{host}]:{port}") } else { format!("{host}:{port}") } } #[derive(Debug, Clone, PartialEq, Eq)] struct SocketAddressParts { host: String, port: u16, } fn parse_host_port(url: &str, default_port: u16) -> Result { 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::(), 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 { 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::().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(':') { if host.is_empty() { bail!("missing host in network proxy address: {input}"); } return Ok(SocketAddressParts { host: host.to_string(), port: port.parse::().ok().unwrap_or(default_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; fn settings_with_unix_sockets(unix_sockets: &[&str]) -> NetworkProxySettings { let mut settings = NetworkProxySettings::default(); if !unix_sockets.is_empty() { settings.set_allow_unix_sockets( unix_sockets .iter() .map(|path| (*path).to_string()) .collect(), ); } settings } #[test] fn network_proxy_settings_default_matches_local_use_baseline() { assert_eq!( NetworkProxySettings::default(), NetworkProxySettings { enabled: false, proxy_url: "http://127.0.0.1:3128".to_string(), enable_socks5: true, socks_url: "http://127.0.0.1:8081".to_string(), enable_socks5_udp: true, allow_upstream_proxy: true, dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::Full, domains: None, unix_sockets: None, allow_local_binding: false, mitm: false, } ); } #[test] fn partial_network_config_uses_struct_defaults_for_missing_fields() { let config: NetworkProxyConfig = serde_json::from_str( r#"{ "network": { "enabled": true } }"#, ) .unwrap(); let expected = NetworkProxySettings { enabled: true, ..NetworkProxySettings::default() }; assert_eq!(config.network, expected); } #[test] fn set_allowed_domains_preserves_existing_deny_for_same_pattern() { let mut settings = NetworkProxySettings::default(); settings.set_denied_domains(vec!["example.com".to_string()]); settings.set_allowed_domains(vec!["example.com".to_string()]); assert_eq!(settings.allowed_domains(), None); assert_eq!( settings.denied_domains(), Some(vec!["example.com".to_string()]) ); } #[test] fn network_domain_permissions_serialize_to_effective_map_shape() { let mut settings = NetworkProxySettings::default(); settings.set_denied_domains(vec!["example.com".to_string()]); settings.set_allowed_domains(vec!["example.com".to_string()]); let config = NetworkProxyConfig { network: settings }; let value = serde_json::to_value(&config).unwrap(); assert_eq!( value, serde_json::json!({ "network": { "enabled": false, "proxy_url": "http://127.0.0.1:3128", "enable_socks5": true, "socks_url": "http://127.0.0.1:8081", "enable_socks5_udp": true, "allow_upstream_proxy": true, "dangerously_allow_non_loopback_proxy": false, "dangerously_allow_all_unix_sockets": false, "mode": "full", "domains": { "example.com": "deny", }, "unix_sockets": null, "allow_local_binding": false, "mitm": false, } }) ); } #[test] fn parse_host_port_defaults_for_empty_string() { assert!(parse_host_port("", /*default_port*/ 1234).is_err()); } #[test] fn parse_host_port_defaults_for_whitespace() { assert!(parse_host_port(" ", /*default_port*/ 5555).is_err()); } #[test] fn parse_host_port_parses_host_port_without_scheme() { assert_eq!( parse_host_port("127.0.0.1:8080", /*default_port*/ 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", /*default_port*/ 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", /*default_port*/ 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", /*default_port*/ 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", /*default_port*/ 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", /*default_port*/ 3128).unwrap(), SocketAddressParts { host: "example.com".to_string(), port: 3128, } ); } #[test] fn host_and_port_from_network_addr_defaults_for_empty_string() { assert_eq!( host_and_port_from_network_addr("", /*default_port*/ 1234), "" ); } #[test] fn host_and_port_from_network_addr_formats_ipv6() { assert_eq!( host_and_port_from_network_addr("http://[::1]:8080", /*default_port*/ 3128), "[::1]:8080" ); } #[test] fn resolve_addr_maps_localhost_to_loopback() { assert_eq!( resolve_addr("localhost", /*default_port*/ 3128).unwrap(), "127.0.0.1:3128".parse::().unwrap() ); } #[test] fn resolve_addr_parses_ip_literals() { assert_eq!( resolve_addr("1.2.3.4", /*default_port*/ 80).unwrap(), "1.2.3.4:80".parse::().unwrap() ); } #[test] fn resolve_addr_parses_ipv6_literals() { assert_eq!( resolve_addr("http://[::1]:8080", /*default_port*/ 3128).unwrap(), "[::1]:8080".parse::().unwrap() ); } #[test] fn resolve_addr_falls_back_to_loopback_for_hostnames() { assert_eq!( resolve_addr("http://example.com:5555", /*default_port*/ 3128).unwrap(), "127.0.0.1:5555".parse::().unwrap() ); } #[test] fn clamp_bind_addrs_allows_non_loopback_when_enabled() { let cfg = NetworkProxySettings { dangerously_allow_non_loopback_proxy: true, ..Default::default() }; let http_addr = "0.0.0.0:3128".parse::().unwrap(); let socks_addr = "0.0.0.0:8081".parse::().unwrap(); let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg); assert_eq!(http_addr, "0.0.0.0:3128".parse::().unwrap()); assert_eq!(socks_addr, "0.0.0.0:8081".parse::().unwrap()); } #[test] fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() { let cfg = { let mut settings = settings_with_unix_sockets(&["/tmp/docker.sock"]); settings.dangerously_allow_non_loopback_proxy = true; settings }; let http_addr = "0.0.0.0:3128".parse::().unwrap(); let socks_addr = "0.0.0.0:8081".parse::().unwrap(); let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg); assert_eq!(http_addr, "127.0.0.1:3128".parse::().unwrap()); assert_eq!(socks_addr, "127.0.0.1:8081".parse::().unwrap()); } #[test] fn clamp_bind_addrs_forces_loopback_when_all_unix_sockets_enabled() { let cfg = NetworkProxySettings { dangerously_allow_non_loopback_proxy: true, dangerously_allow_all_unix_sockets: true, ..Default::default() }; let http_addr = "0.0.0.0:3128".parse::().unwrap(); let socks_addr = "0.0.0.0:8081".parse::().unwrap(); let (http_addr, socks_addr) = clamp_bind_addrs(http_addr, socks_addr, &cfg); assert_eq!(http_addr, "127.0.0.1:3128".parse::().unwrap()); assert_eq!(socks_addr, "127.0.0.1:8081".parse::().unwrap()); } #[test] fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() { let cfg = NetworkProxyConfig { network: settings_with_unix_sockets(&["relative.sock"]), }; let err = match resolve_runtime(&cfg) { Ok(runtime) => panic!( "relative allow_unix_sockets should fail, but resolve_runtime succeeded: {:?}", runtime.http_addr ), Err(err) => err, }; assert!( err.to_string().contains("network.allow_unix_sockets[0]"), "error should point at the invalid allow_unix_sockets entry: {err:#}" ); } #[test] fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() { let cfg = NetworkProxyConfig { network: settings_with_unix_sockets(&["/private/tmp/example.sock"]), }; assert!( resolve_runtime(&cfg).is_ok(), "unix-style absolute allow_unix_sockets entry should be accepted" ); } }