mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Pin resolved IPs for proxy policy checks
This commit is contained in:
@@ -42,6 +42,7 @@ use rama_http_backend::server::layer::upgrade::Upgraded;
|
||||
use rama_net::Protocol;
|
||||
use rama_net::address::ProxyAddress;
|
||||
use rama_net::client::ConnectorService;
|
||||
use rama_net::client::ConnectorTarget;
|
||||
use rama_net::client::EstablishedClientConnection;
|
||||
use rama_net::http::RequestContext;
|
||||
use rama_net::proxy::ProxyRequest;
|
||||
@@ -156,8 +157,16 @@ async fn http_connect_accept(
|
||||
None,
|
||||
);
|
||||
|
||||
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
|
||||
Ok(NetworkDecision::Deny { reason }) => {
|
||||
let outcome = match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
error!("failed to evaluate host for CONNECT {host}: {err}");
|
||||
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
|
||||
}
|
||||
};
|
||||
|
||||
match outcome.decision {
|
||||
NetworkDecision::Deny { reason } => {
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(
|
||||
host.clone(),
|
||||
@@ -172,14 +181,14 @@ async fn http_connect_accept(
|
||||
warn!("CONNECT blocked (client={client}, host={host}, reason={reason})");
|
||||
return Err(blocked_text(&reason));
|
||||
}
|
||||
Ok(NetworkDecision::Allow) => {
|
||||
NetworkDecision::Allow => {
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
info!("CONNECT allowed (client={client}, host={host})");
|
||||
}
|
||||
Err(err) => {
|
||||
error!("failed to evaluate host for CONNECT {host}: {err}");
|
||||
return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(target) = outcome.connector_target {
|
||||
req.extensions_mut().insert(ConnectorTarget(target));
|
||||
}
|
||||
|
||||
let mode = app_state
|
||||
@@ -296,7 +305,7 @@ async fn forward_connect_tunnel(
|
||||
|
||||
async fn http_plain_proxy(
|
||||
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
|
||||
req: Request,
|
||||
mut req: Request,
|
||||
) -> Result<Response, Infallible> {
|
||||
let app_state = match req.extensions().get::<Arc<NetworkProxyState>>().cloned() {
|
||||
Some(state) => state,
|
||||
@@ -435,8 +444,16 @@ async fn http_plain_proxy(
|
||||
None,
|
||||
);
|
||||
|
||||
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
|
||||
Ok(NetworkDecision::Deny { reason }) => {
|
||||
let outcome = match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
error!("failed to evaluate host for {host}: {err}");
|
||||
return Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
|
||||
}
|
||||
};
|
||||
|
||||
match outcome.decision {
|
||||
NetworkDecision::Deny { reason } => {
|
||||
let _ = app_state
|
||||
.record_blocked(BlockedRequest::new(
|
||||
host.clone(),
|
||||
@@ -451,11 +468,7 @@ async fn http_plain_proxy(
|
||||
warn!("request blocked (client={client}, host={host}, reason={reason})");
|
||||
return Ok(json_blocked(&host, &reason));
|
||||
}
|
||||
Ok(NetworkDecision::Allow) => {}
|
||||
Err(err) => {
|
||||
error!("failed to evaluate host for {host}: {err}");
|
||||
return Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error"));
|
||||
}
|
||||
NetworkDecision::Allow => {}
|
||||
}
|
||||
|
||||
if !method_allowed {
|
||||
@@ -481,6 +494,10 @@ async fn http_plain_proxy(
|
||||
let method = req.method();
|
||||
info!("request allowed (client={client}, host={host}, method={method})");
|
||||
|
||||
if let Some(target) = outcome.connector_target {
|
||||
req.extensions_mut().insert(ConnectorTarget(target));
|
||||
}
|
||||
|
||||
let allow_upstream_proxy = match app_state
|
||||
.allow_upstream_proxy()
|
||||
.await
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::reasons::REASON_POLICY_DENIED;
|
||||
use crate::runtime::HostBlockDecision;
|
||||
use crate::runtime::HostBlockReason;
|
||||
use crate::runtime::HostPolicyResult;
|
||||
use crate::state::NetworkProxyState;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use rama_net::address::HostWithPort;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -83,6 +85,12 @@ impl<D: NetworkPolicyDecider + ?Sized> NetworkPolicyDecider for Arc<D> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NetworkPolicyOutcome {
|
||||
pub decision: NetworkDecision,
|
||||
pub connector_target: Option<HostWithPort>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<F, Fut> NetworkPolicyDecider for F
|
||||
where
|
||||
@@ -98,18 +106,30 @@ pub(crate) async fn evaluate_host_policy(
|
||||
state: &NetworkProxyState,
|
||||
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
|
||||
request: &NetworkPolicyRequest,
|
||||
) -> Result<NetworkDecision> {
|
||||
match state.host_blocked(&request.host, request.port).await? {
|
||||
HostBlockDecision::Allowed => Ok(NetworkDecision::Allow),
|
||||
) -> Result<NetworkPolicyOutcome> {
|
||||
let HostPolicyResult {
|
||||
decision: host_decision,
|
||||
connector_target,
|
||||
} = state.host_blocked(&request.host, request.port).await?;
|
||||
let decision = match host_decision {
|
||||
HostBlockDecision::Allowed => NetworkDecision::Allow,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => {
|
||||
if let Some(decider) = decider {
|
||||
Ok(decider.decide(request.clone()).await)
|
||||
decider.decide(request.clone()).await
|
||||
} else {
|
||||
Ok(NetworkDecision::deny(HostBlockReason::NotAllowed.as_str()))
|
||||
NetworkDecision::deny(HostBlockReason::NotAllowed.as_str())
|
||||
}
|
||||
}
|
||||
HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny(reason.as_str())),
|
||||
}
|
||||
HostBlockDecision::Blocked(reason) => NetworkDecision::deny(reason.as_str()),
|
||||
};
|
||||
let connector_target = match decision {
|
||||
NetworkDecision::Allow => connector_target,
|
||||
NetworkDecision::Deny { .. } => None,
|
||||
};
|
||||
Ok(NetworkPolicyOutcome {
|
||||
decision,
|
||||
connector_target,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -141,7 +161,7 @@ mod tests {
|
||||
|
||||
let request = NetworkPolicyRequest::new(
|
||||
NetworkProtocol::Http,
|
||||
"example.com".to_string(),
|
||||
"8.8.8.8".to_string(),
|
||||
80,
|
||||
None,
|
||||
Some("GET".to_string()),
|
||||
@@ -149,10 +169,10 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
let outcome = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decision, NetworkDecision::Allow);
|
||||
assert_eq!(outcome.decision, NetworkDecision::Allow);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
@@ -182,11 +202,11 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
let outcome = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decision,
|
||||
outcome.decision,
|
||||
NetworkDecision::Deny {
|
||||
reason: REASON_DENIED.to_string()
|
||||
}
|
||||
@@ -220,11 +240,11 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
let outcome = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
decision,
|
||||
outcome.decision,
|
||||
NetworkDecision::Deny {
|
||||
reason: REASON_NOT_ALLOWED_LOCAL.to_string()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use globset::GlobSet;
|
||||
use rama_net::address::Host as NetHost;
|
||||
use rama_net::address::HostWithPort;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
@@ -62,6 +64,21 @@ pub enum HostBlockDecision {
|
||||
Blocked(HostBlockReason),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HostPolicyResult {
|
||||
pub decision: HostBlockDecision,
|
||||
pub connector_target: Option<HostWithPort>,
|
||||
}
|
||||
|
||||
impl HostPolicyResult {
|
||||
const fn blocked(reason: HostBlockReason) -> Self {
|
||||
Self {
|
||||
decision: HostBlockDecision::Blocked(reason),
|
||||
connector_target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct BlockedRequest {
|
||||
pub host: String,
|
||||
@@ -188,11 +205,11 @@ impl NetworkProxyState {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn host_blocked(&self, host: &str, port: u16) -> Result<HostBlockDecision> {
|
||||
pub async fn host_blocked(&self, host: &str, port: u16) -> Result<HostPolicyResult> {
|
||||
self.reload_if_needed().await?;
|
||||
let host = match Host::parse(host) {
|
||||
Ok(host) => host,
|
||||
Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)),
|
||||
Err(_) => return Ok(HostPolicyResult::blocked(HostBlockReason::NotAllowed)),
|
||||
};
|
||||
let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = {
|
||||
let guard = self.state.read().await;
|
||||
@@ -212,19 +229,20 @@ impl NetworkProxyState {
|
||||
// 2) local/private networking is opt-in (defense-in-depth)
|
||||
// 3) allowlist is enforced when configured
|
||||
if deny_set.is_match(host_str) {
|
||||
return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
|
||||
return Ok(HostPolicyResult::blocked(HostBlockReason::Denied));
|
||||
}
|
||||
|
||||
let is_allowlisted = allow_set.is_match(host_str);
|
||||
let mut connector_target = None;
|
||||
if !allow_local_binding {
|
||||
// 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. Explicit local/loopback literals are allowed only when explicitly
|
||||
// allowlisted; hostnames that resolve to local/private IPs are blocked even if
|
||||
// allowlisted.
|
||||
// We therefore do a DNS + IP classification check before allowing the request and
|
||||
// reuse the resolved IPs for the eventual connect to avoid DNS rebinding gaps.
|
||||
// Explicit local/loopback literals are allowed only when explicitly allowlisted;
|
||||
// hostnames that resolve to local/private IPs are blocked even if allowlisted.
|
||||
let local_literal = {
|
||||
let host_no_scope = host_str
|
||||
.split_once('%')
|
||||
@@ -241,18 +259,34 @@ impl NetworkProxyState {
|
||||
|
||||
if local_literal {
|
||||
if !is_explicit_local_allowlisted(&allowed_domains, &host) {
|
||||
return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
|
||||
return Ok(HostPolicyResult::blocked(HostBlockReason::NotAllowedLocal));
|
||||
}
|
||||
} else if host_resolves_to_non_public_ip(host_str, port).await {
|
||||
return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
|
||||
} else {
|
||||
let resolved_ips = match resolve_host_ips(host_str, port).await {
|
||||
Some(resolved_ips) => resolved_ips,
|
||||
None => {
|
||||
return Ok(HostPolicyResult::blocked(HostBlockReason::NotAllowedLocal));
|
||||
}
|
||||
};
|
||||
if resolved_ips.iter().copied().any(is_non_public_ip) {
|
||||
return Ok(HostPolicyResult::blocked(HostBlockReason::NotAllowedLocal));
|
||||
}
|
||||
connector_target = resolved_ips
|
||||
.first()
|
||||
.copied()
|
||||
.map(|ip| HostWithPort::new(NetHost::Address(ip), port));
|
||||
}
|
||||
}
|
||||
|
||||
if allowed_domains_empty || !is_allowlisted {
|
||||
Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed))
|
||||
let decision = if allowed_domains_empty || !is_allowlisted {
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
|
||||
} else {
|
||||
Ok(HostBlockDecision::Allowed)
|
||||
}
|
||||
HostBlockDecision::Allowed
|
||||
};
|
||||
Ok(HostPolicyResult {
|
||||
decision,
|
||||
connector_target,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> {
|
||||
@@ -382,26 +416,20 @@ pub(crate) fn unix_socket_permissions_supported() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
}
|
||||
|
||||
async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool {
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return is_non_public_ip(ip);
|
||||
async fn resolve_host_ips(host: &str, port: u16) -> Option<Vec<IpAddr>> {
|
||||
let host_no_scope = host.split_once('%').map(|(ip, _)| ip).unwrap_or(host);
|
||||
if let Ok(ip) = host_no_scope.parse::<IpAddr>() {
|
||||
return Some(vec![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.
|
||||
// If DNS lookup fails, return `None` so callers can decide whether to block. We treat this as
|
||||
// a hard failure when local binding is disabled to avoid DNS rebinding gaps.
|
||||
let addrs = match timeout(DNS_LOOKUP_TIMEOUT, lookup_host((host, port))).await {
|
||||
Ok(Ok(addrs)) => addrs,
|
||||
Ok(Err(_)) | Err(_) => return false,
|
||||
Ok(Err(_)) | Err(_) => return None,
|
||||
};
|
||||
|
||||
for addr in addrs {
|
||||
if is_non_public_ip(addr.ip()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
let ips = addrs.map(|addr| addr.ip()).collect::<Vec<_>>();
|
||||
if ips.is_empty() { None } else { Some(ips) }
|
||||
}
|
||||
|
||||
fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) {
|
||||
@@ -525,7 +553,11 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("example.com", 80).await.unwrap(),
|
||||
state
|
||||
.host_blocked("example.com", 80)
|
||||
.await
|
||||
.unwrap()
|
||||
.decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::Denied)
|
||||
);
|
||||
}
|
||||
@@ -533,18 +565,18 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn host_blocked_requires_allowlist_match() {
|
||||
let state = network_proxy_state_for_policy(NetworkPolicy {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allowed_domains: vec!["1.1.1.1".to_string()],
|
||||
..NetworkPolicy::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("example.com", 80).await.unwrap(),
|
||||
state.host_blocked("1.1.1.1", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
// Use a public IP literal to avoid relying on ambient DNS behavior (some networks
|
||||
// resolve unknown hostnames to private IPs, which would trigger `not_allowed_local`).
|
||||
state.host_blocked("8.8.8.8", 80).await.unwrap(),
|
||||
state.host_blocked("8.8.8.8", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
|
||||
);
|
||||
}
|
||||
@@ -553,15 +585,20 @@ mod tests {
|
||||
async fn host_blocked_subdomain_wildcards_exclude_apex() {
|
||||
let state = network_proxy_state_for_policy(NetworkPolicy {
|
||||
allowed_domains: vec!["*.openai.com".to_string()],
|
||||
allow_local_binding: true,
|
||||
..NetworkPolicy::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("api.openai.com", 80).await.unwrap(),
|
||||
state
|
||||
.host_blocked("api.openai.com", 80)
|
||||
.await
|
||||
.unwrap()
|
||||
.decision,
|
||||
HostBlockDecision::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
state.host_blocked("openai.com", 80).await.unwrap(),
|
||||
state.host_blocked("openai.com", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
|
||||
);
|
||||
}
|
||||
@@ -575,11 +612,11 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("127.0.0.1", 80).await.unwrap(),
|
||||
state.host_blocked("127.0.0.1", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
||||
);
|
||||
assert_eq!(
|
||||
state.host_blocked("localhost", 80).await.unwrap(),
|
||||
state.host_blocked("localhost", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
||||
);
|
||||
}
|
||||
@@ -593,7 +630,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("127.0.0.1", 80).await.unwrap(),
|
||||
state.host_blocked("127.0.0.1", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
||||
);
|
||||
}
|
||||
@@ -607,7 +644,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("10.0.0.1", 80).await.unwrap(),
|
||||
state.host_blocked("10.0.0.1", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
||||
);
|
||||
}
|
||||
@@ -621,7 +658,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("localhost", 80).await.unwrap(),
|
||||
state.host_blocked("localhost", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Allowed
|
||||
);
|
||||
}
|
||||
@@ -635,7 +672,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("10.0.0.1", 80).await.unwrap(),
|
||||
state.host_blocked("10.0.0.1", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Allowed
|
||||
);
|
||||
}
|
||||
@@ -649,7 +686,11 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("fe80::1%lo0", 80).await.unwrap(),
|
||||
state
|
||||
.host_blocked("fe80::1%lo0", 80)
|
||||
.await
|
||||
.unwrap()
|
||||
.decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
||||
);
|
||||
}
|
||||
@@ -663,7 +704,11 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("fe80::1%lo0", 80).await.unwrap(),
|
||||
state
|
||||
.host_blocked("fe80::1%lo0", 80)
|
||||
.await
|
||||
.unwrap()
|
||||
.decision,
|
||||
HostBlockDecision::Allowed
|
||||
);
|
||||
}
|
||||
@@ -677,7 +722,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("10.0.0.1", 80).await.unwrap(),
|
||||
state.host_blocked("10.0.0.1", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
||||
);
|
||||
}
|
||||
@@ -691,7 +736,7 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.host_blocked("127.0.0.1", 80).await.unwrap(),
|
||||
state.host_blocked("127.0.0.1", 80).await.unwrap().decision,
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user