Allow explicitly permitted loopback proxy targets (#34603)

## Why

With `allow_local_binding = false`, proxy-aware clients bypassed the managed
proxy for loopback and private IP targets. This prevented explicit local IP and
`localhost` allowlist entries from taking effect.

## What changed

- Route local targets through the managed proxy when local binding is disabled,
  while preserving direct access when it is enabled.
- Allow a non-public connection only when its requested IP literal or
  `localhost` target is explicitly permitted. Continue blocking private
  addresses reached through unrelated hostnames.
- Bypass inherited upstream proxies for non-public targets so Codex applies the
  local-target policy directly.

## Testing

Added connector, upstream proxy, environment override, and Linux sandbox
coverage for explicitly allowlisted loopback access.

GitOrigin-RevId: 35e0d73633d5051816e096bc5aa8d586aec5eea4
This commit is contained in:
viyatb-oai
2026-07-21 19:45:13 +00:00
committed by copyberry
parent 6c00dc087e
commit 2497972808
9 changed files with 310 additions and 75 deletions

View File

@@ -41,6 +41,8 @@ mode = "full" # default when unset; use "limited" for read-only mode
# If false, local/private networking is rejected. Explicit allowlisting of local IP literals
# (or `localhost`) is required to permit them.
# Hostnames that resolve to local/private IPs are still blocked even if allowlisted.
# Clients that always bypass proxies for loopback, such as Go's `net/http`, remain blocked by
# the operating-system sandbox when local binding is disabled.
allow_local_binding = false
# DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any

View File

@@ -1,10 +1,13 @@
use crate::policy::is_non_public_ip;
use crate::runtime::HostBlockDecision;
use crate::state::NetworkProxyState;
use rama_core::Service;
use rama_core::error::BoxError;
use rama_core::error::ErrorExt as _;
use rama_core::error::OpaqueError;
use rama_core::extensions::ExtensionsMut;
use rama_net::address::Host;
use rama_net::address::HostWithPort;
use rama_net::address::ProxyAddress;
use rama_net::client::EstablishedClientConnection;
use rama_net::transport::TryRefIntoTransportContext;
@@ -17,22 +20,12 @@ use std::sync::Arc;
#[derive(Clone)]
pub(crate) struct TargetCheckedTcpConnector {
policy: TargetPolicy,
state: Arc<NetworkProxyState>,
}
impl TargetCheckedTcpConnector {
pub(crate) fn new(state: Arc<NetworkProxyState>) -> Self {
Self {
policy: TargetPolicy::State(state),
}
}
pub(crate) fn from_allow_local_binding(allow_local_binding: bool) -> Self {
Self {
policy: TargetPolicy::Config {
allow_local_binding,
},
}
Self { state }
}
}
@@ -49,9 +42,16 @@ where
return TcpConnector::new().serve(input).await;
}
let target = input
.try_ref_into_transport_ctx()
.map_err(|err| OpaqueError::from_boxed(err.into()).context("read network target"))?
.host_with_port()
.ok_or_else(|| OpaqueError::from_display("network target is missing a port"))?;
TcpConnector::new()
.with_connector(TargetCheckedStreamConnector {
policy: self.policy.clone(),
state: self.state.clone(),
target,
})
.serve(input)
.await
@@ -60,14 +60,15 @@ where
#[derive(Clone)]
struct TargetCheckedStreamConnector {
policy: TargetPolicy,
state: Arc<NetworkProxyState>,
target: HostWithPort,
}
impl TcpStreamConnector for TargetCheckedStreamConnector {
type Error = BoxError;
async fn connect(&self, addr: SocketAddr) -> Result<TcpStream, Self::Error> {
if !self.policy.allow_local_binding().await? && is_non_public_ip(addr.ip()) {
if is_non_public_ip(addr.ip()) && !self.allows_non_public_target(addr).await? {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"network target rejected by policy",
@@ -82,24 +83,52 @@ impl TcpStreamConnector for TargetCheckedStreamConnector {
}
}
#[derive(Clone)]
enum TargetPolicy {
Config { allow_local_binding: bool },
State(Arc<NetworkProxyState>),
}
impl TargetCheckedStreamConnector {
async fn allows_non_public_target(&self, addr: SocketAddr) -> Result<bool, BoxError> {
if self.state.allow_local_binding().await.map_err(|err| {
let err: BoxError = err.into();
OpaqueError::from_boxed(err)
.context("read network proxy config")
.into_boxed()
})? {
return Ok(true);
}
impl TargetPolicy {
async fn allow_local_binding(&self) -> Result<bool, BoxError> {
match self {
Self::Config {
allow_local_binding,
} => Ok(*allow_local_binding),
Self::State(state) => state.allow_local_binding().await.map_err(|err| {
if !target_matches_non_public_addr(&self.target.host, addr.ip()) {
return Ok(false);
}
self.state
.host_blocked(&self.target.host.to_string(), self.target.port)
.await
.map(|decision| decision == HostBlockDecision::Allowed)
.map_err(|err| {
let err: BoxError = err.into();
OpaqueError::from_boxed(err)
.context("read network proxy config")
.context("evaluate network proxy target")
.into_boxed()
}),
})
}
}
pub(crate) fn is_non_public_target(host: &Host) -> bool {
match host {
Host::Address(ip) => is_non_public_ip(*ip),
Host::Name(name) => name
.as_str()
.trim_end_matches('.')
.eq_ignore_ascii_case("localhost"),
}
}
fn target_matches_non_public_addr(host: &Host, addr: std::net::IpAddr) -> bool {
match host {
Host::Address(ip) => *ip == addr,
Host::Name(name) => {
name.as_str()
.trim_end_matches('.')
.eq_ignore_ascii_case("localhost")
&& addr.is_loopback()
}
}
}
@@ -154,4 +183,56 @@ mod tests {
assert!(result.is_ok(), "local target should be allowed: {result:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn direct_connector_allows_explicitly_allowlisted_non_public_target() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.expect("bind local listener");
let target = listener.local_addr().expect("local addr");
let mut config = NetworkProxyConfig::default();
config.set_allowed_domains(vec![target.ip().to_string()]);
let connector =
TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy(config)));
let request: rama_tcp::client::Request =
rama_tcp::client::Request::new(HostWithPort::from(target));
let result = Service::serve(&connector, request).await;
assert!(
result.is_ok(),
"explicitly allowlisted local target should be allowed: {result:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn direct_connector_allows_explicitly_allowlisted_localhost_target() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.expect("bind local listener");
let target = listener.local_addr().expect("local addr");
let mut config = NetworkProxyConfig::default();
config.set_allowed_domains(vec!["localhost".to_string()]);
let connector =
TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy(config)));
let request: rama_tcp::client::Request =
rama_tcp::client::Request::new(HostWithPort::new(Host::LOCALHOST_NAME, target.port()));
let result = Service::serve(&connector, request).await;
assert!(
result.is_ok(),
"explicitly allowlisted localhost target should be allowed: {result:?}"
);
}
#[test]
fn resolved_private_address_does_not_match_allowlisted_hostname() {
let host = Host::Name("example.com".parse().expect("valid domain"));
assert!(!target_matches_non_public_addr(
&host,
Ipv4Addr::LOCALHOST.into()
));
}
}

View File

@@ -425,7 +425,7 @@ where
}
};
let proxy = if allow_upstream_proxy {
proxy_for_connect()
proxy_for_connect(&authority)
} else {
None
};

View File

@@ -44,6 +44,7 @@ use rama_http_backend::server::HttpServer;
use rama_net::proxy::ProxyTarget;
use rama_net::stream::SocketInfo;
use rama_net::tls::server::TlsPeekStream;
use rama_tls_rustls::dep::rustls;
use rama_tls_rustls::server::TlsAcceptorData;
use rama_tls_rustls::server::TlsAcceptorLayer;
use std::pin::Pin;
@@ -59,14 +60,14 @@ use tracing::warn;
/// State needed to terminate a CONNECT tunnel and enforce policy on inner HTTPS requests.
pub struct MitmState {
ca: Arc<ManagedMitmCa>,
upstream: UpstreamClient,
allow_upstream_proxy: bool,
upstream_tls_root_store: Arc<rustls::RootCertStore>,
inspect: bool,
max_body_bytes: usize,
}
pub(crate) struct MitmUpstreamConfig {
pub(crate) allow_upstream_proxy: bool,
pub(crate) allow_local_binding: bool,
}
#[derive(Clone)]
@@ -81,6 +82,7 @@ struct MitmPolicyContext {
struct MitmRequestContext {
policy: MitmPolicyContext,
mitm: Arc<MitmState>,
upstream: UpstreamClient,
}
enum MitmPolicyDecision {
@@ -160,21 +162,10 @@ impl MitmState {
let upstream_tls_root_store =
crate::certs::upstream_tls_root_store(&crate::certs::ca_env_from_process())?;
let upstream = if config.allow_upstream_proxy {
UpstreamClient::from_env_proxy_with_allow_local_binding(
config.allow_local_binding,
upstream_tls_root_store,
)
} else {
UpstreamClient::direct_with_allow_local_binding(
config.allow_local_binding,
upstream_tls_root_store,
)
};
Ok(Self {
ca,
upstream,
allow_upstream_proxy: config.allow_upstream_proxy,
upstream_tls_root_store,
inspect: MITM_INSPECT_BODIES,
max_body_bytes: MITM_MAX_BODY_BYTES,
})
@@ -222,6 +213,17 @@ where
.get::<NetworkMode>()
.copied()
.unwrap_or(NetworkMode::Full);
let upstream = if mitm.allow_upstream_proxy {
UpstreamClient::from_env_proxy_with_tls_root_store(
app_state.clone(),
mitm.upstream_tls_root_store.clone(),
)
} else {
UpstreamClient::direct_with_tls_root_store(
app_state.clone(),
mitm.upstream_tls_root_store.clone(),
)
};
let request_ctx = Arc::new(MitmRequestContext {
policy: MitmPolicyContext {
target_host,
@@ -230,6 +232,7 @@ where
app_state,
},
mitm,
upstream,
});
let executor = stream
@@ -320,7 +323,7 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
};
let upstream_req = Request::from_parts(parts, body);
let upstream_resp = mitm.upstream.serve(upstream_req).await?;
let upstream_resp = request_ctx.upstream.serve(upstream_req).await?;
respond_with_inspection(
upstream_resp,
inspect,

View File

@@ -572,10 +572,14 @@ fn apply_proxy_env_overrides(
// HTTP(S)_PROXY. Keep them aligned with the managed HTTP proxy endpoint.
set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);
// Keep loopback and IP-literal private targets direct so local IPC/LAN access avoids the proxy.
// Do not include hostname suffixes here: those can force clients to resolve internal names
// locally instead of letting the proxy resolve them.
set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
// Keep local targets direct only when local binding is enabled. Otherwise route them through
// the proxy so explicit literal allowlists and local-network restrictions can be enforced.
let no_proxy = if allow_local_binding {
DEFAULT_NO_PROXY_VALUE
} else {
""
};
set_env_keys(env, NO_PROXY_ENV_KEYS, no_proxy);
env.insert(
ELECTRON_GET_USE_PROXY_ENV_KEY.to_string(),
@@ -1413,15 +1417,7 @@ mod tests {
env.get("FTP_PROXY"),
Some(&"socks5h://127.0.0.1:8081".to_string())
);
assert_eq!(
env.get("NO_PROXY"),
Some(&DEFAULT_NO_PROXY_VALUE.to_string())
);
let no_proxy = env.get("NO_PROXY").expect("NO_PROXY should be set");
assert!(no_proxy.contains("10.0.0.0/8"));
assert!(no_proxy.contains("172.16.0.0/12"));
assert!(no_proxy.contains("192.168.0.0/16"));
assert!(!no_proxy.contains("169.254.0.0/16"));
assert_eq!(env.get("NO_PROXY"), Some(&String::new()));
assert_eq!(env.get(PROXY_ACTIVE_ENV_KEY), Some(&"1".to_string()));
assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string()));
assert_eq!(
@@ -1441,6 +1437,24 @@ mod tests {
assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
}
#[test]
fn apply_proxy_env_overrides_keeps_local_targets_direct_when_local_binding_enabled() {
let mut env = HashMap::new();
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
/*socks_enabled*/ true,
/*allow_local_binding*/ true,
/*mitm_ca_trust_bundle*/ None,
);
assert_eq!(
env.get("NO_PROXY"),
Some(&DEFAULT_NO_PROXY_VALUE.to_string())
);
}
#[test]
fn apply_proxy_env_overrides_sets_only_expected_env_keys() {
let mut env = HashMap::new();

View File

@@ -76,7 +76,6 @@ pub fn build_config_state(
let mitm = if config.mitm {
Some(Arc::new(MitmState::new(MitmUpstreamConfig {
allow_upstream_proxy: config.allow_upstream_proxy,
allow_local_binding: config.allow_local_binding,
})?))
} else {
None

View File

@@ -1,4 +1,5 @@
use crate::connect_policy::TargetCheckedTcpConnector;
use crate::connect_policy::is_non_public_target;
use crate::state::NetworkProxyState;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
use rama_core::Layer;
@@ -16,6 +17,7 @@ use rama_http::layer::version_adapter::RequestVersionAdapter;
use rama_http_backend::client::HttpClientService;
use rama_http_backend::client::HttpConnector;
use rama_http_backend::client::proxy::layer::HttpProxyConnectorLayer;
use rama_net::address::HostWithPort;
use rama_net::address::ProxyAddress;
use rama_net::client::EstablishedClientConnection;
use rama_net::http::RequestContext;
@@ -56,6 +58,13 @@ impl ProxyConfig {
self.http.clone().or_else(|| self.all.clone())
}
}
fn proxy_for_target(&self, target: &HostWithPort, is_secure: bool) -> Option<ProxyAddress> {
if is_non_public_target(&target.host) {
return None;
}
self.proxy_for_protocol(is_secure)
}
}
fn read_proxy_env(keys: &[&str]) -> Option<ProxyAddress> {
@@ -87,8 +96,8 @@ fn read_proxy_env(keys: &[&str]) -> Option<ProxyAddress> {
None
}
pub(crate) fn proxy_for_connect() -> Option<ProxyAddress> {
ProxyConfig::from_env().proxy_for_protocol(/*is_secure*/ true)
pub(crate) fn proxy_for_connect(target: &HostWithPort) -> Option<ProxyAddress> {
ProxyConfig::from_env().proxy_for_target(target, /*is_secure*/ true)
}
#[derive(Clone)]
@@ -118,24 +127,24 @@ impl UpstreamClient {
)
}
pub(crate) fn direct_with_allow_local_binding(
allow_local_binding: bool,
pub(crate) fn direct_with_tls_root_store(
state: Arc<NetworkProxyState>,
tls_root_store: Arc<rustls::RootCertStore>,
) -> Self {
Self::new(
ProxyConfig::default(),
TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding),
TargetCheckedTcpConnector::new(state),
tls_root_store,
)
}
pub(crate) fn from_env_proxy_with_allow_local_binding(
allow_local_binding: bool,
pub(crate) fn from_env_proxy_with_tls_root_store(
state: Arc<NetworkProxyState>,
tls_root_store: Arc<rustls::RootCertStore>,
) -> Self {
Self::new(
ProxyConfig::from_env(),
TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding),
TargetCheckedTcpConnector::new(state),
tls_root_store,
)
}
@@ -172,11 +181,12 @@ impl Service<Request<Body>> for UpstreamClient {
.as_ref()
.map(|ctx| ctx.host_with_port().to_string())
.unwrap_or_else(|| "<unknown>".to_string());
let proxy = self.proxy_config.proxy_for_protocol(
request_context
.as_ref()
.map(|ctx| ctx.protocol.is_secure())
.unwrap_or(false),
let proxy = request_context.as_ref().map_or_else(
|| self.proxy_config.proxy_for_protocol(/*is_secure*/ false),
|ctx| {
self.proxy_config
.proxy_for_target(&ctx.host_with_port(), ctx.protocol.is_secure())
},
);
match proxy.as_ref() {
Some(proxy) => info!(

View File

@@ -1,6 +1,11 @@
use super::*;
use crate::config::NetworkProxyConfig;
use crate::state::network_proxy_state_for_policy;
use pretty_assertions::assert_eq;
use rama_http::StatusCode;
use rama_http::Version;
use rama_net::address::Host;
use rama_net::address::HostWithPort;
use rama_tls_rustls::dep::pki_types::CertificateDer;
use rama_tls_rustls::dep::pki_types::PrivateKeyDer;
use rama_tls_rustls::dep::pki_types::pem::PemObject;
@@ -94,11 +99,14 @@ async fn mitm_upstream_client_trusts_startup_custom_ca() {
.unwrap();
});
let client =
UpstreamClient::direct_with_allow_local_binding(/*allow_local_binding*/ true, roots);
let mut config = NetworkProxyConfig::default();
config.set_allowed_domains(vec!["localhost".to_string()]);
let state = Arc::new(network_proxy_state_for_policy(config));
let client = UpstreamClient::direct_with_tls_root_store(state, roots);
let response = client
.serve(
Request::builder()
.version(Version::HTTP_2)
.uri(format!("https://localhost:{}/", address.port()))
.body(Body::empty())
.unwrap(),
@@ -109,3 +117,29 @@ async fn mitm_upstream_client_trusts_startup_custom_ca() {
assert_eq!(response.status(), StatusCode::OK);
server.await.unwrap();
}
#[test]
fn inherited_upstream_proxy_is_bypassed_for_non_public_targets() {
let proxy = ProxyAddress::try_from("http://127.0.0.1:43128").unwrap();
let config = ProxyConfig {
http: Some(proxy.clone()),
https: Some(proxy),
all: None,
};
for target in [
HostWithPort::new(Host::LOCALHOST_NAME, 8080),
HostWithPort::new(Host::LOCALHOST_IPV4, 8080),
HostWithPort::new(Host::Address("10.0.0.1".parse().unwrap()), 8080),
] {
assert_eq!(config.proxy_for_target(&target, /*is_secure*/ false), None);
assert_eq!(config.proxy_for_target(&target, /*is_secure*/ true), None);
}
let public = HostWithPort::new(Host::EXAMPLE_NAME, 443);
assert!(
config
.proxy_for_target(&public, /*is_secure*/ true)
.is_some()
);
}