mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
## Why Managed-network commands within one Codex conversation share the same HTTP and SOCKS proxy ingress. When several exec calls run concurrently, the proxy sees the requested destination but cannot tell which exec opened the connection. For example: ```text exec A: curl https://example.com/a ─┐ ├─> conversation proxy ─> Guardian exec B: curl https://example.com/b ─┘ host: example.com trigger: unknown ```. Three parallel network execs reached Guardian without their triggering call IDs or commands. Guardian denied the requests, but Codex could not safely associate those outcomes with the individual tool calls. ## What changes Keep the shared proxy ingress and tag each connection at the existing trusted Linux bridge: ```text exec A ─> existing Linux bridge ─> [token A][proxy bytes] ─┐ ├─> shared HTTP/SOCKS ingress exec B ─> existing Linux bridge ─> [token B][proxy bytes] ─┘ │ token A ─> exec A ─────┤ token B ─> exec B ─────┘ ``` The complete path is: ```text active exec registration │ ├─ registers its UUID as a short-lived attribution token ├─ passes the token to the Linux sandbox helper ├─ helper removes the token before launching the user command ├─ existing host bridge prepends the token to each proxy connection ├─ shared proxy consumes the bounded attribution frame └─ proxy attaches the matching execution-scoped state ├─ Guardian receives the exact call ID and command └─ a denial finishes/cancels the matching tool call ``` Dropping the active or deferred exec registration removes the token. Connections that were already accepted retain their resolved attribution; new connections using an expired token fail closed. ## Before and after Before, Guardian could receive only the network destination: ```json { "tool": "network_access", "host": "www.17track.net", "port": 443, "protocol": "https" } ``` After, the same request includes the action that caused it: ```json { "tool": "network_access", "host": "www.17track.net", "port": 443, "protocol": "https", "trigger": { "callId": "exec-network-first", "command": ["/bin/sh", "-c", "curl https://www.17track.net"] } } ``` ## Listener accounting This PR does **not** create proxy listeners per exec. ```text Existing topology: one conversation -> one HTTP listener + optional one SOCKS listener Discarded per-exec approach: one conversation -> existing listener pair + up to one additional listener pair per active exec This PR: one conversation -> existing listener pair only + one small token-map entry per active exec ``` The Linux sandbox already creates a trusted routing bridge for each sandboxed command. This PR adds a short frame write to that bridge rather than introducing another listener, task, or proxy process. The existing conversation-scoped listener pair remains. Making a single proxy service shared across multiple conversations would be a separate multi-tenant architecture change involving per-conversation policy, configuration, audit, and Guardian routing. ## Keeping the implementation small The attribution is bound once, when the TCP connection enters the proxy. The ingress installs an execution-scoped clone of the existing `NetworkProxyState`, so the established HTTP, SOCKS, MITM, policy, audit, and blocked-request paths continue using their existing state lookup. This avoids plumbing a new request-context type through every protocol handler. Outside the two ingress wrappers, protocol-specific request handling is unchanged. ## Security behavior - Tokens are generated from the existing random execution registration IDs. - The trusted Linux helper consumes and removes the token before executing user code. - Attribution frames have a fixed magic prefix, bounded token length, and bounded read timeout. - Unknown or expired tokens close the connection. - A token presented to a proxy for another environment closes the connection. - Existing unframed callers preserve the current conservative attribution behavior. ## Platform scope Exact bridge attribution is enabled on Linux. macOS and Windows retain their current shared-proxy behavior. ## Test coverage The concurrent end-to-end test starts two managed-network execs together and synchronizes them so both are active before either connects. It then inspects the two Guardian requests and compares the complete attribution pairs: ```text (exec-network-first, exact first command) (exec-network-second, exact second command) ``` Focused proxy coverage verifies the bounded frame and that a registered framed connection receives the matching execution and environment state. ## Scope This fixes the Linux network-to-exec attribution path and records a denial against the exact matching tool call. It intentionally does not change: - delivery of an entirely unattributed denial to the parent turn; - how parallel denials count toward the Guardian circuit breaker; - how the UI displays the rejection reason or completed-turn state. Those remain separate concerns from attribution. ## Relationship to #29456 and #29668 #29456 made the proxy environment and sandbox policy come from the same prepared network context. This PR adds the execution token to that prepared launch and consumes it at the shared ingress. This follows #29668's shared-ingress framing direction, but completes the production registration, Linux bridge, core call mapping, denial mapping, and concurrent end-to-end path. It also keeps attribution in the existing per-connection proxy state instead of introducing request-context plumbing through every HTTP, SOCKS, and MITM handler. This PR is intended to supersede #29668 for the Linux attribution fix. --------- Co-authored-by: viyatb-oai <viyatb@openai.com> Co-authored-by: Codex <noreply@openai.com>
2136 lines
72 KiB
Rust
2136 lines
72 KiB
Rust
use crate::config::NetworkDomainPermission;
|
|
use crate::config::NetworkMode;
|
|
use crate::config::NetworkProxyConfig;
|
|
use crate::config::ValidatedUnixSocketPath;
|
|
use crate::credential_broker::CredentialBroker;
|
|
use crate::mitm::MitmState;
|
|
use crate::mitm_hook::HookEvaluation;
|
|
use crate::mitm_hook::MitmHooksByHost;
|
|
use crate::mitm_hook::evaluate_mitm_hooks;
|
|
use crate::policy::Host;
|
|
use crate::policy::is_loopback_host;
|
|
use crate::policy::is_non_public_ip;
|
|
use crate::policy::normalize_host;
|
|
use crate::policy::unscoped_ip_literal;
|
|
use crate::reasons::REASON_DENIED;
|
|
use crate::reasons::REASON_NOT_ALLOWED;
|
|
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
|
use crate::state::NetworkProxyConstraintError;
|
|
use crate::state::NetworkProxyConstraints;
|
|
use crate::state::build_config_state;
|
|
use crate::state::validate_policy_against_constraints;
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use globset::GlobSet;
|
|
use serde::Serialize;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::collections::VecDeque;
|
|
use std::future::Future;
|
|
use std::net::IpAddr;
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
use time::OffsetDateTime;
|
|
use tokio::net::lookup_host;
|
|
use tokio::sync::RwLock;
|
|
use tokio::time::timeout;
|
|
use tracing::debug;
|
|
use tracing::info;
|
|
use tracing::warn;
|
|
|
|
const MAX_BLOCKED_EVENTS: usize = 200;
|
|
const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
|
|
const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct NetworkProxyAuditMetadata {
|
|
pub conversation_id: Option<String>,
|
|
pub app_version: Option<String>,
|
|
pub user_account_id: Option<String>,
|
|
pub auth_mode: Option<String>,
|
|
pub originator: Option<String>,
|
|
pub user_email: Option<String>,
|
|
pub terminal_type: Option<String>,
|
|
pub model: Option<String>,
|
|
pub slug: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum HostBlockReason {
|
|
Denied,
|
|
NotAllowed,
|
|
NotAllowedLocal,
|
|
}
|
|
|
|
impl HostBlockReason {
|
|
pub const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Denied => REASON_DENIED,
|
|
Self::NotAllowed => REASON_NOT_ALLOWED,
|
|
Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for HostBlockReason {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum HostBlockDecision {
|
|
Allowed,
|
|
Blocked(HostBlockReason),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct BlockedRequest {
|
|
pub host: String,
|
|
pub reason: String,
|
|
pub client: Option<String>,
|
|
pub method: Option<String>,
|
|
pub mode: Option<NetworkMode>,
|
|
pub protocol: String,
|
|
#[serde(skip)]
|
|
pub execution_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub decision: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub port: Option<u16>,
|
|
pub timestamp: i64,
|
|
}
|
|
|
|
pub struct BlockedRequestArgs {
|
|
pub host: String,
|
|
pub reason: String,
|
|
pub client: Option<String>,
|
|
pub method: Option<String>,
|
|
pub mode: Option<NetworkMode>,
|
|
pub protocol: String,
|
|
pub decision: Option<String>,
|
|
pub source: Option<String>,
|
|
pub port: Option<u16>,
|
|
}
|
|
|
|
impl BlockedRequest {
|
|
pub fn new(args: BlockedRequestArgs) -> Self {
|
|
let BlockedRequestArgs {
|
|
host,
|
|
reason,
|
|
client,
|
|
method,
|
|
mode,
|
|
protocol,
|
|
decision,
|
|
source,
|
|
port,
|
|
} = args;
|
|
Self {
|
|
host,
|
|
reason,
|
|
client,
|
|
method,
|
|
mode,
|
|
protocol,
|
|
execution_id: None,
|
|
decision,
|
|
source,
|
|
port,
|
|
timestamp: unix_timestamp(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn blocked_request_violation_log_line(entry: &BlockedRequest) -> String {
|
|
match serde_json::to_string(entry) {
|
|
Ok(json) => format!("{NETWORK_POLICY_VIOLATION_PREFIX} {json}"),
|
|
Err(err) => {
|
|
debug!("failed to serialize blocked request for violation log: {err}");
|
|
format!(
|
|
"{NETWORK_POLICY_VIOLATION_PREFIX} host={} reason={}",
|
|
entry.host, entry.reason
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ConfigState {
|
|
pub config: NetworkProxyConfig,
|
|
pub allow_set: GlobSet,
|
|
pub deny_set: GlobSet,
|
|
pub mitm: Option<Arc<MitmState>>,
|
|
pub mitm_hooks: MitmHooksByHost,
|
|
pub constraints: NetworkProxyConstraints,
|
|
pub blocked: VecDeque<BlockedRequest>,
|
|
pub blocked_total: u64,
|
|
}
|
|
|
|
pub trait ConfigReloader: Send + Sync {
|
|
/// Human-readable description of where config is loaded from, for logs.
|
|
fn source_label(&self) -> String;
|
|
|
|
/// Return a freshly loaded state if a reload is needed; otherwise, return `None`.
|
|
fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>>;
|
|
|
|
/// Force a reload, regardless of whether a change was detected.
|
|
fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState>;
|
|
}
|
|
|
|
pub type ConfigReloaderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
|
|
|
|
pub trait BlockedRequestObserver: Send + Sync + 'static {
|
|
fn on_blocked_request(&self, request: BlockedRequest) -> BlockedRequestObserverFuture<'_>;
|
|
}
|
|
|
|
pub type BlockedRequestObserverFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
|
|
|
|
impl<O: BlockedRequestObserver + ?Sized> BlockedRequestObserver for Arc<O> {
|
|
fn on_blocked_request(&self, request: BlockedRequest) -> BlockedRequestObserverFuture<'_> {
|
|
Box::pin(async move { (**self).on_blocked_request(request).await })
|
|
}
|
|
}
|
|
|
|
impl<F, Fut> BlockedRequestObserver for F
|
|
where
|
|
F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static,
|
|
Fut: Future<Output = ()> + Send + 'static,
|
|
{
|
|
fn on_blocked_request(&self, request: BlockedRequest) -> BlockedRequestObserverFuture<'_> {
|
|
Box::pin((self)(request))
|
|
}
|
|
}
|
|
|
|
pub struct NetworkProxyState {
|
|
state: Arc<RwLock<ConfigState>>,
|
|
reloader: Arc<dyn ConfigReloader>,
|
|
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
|
|
credential_broker: CredentialBroker,
|
|
audit_metadata: NetworkProxyAuditMetadata,
|
|
execution_attributions: Arc<Mutex<HashMap<String, ExecutionAttribution>>>,
|
|
environment_id: Option<Arc<str>>,
|
|
execution_id: Option<Arc<str>>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum HostMitmRequirement {
|
|
None,
|
|
Tls,
|
|
Always,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ExecutionAttribution {
|
|
environment_id: String,
|
|
execution_id: String,
|
|
}
|
|
|
|
impl std::fmt::Debug for NetworkProxyState {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
// Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
|
|
// and may contain sensitive paths.
|
|
f.debug_struct("NetworkProxyState").finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl Clone for NetworkProxyState {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
state: self.state.clone(),
|
|
reloader: self.reloader.clone(),
|
|
blocked_request_observer: self.blocked_request_observer.clone(),
|
|
credential_broker: self.credential_broker.clone(),
|
|
audit_metadata: self.audit_metadata.clone(),
|
|
execution_attributions: self.execution_attributions.clone(),
|
|
environment_id: self.environment_id.clone(),
|
|
execution_id: self.execution_id.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl NetworkProxyState {
|
|
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
|
|
Self::with_reloader_and_audit_metadata(
|
|
state,
|
|
reloader,
|
|
NetworkProxyAuditMetadata::default(),
|
|
)
|
|
}
|
|
|
|
pub fn with_reloader_and_blocked_observer(
|
|
state: ConfigState,
|
|
reloader: Arc<dyn ConfigReloader>,
|
|
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
|
|
) -> Self {
|
|
Self::with_reloader_and_audit_metadata_and_blocked_observer(
|
|
state,
|
|
reloader,
|
|
NetworkProxyAuditMetadata::default(),
|
|
blocked_request_observer,
|
|
)
|
|
}
|
|
|
|
pub fn with_reloader_and_audit_metadata(
|
|
state: ConfigState,
|
|
reloader: Arc<dyn ConfigReloader>,
|
|
audit_metadata: NetworkProxyAuditMetadata,
|
|
) -> Self {
|
|
Self::with_reloader_and_audit_metadata_and_blocked_observer(
|
|
state,
|
|
reloader,
|
|
audit_metadata,
|
|
/*blocked_request_observer*/ None,
|
|
)
|
|
}
|
|
|
|
pub fn with_reloader_and_audit_metadata_and_blocked_observer(
|
|
state: ConfigState,
|
|
reloader: Arc<dyn ConfigReloader>,
|
|
audit_metadata: NetworkProxyAuditMetadata,
|
|
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
|
|
) -> Self {
|
|
Self {
|
|
credential_broker: CredentialBroker::new(state.config.network.credential_broker),
|
|
state: Arc::new(RwLock::new(state)),
|
|
reloader,
|
|
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
|
|
audit_metadata,
|
|
execution_attributions: Arc::new(Mutex::new(HashMap::new())),
|
|
environment_id: None,
|
|
execution_id: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn register_execution(
|
|
&self,
|
|
attribution_token: &str,
|
|
environment_id: &str,
|
|
execution_id: &str,
|
|
) {
|
|
self.execution_attributions
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.insert(
|
|
attribution_token.to_string(),
|
|
ExecutionAttribution {
|
|
environment_id: environment_id.to_string(),
|
|
execution_id: execution_id.to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
pub(crate) fn unregister_execution(&self, attribution_token: &str) {
|
|
self.execution_attributions
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.remove(attribution_token);
|
|
}
|
|
|
|
pub(crate) fn for_execution_token(&self, token: &str) -> Option<Self> {
|
|
let attribution = self
|
|
.execution_attributions
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.get(token)?
|
|
.clone();
|
|
Some(Self {
|
|
environment_id: Some(attribution.environment_id.into()),
|
|
execution_id: Some(attribution.execution_id.into()),
|
|
..self.clone()
|
|
})
|
|
}
|
|
|
|
pub(crate) fn environment_id(&self) -> Option<&str> {
|
|
self.environment_id.as_deref()
|
|
}
|
|
|
|
pub(crate) fn execution_id(&self) -> Option<String> {
|
|
self.execution_id.as_deref().map(str::to_string)
|
|
}
|
|
|
|
pub async fn set_blocked_request_observer(
|
|
&self,
|
|
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
|
|
) {
|
|
let mut observer = self.blocked_request_observer.write().await;
|
|
*observer = blocked_request_observer;
|
|
}
|
|
|
|
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
|
|
&self.audit_metadata
|
|
}
|
|
|
|
pub fn virtualize_child_credentials(&self, env: &mut HashMap<String, String>) {
|
|
self.credential_broker.virtualize_child_env(env);
|
|
}
|
|
|
|
pub fn inject_request_credentials(&self, host: &str, headers: &mut rama_http::HeaderMap) {
|
|
self.credential_broker.inject_request_headers(host, headers);
|
|
}
|
|
|
|
pub async fn plaintext_credential_injection_enabled(&self) -> Result<bool> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard
|
|
.config
|
|
.network
|
|
.dangerously_allow_plaintext_credential_injection)
|
|
}
|
|
|
|
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
|
|
// Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to
|
|
// `config.toml` (including Codex-managed writes) take effect without a restart.
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard.config.clone())
|
|
}
|
|
|
|
pub async fn current_patterns(&self) -> Result<(Vec<String>, Vec<String>)> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok((
|
|
guard.config.network.allowed_domains().unwrap_or_default(),
|
|
guard.config.network.denied_domains().unwrap_or_default(),
|
|
))
|
|
}
|
|
|
|
pub async fn enabled(&self) -> Result<bool> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard.config.network.enabled)
|
|
}
|
|
|
|
pub async fn force_reload(&self) -> Result<()> {
|
|
let previous_cfg = {
|
|
let guard = self.state.read().await;
|
|
guard.config.clone()
|
|
};
|
|
|
|
match self.reloader.reload_now().await {
|
|
Ok(mut new_state) => {
|
|
self.ensure_credential_broker_enablement_unchanged(&new_state)?;
|
|
// Policy changes are operationally sensitive; logging diffs makes changes traceable
|
|
// without needing to dump full config blobs (which can include unrelated settings).
|
|
log_policy_changes(&previous_cfg, &new_state.config);
|
|
{
|
|
let mut guard = self.state.write().await;
|
|
new_state.blocked = guard.blocked.clone();
|
|
*guard = new_state;
|
|
}
|
|
let source = self.reloader.source_label();
|
|
info!("reloaded config from {source}");
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
let source = self.reloader.source_label();
|
|
warn!("failed to reload config from {source}: {err}; keeping previous config");
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn replace_config_state(&self, mut new_state: ConfigState) -> Result<()> {
|
|
self.reload_if_needed().await?;
|
|
self.ensure_credential_broker_enablement_unchanged(&new_state)?;
|
|
let mut guard = self.state.write().await;
|
|
log_policy_changes(&guard.config, &new_state.config);
|
|
new_state.blocked = guard.blocked.clone();
|
|
new_state.blocked_total = guard.blocked_total;
|
|
*guard = new_state;
|
|
info!("updated network proxy config state");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn host_blocked(&self, host: &str, port: u16) -> Result<HostBlockDecision> {
|
|
self.reload_if_needed().await?;
|
|
let host = match Host::parse(host) {
|
|
Ok(host) => host,
|
|
Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)),
|
|
};
|
|
let (deny_set, allow_set, allow_local_binding, allowed_domains) = {
|
|
let guard = self.state.read().await;
|
|
let allowed_domains = guard.config.network.allowed_domains();
|
|
(
|
|
guard.deny_set.clone(),
|
|
guard.allow_set.clone(),
|
|
guard.config.network.allow_local_binding,
|
|
allowed_domains,
|
|
)
|
|
};
|
|
let allowed_domains_empty = allowed_domains.is_none();
|
|
let allowed_domains = allowed_domains.unwrap_or_default();
|
|
|
|
let host_str = host.as_str();
|
|
|
|
// Decision order matters:
|
|
// 1) explicit deny always wins
|
|
// 2) local/private networking is opt-in (defense-in-depth)
|
|
// 3) allowlist is enforced when configured
|
|
if globset_matches_host_or_unscoped(&deny_set, host_str) {
|
|
return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
|
|
}
|
|
|
|
let is_allowlisted = globset_matches_host_or_unscoped(&allow_set, host_str);
|
|
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.
|
|
let local_literal = {
|
|
let host_no_scope = unscoped_ip_literal(host_str).unwrap_or(host_str);
|
|
if is_loopback_host(&host) {
|
|
true
|
|
} else if let Ok(ip) = host_no_scope.parse::<IpAddr>() {
|
|
is_non_public_ip(ip)
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
|
|
if local_literal {
|
|
if !is_explicit_local_allowlisted(&allowed_domains, &host) {
|
|
return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
|
|
}
|
|
} else if host_resolves_to_non_public_ip(
|
|
host_str,
|
|
port,
|
|
DNS_LOOKUP_TIMEOUT,
|
|
|host, port| async move {
|
|
lookup_host((host.as_str(), port))
|
|
.await
|
|
.map(Iterator::collect)
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
|
|
}
|
|
}
|
|
|
|
if allowed_domains_empty || !is_allowlisted {
|
|
Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed))
|
|
} else {
|
|
Ok(HostBlockDecision::Allowed)
|
|
}
|
|
}
|
|
|
|
pub async fn record_blocked(&self, mut entry: BlockedRequest) -> Result<()> {
|
|
self.reload_if_needed().await?;
|
|
entry.execution_id = self.execution_id();
|
|
let blocked_for_observer = entry.clone();
|
|
let blocked_request_observer = self.blocked_request_observer.read().await.clone();
|
|
let violation_line = blocked_request_violation_log_line(&entry);
|
|
let host = entry.host.clone();
|
|
let reason = entry.reason.clone();
|
|
let decision = entry.decision.clone();
|
|
let source = entry.source.clone();
|
|
let protocol = entry.protocol.clone();
|
|
let port = entry.port;
|
|
let (total, buffered) = {
|
|
let mut guard = self.state.write().await;
|
|
guard.blocked.push_back(entry);
|
|
guard.blocked_total = guard.blocked_total.saturating_add(1);
|
|
let total = guard.blocked_total;
|
|
while guard.blocked.len() > MAX_BLOCKED_EVENTS {
|
|
guard.blocked.pop_front();
|
|
}
|
|
(total, guard.blocked.len())
|
|
};
|
|
debug!(
|
|
"recorded blocked request telemetry (\
|
|
total={total}, host={host}, reason={reason}, \
|
|
decision={decision:?}, source={source:?}, \
|
|
protocol={protocol}, port={port:?}, buffered={buffered})"
|
|
);
|
|
debug!("{violation_line}");
|
|
|
|
if let Some(observer) = blocked_request_observer {
|
|
observer.on_blocked_request(blocked_for_observer).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns a snapshot of buffered blocked-request entries without consuming
|
|
/// them.
|
|
pub async fn blocked_snapshot(&self) -> Result<Vec<BlockedRequest>> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard.blocked.iter().cloned().collect())
|
|
}
|
|
|
|
/// Drain and return the buffered blocked-request entries in FIFO order.
|
|
pub async fn drain_blocked(&self) -> Result<Vec<BlockedRequest>> {
|
|
self.reload_if_needed().await?;
|
|
let blocked = {
|
|
let mut guard = self.state.write().await;
|
|
std::mem::take(&mut guard.blocked)
|
|
};
|
|
Ok(blocked.into_iter().collect())
|
|
}
|
|
|
|
pub async fn is_unix_socket_allowed(&self, path: &str) -> Result<bool> {
|
|
self.reload_if_needed().await?;
|
|
if !unix_socket_permissions_supported() {
|
|
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).
|
|
let requested_path = Path::new(path);
|
|
if !requested_path.is_absolute() {
|
|
return Ok(false);
|
|
}
|
|
|
|
let guard = self.state.read().await;
|
|
if guard.config.network.dangerously_allow_all_unix_sockets {
|
|
return Ok(true);
|
|
}
|
|
|
|
// Normalize the path while keeping the absolute-path requirement explicit.
|
|
let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) {
|
|
Ok(path) => path,
|
|
Err(_) => return Ok(false),
|
|
};
|
|
let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok();
|
|
for allowed in &guard.config.network.allow_unix_sockets() {
|
|
let allowed_path = match ValidatedUnixSocketPath::parse(allowed) {
|
|
Ok(ValidatedUnixSocketPath::Native(path)) => path,
|
|
Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue,
|
|
Err(err) => {
|
|
warn!("ignoring invalid network.allow_unix_sockets entry at runtime: {err:#}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if allowed_path.as_path() == requested_abs.as_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_path.as_path())
|
|
&& &allowed_canonical == requested_canonical
|
|
{
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
pub async fn method_allowed(&self, method: &str) -> Result<bool> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard.config.network.mode.allows_method(method))
|
|
}
|
|
|
|
pub async fn allow_upstream_proxy(&self) -> Result<bool> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard.config.network.allow_upstream_proxy)
|
|
}
|
|
|
|
pub async fn allow_local_binding(&self) -> Result<bool> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard.config.network.allow_local_binding)
|
|
}
|
|
|
|
pub async fn network_mode(&self) -> Result<NetworkMode> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard.config.network.mode)
|
|
}
|
|
|
|
pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> {
|
|
loop {
|
|
self.reload_if_needed().await?;
|
|
let (candidate, constraints) = {
|
|
let guard = self.state.read().await;
|
|
let mut candidate = guard.config.clone();
|
|
candidate.network.mode = mode;
|
|
(candidate, guard.constraints.clone())
|
|
};
|
|
|
|
validate_policy_against_constraints(&candidate, &constraints)
|
|
.map_err(NetworkProxyConstraintError::into_anyhow)
|
|
.context("network.mode constrained by managed config")?;
|
|
|
|
let mut guard = self.state.write().await;
|
|
if guard.constraints != constraints {
|
|
drop(guard);
|
|
continue;
|
|
}
|
|
guard.config.network.mode = mode;
|
|
info!("updated network mode to {mode:?}");
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
pub async fn mitm_state(&self) -> Result<Option<Arc<MitmState>>> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(guard.mitm.clone())
|
|
}
|
|
|
|
pub(crate) async fn evaluate_mitm_hook_request(
|
|
&self,
|
|
host: &str,
|
|
req: &rama_http::Request,
|
|
) -> Result<HookEvaluation> {
|
|
self.reload_if_needed().await?;
|
|
let guard = self.state.read().await;
|
|
Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req))
|
|
}
|
|
|
|
pub(crate) async fn host_mitm_requirement(&self, host: &str) -> Result<HostMitmRequirement> {
|
|
self.reload_if_needed().await?;
|
|
let normalized_host = normalize_host(host);
|
|
let host_has_mitm_hooks = {
|
|
let guard = self.state.read().await;
|
|
guard.mitm_hooks.contains_key(&normalized_host)
|
|
};
|
|
Ok(if host_has_mitm_hooks {
|
|
HostMitmRequirement::Always
|
|
} else if self.credential_broker.host_requires_mitm(&normalized_host) {
|
|
HostMitmRequirement::Tls
|
|
} else {
|
|
HostMitmRequirement::None
|
|
})
|
|
}
|
|
|
|
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
|
|
self.update_domain_list(host, DomainListKind::Allow).await
|
|
}
|
|
|
|
pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
|
|
self.update_domain_list(host, DomainListKind::Deny).await
|
|
}
|
|
|
|
async fn update_domain_list(&self, host: &str, target: DomainListKind) -> Result<()> {
|
|
let host = Host::parse(host).context("invalid network host")?;
|
|
let normalized_host = host.as_str().to_string();
|
|
let list_name = target.list_name();
|
|
let constraint_field = target.constraint_field();
|
|
|
|
loop {
|
|
self.reload_if_needed().await?;
|
|
let (previous_cfg, constraints, blocked, blocked_total) = {
|
|
let guard = self.state.read().await;
|
|
(
|
|
guard.config.clone(),
|
|
guard.constraints.clone(),
|
|
guard.blocked.clone(),
|
|
guard.blocked_total,
|
|
)
|
|
};
|
|
|
|
let mut candidate = previous_cfg.clone();
|
|
let target_entries = target.entries(&candidate.network);
|
|
let opposite_entries = target.opposite_entries(&candidate.network);
|
|
let target_contains = target_entries
|
|
.iter()
|
|
.any(|entry| normalize_host(entry) == normalized_host);
|
|
let opposite_contains = opposite_entries
|
|
.iter()
|
|
.any(|entry| normalize_host(entry) == normalized_host);
|
|
if target_contains && !opposite_contains {
|
|
return Ok(());
|
|
}
|
|
|
|
candidate.network.upsert_domain_permission(
|
|
normalized_host.clone(),
|
|
target.permission(),
|
|
normalize_host,
|
|
);
|
|
|
|
validate_policy_against_constraints(&candidate, &constraints)
|
|
.map_err(NetworkProxyConstraintError::into_anyhow)
|
|
.with_context(|| format!("{constraint_field} constrained by managed config"))?;
|
|
|
|
let mut new_state = build_config_state(candidate.clone(), constraints.clone())
|
|
.with_context(|| format!("failed to compile updated network {list_name}"))?;
|
|
new_state.blocked = blocked;
|
|
new_state.blocked_total = blocked_total;
|
|
|
|
let mut guard = self.state.write().await;
|
|
if guard.constraints != constraints || guard.config != previous_cfg {
|
|
drop(guard);
|
|
continue;
|
|
}
|
|
|
|
log_policy_changes(&guard.config, &candidate);
|
|
*guard = new_state;
|
|
info!("updated network {list_name} with {normalized_host}");
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
async fn reload_if_needed(&self) -> Result<()> {
|
|
match self.reloader.maybe_reload().await? {
|
|
None => Ok(()),
|
|
Some(mut new_state) => {
|
|
self.ensure_credential_broker_enablement_unchanged(&new_state)?;
|
|
let (previous_cfg, blocked, blocked_total) = {
|
|
let guard = self.state.read().await;
|
|
(
|
|
guard.config.clone(),
|
|
guard.blocked.clone(),
|
|
guard.blocked_total,
|
|
)
|
|
};
|
|
log_policy_changes(&previous_cfg, &new_state.config);
|
|
new_state.blocked = blocked;
|
|
new_state.blocked_total = blocked_total;
|
|
{
|
|
let mut guard = self.state.write().await;
|
|
*guard = new_state;
|
|
}
|
|
let source = self.reloader.source_label();
|
|
info!("reloaded config from {source}");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ensure_credential_broker_enablement_unchanged(&self, new_state: &ConfigState) -> Result<()> {
|
|
anyhow::ensure!(
|
|
self.credential_broker.enabled() == new_state.config.network.credential_broker,
|
|
"network.credential_broker cannot change while the proxy is running"
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum DomainListKind {
|
|
Allow,
|
|
Deny,
|
|
}
|
|
|
|
impl DomainListKind {
|
|
fn list_name(self) -> &'static str {
|
|
match self {
|
|
Self::Allow => "allowlist",
|
|
Self::Deny => "denylist",
|
|
}
|
|
}
|
|
|
|
fn constraint_field(self) -> &'static str {
|
|
match self {
|
|
Self::Allow => "network.allowed_domains",
|
|
Self::Deny => "network.denied_domains",
|
|
}
|
|
}
|
|
|
|
fn permission(self) -> NetworkDomainPermission {
|
|
match self {
|
|
Self::Allow => NetworkDomainPermission::Allow,
|
|
Self::Deny => NetworkDomainPermission::Deny,
|
|
}
|
|
}
|
|
|
|
fn entries(self, network: &crate::config::NetworkProxySettings) -> Vec<String> {
|
|
match self {
|
|
Self::Allow => network.allowed_domains().unwrap_or_default(),
|
|
Self::Deny => network.denied_domains().unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
fn opposite_entries(self, network: &crate::config::NetworkProxySettings) -> Vec<String> {
|
|
match self {
|
|
Self::Allow => network.denied_domains().unwrap_or_default(),
|
|
Self::Deny => network.allowed_domains().unwrap_or_default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn unix_socket_permissions_supported() -> bool {
|
|
cfg!(target_os = "macos")
|
|
}
|
|
|
|
async fn host_resolves_to_non_public_ip<F, Fut>(
|
|
host: &str,
|
|
port: u16,
|
|
lookup_timeout: Duration,
|
|
lookup: F,
|
|
) -> bool
|
|
where
|
|
F: FnOnce(String, u16) -> Fut,
|
|
Fut: Future<Output = std::io::Result<Vec<SocketAddr>>>,
|
|
{
|
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
|
return is_non_public_ip(ip);
|
|
}
|
|
|
|
// Block the request if this DNS lookup fails. We resolve the hostname again when we connect,
|
|
// so a failed check here does not prove the destination is public.
|
|
let addrs = match timeout(lookup_timeout, lookup(host.to_string(), port)).await {
|
|
Ok(Ok(addrs)) => addrs,
|
|
Ok(Err(err)) => {
|
|
debug!(
|
|
"blocking host because DNS lookup failed during local/private IP check (host={host}, port={port}): {err}"
|
|
);
|
|
return true;
|
|
}
|
|
Err(_) => {
|
|
debug!(
|
|
"blocking host because DNS lookup timed out during local/private IP check (host={host}, port={port})"
|
|
);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
for addr in addrs {
|
|
if is_non_public_ip(addr.ip()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) {
|
|
let previous_allowed_domains = previous.network.allowed_domains().unwrap_or_default();
|
|
let next_allowed_domains = next.network.allowed_domains().unwrap_or_default();
|
|
log_domain_list_changes(
|
|
"allowlist",
|
|
&previous_allowed_domains,
|
|
&next_allowed_domains,
|
|
);
|
|
let previous_denied_domains = previous.network.denied_domains().unwrap_or_default();
|
|
let next_denied_domains = next.network.denied_domains().unwrap_or_default();
|
|
log_domain_list_changes("denylist", &previous_denied_domains, &next_denied_domains);
|
|
}
|
|
|
|
fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) {
|
|
let previous_set: HashSet<String> = previous
|
|
.iter()
|
|
.map(|entry| entry.to_ascii_lowercase())
|
|
.collect();
|
|
let next_set: HashSet<String> = next
|
|
.iter()
|
|
.map(|entry| entry.to_ascii_lowercase())
|
|
.collect();
|
|
|
|
let added = next_set
|
|
.difference(&previous_set)
|
|
.cloned()
|
|
.collect::<HashSet<_>>();
|
|
let removed = previous_set
|
|
.difference(&next_set)
|
|
.cloned()
|
|
.collect::<HashSet<_>>();
|
|
|
|
let mut seen_next = HashSet::new();
|
|
for entry in next {
|
|
let key = entry.to_ascii_lowercase();
|
|
if seen_next.insert(key.clone()) && added.contains(&key) {
|
|
info!("config entry added to {list_name}: {entry}");
|
|
}
|
|
}
|
|
|
|
let mut seen_previous = HashSet::new();
|
|
for entry in previous {
|
|
let key = entry.to_ascii_lowercase();
|
|
if seen_previous.insert(key.clone()) && removed.contains(&key) {
|
|
info!("config entry removed from {list_name}: {entry}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn globset_matches_host_or_unscoped(set: &GlobSet, host: &str) -> bool {
|
|
set.is_match(host) || unscoped_ip_literal(host).is_some_and(|ip| set.is_match(ip))
|
|
}
|
|
|
|
fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool {
|
|
let normalized_host = host.as_str();
|
|
let unscoped_host = unscoped_ip_literal(normalized_host);
|
|
allowed_domains.iter().any(|pattern| {
|
|
let pattern = pattern.trim();
|
|
if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") {
|
|
return false;
|
|
}
|
|
if pattern.contains('*') || pattern.contains('?') {
|
|
return false;
|
|
}
|
|
let normalized_pattern = normalize_host(pattern);
|
|
normalized_pattern == normalized_host
|
|
|| unscoped_host.is_some_and(|ip| normalized_pattern == ip)
|
|
})
|
|
}
|
|
|
|
fn unix_timestamp() -> i64 {
|
|
OffsetDateTime::now_utc().unix_timestamp()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn network_proxy_state_for_policy(
|
|
mut network: crate::config::NetworkProxySettings,
|
|
) -> NetworkProxyState {
|
|
network.enabled = true;
|
|
let config = NetworkProxyConfig { network };
|
|
let state = ConfigState {
|
|
allow_set: crate::policy::compile_allowlist_globset(
|
|
&config.network.allowed_domains().unwrap_or_default(),
|
|
)
|
|
.unwrap(),
|
|
blocked: VecDeque::new(),
|
|
blocked_total: 0,
|
|
config: config.clone(),
|
|
constraints: NetworkProxyConstraints::default(),
|
|
deny_set: crate::policy::compile_denylist_globset(
|
|
&config.network.denied_domains().unwrap_or_default(),
|
|
)
|
|
.unwrap(),
|
|
mitm: None,
|
|
mitm_hooks: crate::mitm_hook::compile_mitm_hooks(&config).unwrap(),
|
|
};
|
|
|
|
NetworkProxyState::with_reloader(state, Arc::new(NoopReloader))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
struct NoopReloader;
|
|
|
|
#[cfg(test)]
|
|
impl ConfigReloader for NoopReloader {
|
|
fn source_label(&self) -> String {
|
|
"test config state".to_string()
|
|
}
|
|
|
|
fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
|
|
Box::pin(async { Ok(None) })
|
|
}
|
|
|
|
fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
|
|
Box::pin(async { Err(anyhow::anyhow!("force reload is not supported in tests")) })
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use crate::config::NetworkProxyConfig;
|
|
use crate::config::NetworkProxySettings;
|
|
use crate::policy::compile_allowlist_globset;
|
|
use crate::policy::compile_denylist_globset;
|
|
use crate::state::NetworkProxyConstraints;
|
|
use crate::state::build_config_state;
|
|
use crate::state::validate_policy_against_constraints;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[derive(Clone)]
|
|
struct StaticReloader {
|
|
state: ConfigState,
|
|
}
|
|
|
|
impl ConfigReloader for StaticReloader {
|
|
fn source_label(&self) -> String {
|
|
"static test reloader".to_string()
|
|
}
|
|
|
|
fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
|
|
let state = self.state.clone();
|
|
Box::pin(async move { Ok(Some(state)) })
|
|
}
|
|
|
|
fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
|
|
let state = self.state.clone();
|
|
Box::pin(async move { Ok(state) })
|
|
}
|
|
}
|
|
|
|
fn strings(entries: &[&str]) -> Vec<String> {
|
|
entries.iter().map(|entry| (*entry).to_string()).collect()
|
|
}
|
|
|
|
fn network_settings(allowed_domains: &[&str], denied_domains: &[&str]) -> NetworkProxySettings {
|
|
let mut network = NetworkProxySettings::default();
|
|
if !allowed_domains.is_empty() {
|
|
network.set_allowed_domains(strings(allowed_domains));
|
|
}
|
|
if !denied_domains.is_empty() {
|
|
network.set_denied_domains(strings(denied_domains));
|
|
}
|
|
network
|
|
}
|
|
|
|
fn network_settings_with_unix_sockets(
|
|
allowed_domains: &[&str],
|
|
denied_domains: &[&str],
|
|
unix_sockets: &[String],
|
|
) -> NetworkProxySettings {
|
|
let mut network = network_settings(allowed_domains, denied_domains);
|
|
if !unix_sockets.is_empty() {
|
|
network.set_allow_unix_sockets(unix_sockets.to_vec());
|
|
}
|
|
network
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reload_rejects_credential_broker_enablement_changes() {
|
|
let initial_state = build_config_state(
|
|
NetworkProxyConfig::default(),
|
|
NetworkProxyConstraints::default(),
|
|
)
|
|
.unwrap();
|
|
let mut reloaded_state = initial_state.clone();
|
|
reloaded_state
|
|
.config
|
|
.set_credential_broker_enabled(/*enabled*/ true);
|
|
let state = NetworkProxyState::with_reloader(
|
|
initial_state,
|
|
Arc::new(StaticReloader {
|
|
state: reloaded_state,
|
|
}),
|
|
);
|
|
|
|
let err = state
|
|
.force_reload()
|
|
.await
|
|
.expect_err("credential broker enablement should require a proxy restart");
|
|
let mut env = HashMap::from([("OPENAI_API_KEY".to_string(), "sk-real".to_string())]);
|
|
state.virtualize_child_credentials(&mut env);
|
|
|
|
assert!(
|
|
format!("{err:#}")
|
|
.contains("network.credential_broker cannot change while the proxy is running"),
|
|
"unexpected error: {err:#}"
|
|
);
|
|
assert_eq!(env["OPENAI_API_KEY"], "sk-real");
|
|
assert!(!state.credential_broker.enabled());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_denied_wins_over_allowed() {
|
|
let state =
|
|
network_proxy_state_for_policy(network_settings(&["example.com"], &["example.com"]));
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("example.com", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::Denied)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_requires_allowlist_match() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("example.com", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
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", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_allowed_domain_removes_matching_deny_entry() {
|
|
let state = network_proxy_state_for_policy(network_settings(&[], &["example.com"]));
|
|
|
|
state.add_allowed_domain("ExAmPlE.CoM").await.unwrap();
|
|
|
|
let (allowed, denied) = state.current_patterns().await.unwrap();
|
|
assert_eq!(allowed, vec!["example.com".to_string()]);
|
|
assert!(denied.is_empty());
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("example.com", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_denied_domain_removes_matching_allow_entry() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
|
|
|
state.add_denied_domain("EXAMPLE.COM").await.unwrap();
|
|
|
|
let (allowed, denied) = state.current_patterns().await.unwrap();
|
|
assert!(allowed.is_empty());
|
|
assert_eq!(denied, vec!["example.com".to_string()]);
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("example.com", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::Denied)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["*"], &[]));
|
|
|
|
assert_eq!(
|
|
// Use a public IP literal to avoid relying on ambient DNS behavior.
|
|
state.host_blocked("8.8.8.8", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
|
|
state.add_denied_domain("8.8.8.8").await.unwrap();
|
|
|
|
let (allowed, denied) = state.current_patterns().await.unwrap();
|
|
assert_eq!(allowed, vec!["*".to_string()]);
|
|
assert_eq!(denied, vec!["8.8.8.8".to_string()]);
|
|
assert_eq!(
|
|
state.host_blocked("8.8.8.8", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::Denied)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() {
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["managed.example.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["managed.example.com".to_string()]),
|
|
allowlist_expansion_enabled: Some(true),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
let state = NetworkProxyState::with_reloader(
|
|
build_config_state(config, constraints).unwrap(),
|
|
Arc::new(NoopReloader),
|
|
);
|
|
|
|
state.add_allowed_domain("user.example.com").await.unwrap();
|
|
|
|
let (allowed, denied) = state.current_patterns().await.unwrap();
|
|
assert_eq!(
|
|
allowed,
|
|
vec![
|
|
"managed.example.com".to_string(),
|
|
"user.example.com".to_string()
|
|
]
|
|
);
|
|
assert!(denied.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() {
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["managed.example.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["managed.example.com".to_string()]),
|
|
allowlist_expansion_enabled: Some(false),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
let state = NetworkProxyState::with_reloader(
|
|
build_config_state(config, constraints).unwrap(),
|
|
Arc::new(NoopReloader),
|
|
);
|
|
|
|
let err = state
|
|
.add_allowed_domain("user.example.com")
|
|
.await
|
|
.expect_err("managed baseline should reject allowlist expansion");
|
|
|
|
assert!(
|
|
format!("{err:#}").contains("network.allowed_domains constrained by managed config"),
|
|
"unexpected error: {err:#}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() {
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&[], &["managed.example.com"]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
let constraints = NetworkProxyConstraints {
|
|
denied_domains: Some(vec!["managed.example.com".to_string()]),
|
|
denylist_expansion_enabled: Some(false),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
let state = NetworkProxyState::with_reloader(
|
|
build_config_state(config, constraints).unwrap(),
|
|
Arc::new(NoopReloader),
|
|
);
|
|
|
|
let err = state
|
|
.add_denied_domain("user.example.com")
|
|
.await
|
|
.expect_err("managed baseline should reject denylist expansion");
|
|
|
|
assert!(
|
|
format!("{err:#}").contains("network.denied_domains constrained by managed config"),
|
|
"unexpected error: {err:#}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blocked_snapshot_does_not_consume_entries() {
|
|
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
|
|
|
state
|
|
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
|
host: "google.com".to_string(),
|
|
reason: "not_allowed".to_string(),
|
|
client: None,
|
|
method: Some("GET".to_string()),
|
|
mode: None,
|
|
protocol: "http".to_string(),
|
|
decision: Some("ask".to_string()),
|
|
source: Some("decider".to_string()),
|
|
port: Some(80),
|
|
}))
|
|
.await
|
|
.expect("entry should be recorded");
|
|
|
|
let snapshot = state
|
|
.blocked_snapshot()
|
|
.await
|
|
.expect("snapshot should succeed");
|
|
assert_eq!(snapshot.len(), 1);
|
|
assert_eq!(snapshot[0].host, "google.com");
|
|
assert_eq!(snapshot[0].decision.as_deref(), Some("ask"));
|
|
|
|
let drained = state
|
|
.drain_blocked()
|
|
.await
|
|
.expect("drain should include snapshot entry");
|
|
assert_eq!(drained.len(), 1);
|
|
assert_eq!(drained[0].host, snapshot[0].host);
|
|
assert_eq!(drained[0].reason, snapshot[0].reason);
|
|
assert_eq!(drained[0].decision, snapshot[0].decision);
|
|
assert_eq!(drained[0].source, snapshot[0].source);
|
|
assert_eq!(drained[0].port, snapshot[0].port);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn drain_blocked_returns_buffered_window() {
|
|
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
|
|
|
for idx in 0..(MAX_BLOCKED_EVENTS + 5) {
|
|
state
|
|
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
|
host: format!("example{idx}.com"),
|
|
reason: "not_allowed".to_string(),
|
|
client: None,
|
|
method: Some("GET".to_string()),
|
|
mode: None,
|
|
protocol: "http".to_string(),
|
|
decision: Some("ask".to_string()),
|
|
source: Some("decider".to_string()),
|
|
port: Some(80),
|
|
}))
|
|
.await
|
|
.expect("entry should be recorded");
|
|
}
|
|
|
|
let blocked = state.drain_blocked().await.expect("drain should succeed");
|
|
assert_eq!(blocked.len(), MAX_BLOCKED_EVENTS);
|
|
assert_eq!(blocked[0].host, "example5.com");
|
|
}
|
|
|
|
#[test]
|
|
fn blocked_request_violation_log_line_serializes_payload() {
|
|
let entry = BlockedRequest {
|
|
host: "google.com".to_string(),
|
|
reason: "not_allowed".to_string(),
|
|
client: Some("127.0.0.1".to_string()),
|
|
method: Some("GET".to_string()),
|
|
mode: Some(NetworkMode::Full),
|
|
protocol: "http".to_string(),
|
|
execution_id: None,
|
|
decision: Some("ask".to_string()),
|
|
source: Some("decider".to_string()),
|
|
port: Some(80),
|
|
timestamp: 1_735_689_600,
|
|
};
|
|
|
|
assert_eq!(
|
|
blocked_request_violation_log_line(&entry),
|
|
r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"#
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_subdomain_wildcards_exclude_apex() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["*.openai.com"], &[]));
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("api.openai.com", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
assert_eq!(
|
|
state.host_blocked("openai.com", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["*"], &["evil.example"]));
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("example.com", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("api.openai.com", /*port*/ 443)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("evil.example", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::Denied)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_rejects_loopback_when_local_binding_disabled() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
|
|
|
assert_eq!(
|
|
state.host_blocked("127.0.0.1", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
|
);
|
|
assert_eq!(
|
|
state.host_blocked("localhost", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["localhost"], &[]));
|
|
|
|
assert_eq!(
|
|
state.host_blocked("localhost", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["10.0.0.1"], &[]));
|
|
|
|
assert_eq!(
|
|
state.host_blocked("10.0.0.1", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("fe80::1%lo0", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["fe80::1"], &[]));
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("fe80::1%lo0", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_requires_exact_scoped_ipv6_allowlist_match() {
|
|
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
|
allow_local_binding: true,
|
|
..network_settings(&["fe80::1%eth0"], &[])
|
|
});
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("fe80::1%eth0", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("fe80::1%eth1", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_denies_scoped_ipv6_literal_before_local_binding() {
|
|
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
|
allow_local_binding: true,
|
|
..network_settings(&["*"], &["fd00::1"])
|
|
});
|
|
|
|
for host in ["fd00::1%eth0", "[fd00::1%eth0]", "[fd00::1%25eth0]"] {
|
|
assert_eq!(
|
|
state.host_blocked(host, /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::Denied),
|
|
"host should be denied after normalization: {host}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_requires_exact_scoped_ipv6_denylist_match() {
|
|
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
|
allow_local_binding: true,
|
|
..network_settings(&["*"], &["fd00::1%eth0"])
|
|
});
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("fd00::1%eth0", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::Denied)
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("fd00::1%eth1", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Allowed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() {
|
|
let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
|
|
|
|
assert_eq!(
|
|
state.host_blocked("10.0.0.1", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_rejects_loopback_when_allowlist_empty() {
|
|
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
|
|
|
assert_eq!(
|
|
state.host_blocked("127.0.0.1", /*port*/ 80).await.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_blocked_rejects_allowlisted_hostname_when_dns_lookup_fails() {
|
|
let mut network = NetworkProxySettings::default();
|
|
network.set_allowed_domains(vec!["does-not-resolve.invalid".to_string()]);
|
|
let state = network_proxy_state_for_policy(network);
|
|
|
|
assert_eq!(
|
|
state
|
|
.host_blocked("does-not-resolve.invalid", /*port*/ 80)
|
|
.await
|
|
.unwrap(),
|
|
HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_resolves_to_non_public_ip_blocks_on_dns_lookup_timeout() {
|
|
let blocked = host_resolves_to_non_public_ip(
|
|
"slow.example",
|
|
/*port*/ 80,
|
|
Duration::from_millis(1),
|
|
|_host, _port| async {
|
|
std::future::pending::<std::io::Result<Vec<SocketAddr>>>().await
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(blocked);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_resolves_to_non_public_ip_blocks_on_dns_lookup_error() {
|
|
let blocked = host_resolves_to_non_public_ip(
|
|
"error.example",
|
|
/*port*/ 80,
|
|
Duration::from_millis(10),
|
|
|_host, _port| async {
|
|
Err::<Vec<SocketAddr>, std::io::Error>(std::io::Error::new(
|
|
std::io::ErrorKind::TimedOut,
|
|
"forced failure",
|
|
))
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert!(blocked);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_resolves_to_non_public_ip_blocks_private_resolution() {
|
|
let blocked = host_resolves_to_non_public_ip(
|
|
"local.example",
|
|
/*port*/ 80,
|
|
Duration::from_millis(10),
|
|
|_host, _port| async { Ok(vec!["127.0.0.1:80".parse().unwrap()]) },
|
|
)
|
|
.await;
|
|
|
|
assert!(blocked);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_resolves_to_non_public_ip_allows_public_resolution() {
|
|
let blocked = host_resolves_to_non_public_ip(
|
|
"public.example",
|
|
/*port*/ 80,
|
|
Duration::from_millis(10),
|
|
|_host, _port| async { Ok(vec!["8.8.8.8:80".parse().unwrap()]) },
|
|
)
|
|
.await;
|
|
|
|
assert!(!blocked);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_disallows_widening_allowed_domains() {
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["example.com".to_string()]),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["example.com", "evil.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_allows_expanding_allowed_domains_when_enabled() {
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["example.com".to_string()]),
|
|
allowlist_expansion_enabled: Some(true),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["example.com", "api.openai.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_disallows_widening_mode() {
|
|
let constraints = NetworkProxyConstraints {
|
|
mode: Some(NetworkMode::Limited),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: NetworkProxySettings {
|
|
enabled: true,
|
|
mode: NetworkMode::Full,
|
|
..NetworkProxySettings::default()
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() {
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["*.example.com".to_string()]),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["api.example.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() {
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["*.example.com".to_string()]),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["**.example.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_rejects_global_wildcard_in_managed_allowlist() {
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["*".to_string()]),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["api.example.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_rejects_bracketed_global_wildcard_in_managed_allowlist()
|
|
{
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["[*]".to_string()]),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["api.example.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_rejects_double_wildcard_bracketed_global_wildcard_in_managed_allowlist()
|
|
{
|
|
let constraints = NetworkProxyConstraints {
|
|
allowed_domains: Some(vec!["**.[*]".to_string()]),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["api.example.com"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_requires_managed_denied_domains_entries() {
|
|
let constraints = NetworkProxyConstraints {
|
|
denied_domains: Some(vec!["evil.com".to_string()]),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: NetworkProxySettings {
|
|
enabled: true,
|
|
..NetworkProxySettings::default()
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_disallows_expanding_denied_domains_when_fixed() {
|
|
let constraints = NetworkProxyConstraints {
|
|
denied_domains: Some(vec!["evil.com".to_string()]),
|
|
denylist_expansion_enabled: Some(false),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&[], &["evil.com", "more-evil.com"]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() {
|
|
let constraints = NetworkProxyConstraints {
|
|
enabled: Some(false),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: NetworkProxySettings {
|
|
enabled: true,
|
|
..NetworkProxySettings::default()
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() {
|
|
let constraints = NetworkProxyConstraints {
|
|
allow_local_binding: Some(false),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: NetworkProxySettings {
|
|
enabled: true,
|
|
allow_local_binding: true,
|
|
..NetworkProxySettings::default()
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_without_managed_opt_in()
|
|
{
|
|
let constraints = NetworkProxyConstraints {
|
|
dangerously_allow_all_unix_sockets: Some(false),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: NetworkProxySettings {
|
|
enabled: true,
|
|
dangerously_allow_all_unix_sockets: true,
|
|
..NetworkProxySettings::default()
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_when_allowlist_is_managed()
|
|
{
|
|
let constraints = NetworkProxyConstraints {
|
|
allow_unix_sockets: Some(vec!["/tmp/allowed.sock".to_string()]),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: NetworkProxySettings {
|
|
enabled: true,
|
|
dangerously_allow_all_unix_sockets: true,
|
|
..NetworkProxySettings::default()
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_allows_allow_all_unix_sockets_with_managed_opt_in() {
|
|
let constraints = NetworkProxyConstraints {
|
|
dangerously_allow_all_unix_sockets: Some(true),
|
|
..NetworkProxyConstraints::default()
|
|
};
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: NetworkProxySettings {
|
|
enabled: true,
|
|
dangerously_allow_all_unix_sockets: true,
|
|
..NetworkProxySettings::default()
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_policy_against_constraints_allows_allow_all_unix_sockets_when_unmanaged() {
|
|
let constraints = NetworkProxyConstraints::default();
|
|
|
|
let config = NetworkProxyConfig {
|
|
network: NetworkProxySettings {
|
|
enabled: true,
|
|
dangerously_allow_all_unix_sockets: true,
|
|
..NetworkProxySettings::default()
|
|
},
|
|
};
|
|
|
|
assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_is_case_insensitive() {
|
|
let patterns = vec!["ExAmPle.CoM".to_string()];
|
|
let set = compile_denylist_globset(&patterns).unwrap();
|
|
assert!(set.is_match("example.com"));
|
|
assert!(set.is_match("EXAMPLE.COM"));
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_excludes_apex_for_subdomain_patterns() {
|
|
let patterns = vec!["*.openai.com".to_string()];
|
|
let set = compile_denylist_globset(&patterns).unwrap();
|
|
assert!(set.is_match("api.openai.com"));
|
|
assert!(!set.is_match("openai.com"));
|
|
assert!(!set.is_match("evilopenai.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_includes_apex_for_double_wildcard_patterns() {
|
|
let patterns = vec!["**.openai.com".to_string()];
|
|
let set = compile_denylist_globset(&patterns).unwrap();
|
|
assert!(set.is_match("openai.com"));
|
|
assert!(set.is_match("api.openai.com"));
|
|
assert!(!set.is_match("evilopenai.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_rejects_global_wildcard() {
|
|
let patterns = vec!["*".to_string()];
|
|
assert!(compile_denylist_globset(&patterns).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_allows_global_wildcard_when_enabled() {
|
|
let patterns = vec!["*".to_string()];
|
|
let set = compile_allowlist_globset(&patterns).unwrap();
|
|
assert!(set.is_match("example.com"));
|
|
assert!(set.is_match("api.openai.com"));
|
|
assert!(set.is_match("localhost"));
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_rejects_bracketed_global_wildcard() {
|
|
let patterns = vec!["[*]".to_string()];
|
|
assert!(compile_denylist_globset(&patterns).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() {
|
|
let patterns = vec!["**.[*]".to_string()];
|
|
assert!(compile_denylist_globset(&patterns).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_dedupes_patterns_without_changing_behavior() {
|
|
let patterns = vec!["example.com".to_string(), "example.com".to_string()];
|
|
let set = compile_denylist_globset(&patterns).unwrap();
|
|
assert!(set.is_match("example.com"));
|
|
assert!(set.is_match("EXAMPLE.COM"));
|
|
assert!(!set.is_match("not-example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn compile_globset_rejects_invalid_patterns() {
|
|
let patterns = vec!["[".to_string()];
|
|
assert!(compile_denylist_globset(&patterns).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn build_config_state_allows_global_wildcard_allowed_domains() {
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["*"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() {
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["[*]"], &[]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn build_config_state_rejects_global_wildcard_denied_domains() {
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["example.com"], &["*"]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() {
|
|
let config = NetworkProxyConfig {
|
|
network: {
|
|
let mut network = network_settings(&["example.com"], &["[*]"]);
|
|
network.enabled = true;
|
|
network
|
|
},
|
|
};
|
|
|
|
assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[tokio::test]
|
|
async fn unix_socket_allowlist_is_respected_on_macos() {
|
|
let socket_path = "/tmp/example.sock".to_string();
|
|
let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
|
|
&["example.com"],
|
|
&[],
|
|
std::slice::from_ref(&socket_path),
|
|
));
|
|
|
|
assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap());
|
|
assert!(
|
|
!state
|
|
.is_unix_socket_allowed("/tmp/not-allowed.sock")
|
|
.await
|
|
.unwrap()
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[tokio::test]
|
|
async fn unix_socket_allowlist_resolves_symlinks() {
|
|
use std::os::unix::fs::symlink;
|
|
use tempfile::tempdir;
|
|
|
|
let temp_dir = tempdir().unwrap();
|
|
let dir = temp_dir.path();
|
|
|
|
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 = network_proxy_state_for_policy(network_settings_with_unix_sockets(
|
|
&["example.com"],
|
|
&[],
|
|
std::slice::from_ref(&real_s),
|
|
));
|
|
|
|
assert!(state.is_unix_socket_allowed(&link_s).await.unwrap());
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[tokio::test]
|
|
async fn unix_socket_allow_all_flag_bypasses_allowlist() {
|
|
let state = network_proxy_state_for_policy({
|
|
let mut network = network_settings(&["example.com"], &[]);
|
|
network.dangerously_allow_all_unix_sockets = true;
|
|
network
|
|
});
|
|
|
|
assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap());
|
|
assert!(!state.is_unix_socket_allowed("relative.sock").await.unwrap());
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
#[tokio::test]
|
|
async fn unix_socket_allowlist_is_rejected_on_non_macos() {
|
|
let socket_path = "/tmp/example.sock".to_string();
|
|
let state = network_proxy_state_for_policy({
|
|
let mut network = network_settings_with_unix_sockets(
|
|
&["example.com"],
|
|
&[],
|
|
std::slice::from_ref(&socket_path),
|
|
);
|
|
network.dangerously_allow_all_unix_sockets = true;
|
|
network
|
|
});
|
|
|
|
assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap());
|
|
}
|
|
}
|