mirror of
https://github.com/openai/codex.git
synced 2026-04-29 17:06:51 +00:00
tighten escape mechanisms
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user