1use crate::config::NetworkDomainPermission;
2use crate::config::NetworkMode;
3use crate::config::NetworkProxyConfig;
4use crate::config::ValidatedUnixSocketPath;
5use crate::mitm::MitmState;
6use crate::policy::Host;
7use crate::policy::is_loopback_host;
8use crate::policy::is_non_public_ip;
9use crate::policy::normalize_host;
10use crate::policy::unscoped_ip_literal;
11use crate::reasons::REASON_DENIED;
12use crate::reasons::REASON_NOT_ALLOWED;
13use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
14use crate::state::NetworkProxyConstraintError;
15use crate::state::NetworkProxyConstraints;
16use crate::state::build_config_state;
17use crate::state::validate_policy_against_constraints;
18use anyhow::Context;
19use anyhow::Result;
20use async_trait::async_trait;
21use globset::GlobSet;
22use serde::Serialize;
23use std::collections::HashSet;
24use std::collections::VecDeque;
25use std::future::Future;
26use std::net::IpAddr;
27use std::net::SocketAddr;
28use std::path::Path;
29use std::sync::Arc;
30use std::time::Duration;
31use time::OffsetDateTime;
32use tokio::net::lookup_host;
33use tokio::sync::RwLock;
34use tokio::time::timeout;
35use tracing::debug;
36use tracing::info;
37use tracing::warn;
38use zerobox_utils_absolute_path::AbsolutePathBuf;
39
40const MAX_BLOCKED_EVENTS: usize = 200;
41const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
42const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
43
44#[derive(Clone, Debug, Default, PartialEq, Eq)]
45pub struct NetworkProxyAuditMetadata {
46 pub conversation_id: Option<String>,
47 pub app_version: Option<String>,
48 pub user_account_id: Option<String>,
49 pub auth_mode: Option<String>,
50 pub originator: Option<String>,
51 pub user_email: Option<String>,
52 pub terminal_type: Option<String>,
53 pub model: Option<String>,
54 pub slug: Option<String>,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum HostBlockReason {
59 Denied,
60 NotAllowed,
61 NotAllowedLocal,
62}
63
64impl HostBlockReason {
65 pub const fn as_str(self) -> &'static str {
66 match self {
67 Self::Denied => REASON_DENIED,
68 Self::NotAllowed => REASON_NOT_ALLOWED,
69 Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL,
70 }
71 }
72}
73
74impl std::fmt::Display for HostBlockReason {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.write_str(self.as_str())
77 }
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum HostBlockDecision {
82 Allowed,
83 Blocked(HostBlockReason),
84}
85
86#[derive(Clone, Debug, Serialize)]
87pub struct BlockedRequest {
88 pub host: String,
89 pub reason: String,
90 pub client: Option<String>,
91 pub method: Option<String>,
92 pub mode: Option<NetworkMode>,
93 pub protocol: String,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub decision: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub source: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub port: Option<u16>,
100 pub timestamp: i64,
101}
102
103pub struct BlockedRequestArgs {
104 pub host: String,
105 pub reason: String,
106 pub client: Option<String>,
107 pub method: Option<String>,
108 pub mode: Option<NetworkMode>,
109 pub protocol: String,
110 pub decision: Option<String>,
111 pub source: Option<String>,
112 pub port: Option<u16>,
113}
114
115impl BlockedRequest {
116 pub fn new(args: BlockedRequestArgs) -> Self {
117 let BlockedRequestArgs {
118 host,
119 reason,
120 client,
121 method,
122 mode,
123 protocol,
124 decision,
125 source,
126 port,
127 } = args;
128 Self {
129 host,
130 reason,
131 client,
132 method,
133 mode,
134 protocol,
135 decision,
136 source,
137 port,
138 timestamp: unix_timestamp(),
139 }
140 }
141}
142
143fn blocked_request_violation_log_line(entry: &BlockedRequest) -> String {
144 match serde_json::to_string(entry) {
145 Ok(json) => format!("{NETWORK_POLICY_VIOLATION_PREFIX} {json}"),
146 Err(err) => {
147 debug!("failed to serialize blocked request for violation log: {err}");
148 format!(
149 "{NETWORK_POLICY_VIOLATION_PREFIX} host={} reason={}",
150 entry.host, entry.reason
151 )
152 }
153 }
154}
155
156#[derive(Clone)]
157pub struct ConfigState {
158 pub config: NetworkProxyConfig,
159 pub allow_set: GlobSet,
160 pub deny_set: GlobSet,
161 pub mitm: Option<Arc<MitmState>>,
162 pub constraints: NetworkProxyConstraints,
163 pub blocked: VecDeque<BlockedRequest>,
164 pub blocked_total: u64,
165}
166
167#[async_trait]
168pub trait ConfigReloader: Send + Sync {
169 fn source_label(&self) -> String;
171
172 async fn maybe_reload(&self) -> Result<Option<ConfigState>>;
174
175 async fn reload_now(&self) -> Result<ConfigState>;
177}
178
179#[async_trait]
180pub trait BlockedRequestObserver: Send + Sync + 'static {
181 async fn on_blocked_request(&self, request: BlockedRequest);
182}
183
184#[async_trait]
185impl<O: BlockedRequestObserver + ?Sized> BlockedRequestObserver for Arc<O> {
186 async fn on_blocked_request(&self, request: BlockedRequest) {
187 (**self).on_blocked_request(request).await
188 }
189}
190
191#[async_trait]
192impl<F, Fut> BlockedRequestObserver for F
193where
194 F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static,
195 Fut: Future<Output = ()> + Send,
196{
197 async fn on_blocked_request(&self, request: BlockedRequest) {
198 (self)(request).await
199 }
200}
201
202pub trait RequestHeaderTransformer: Send + Sync + 'static {
206 fn transform_headers(&self, headers: &mut rama_http::HeaderMap, target_host: &str);
207}
208
209pub struct NetworkProxyState {
210 state: Arc<RwLock<ConfigState>>,
211 reloader: Arc<dyn ConfigReloader>,
212 blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
213 audit_metadata: NetworkProxyAuditMetadata,
214 header_transformer: Option<Arc<dyn RequestHeaderTransformer>>,
215}
216
217impl std::fmt::Debug for NetworkProxyState {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 f.debug_struct("NetworkProxyState").finish_non_exhaustive()
222 }
223}
224
225impl Clone for NetworkProxyState {
226 fn clone(&self) -> Self {
227 Self {
228 state: self.state.clone(),
229 reloader: self.reloader.clone(),
230 blocked_request_observer: self.blocked_request_observer.clone(),
231 audit_metadata: self.audit_metadata.clone(),
232 header_transformer: self.header_transformer.clone(),
233 }
234 }
235}
236
237impl NetworkProxyState {
238 pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
239 Self::with_reloader_and_audit_metadata(
240 state,
241 reloader,
242 NetworkProxyAuditMetadata::default(),
243 )
244 }
245
246 pub fn with_reloader_and_blocked_observer(
247 state: ConfigState,
248 reloader: Arc<dyn ConfigReloader>,
249 blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
250 ) -> Self {
251 Self::with_reloader_and_audit_metadata_and_blocked_observer(
252 state,
253 reloader,
254 NetworkProxyAuditMetadata::default(),
255 blocked_request_observer,
256 )
257 }
258
259 pub fn with_reloader_and_audit_metadata(
260 state: ConfigState,
261 reloader: Arc<dyn ConfigReloader>,
262 audit_metadata: NetworkProxyAuditMetadata,
263 ) -> Self {
264 Self::with_reloader_and_audit_metadata_and_blocked_observer(
265 state,
266 reloader,
267 audit_metadata,
268 None,
269 )
270 }
271
272 pub fn with_reloader_and_audit_metadata_and_blocked_observer(
273 state: ConfigState,
274 reloader: Arc<dyn ConfigReloader>,
275 audit_metadata: NetworkProxyAuditMetadata,
276 blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
277 ) -> Self {
278 Self {
279 state: Arc::new(RwLock::new(state)),
280 reloader,
281 blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
282 audit_metadata,
283 header_transformer: None,
284 }
285 }
286
287 pub async fn set_blocked_request_observer(
288 &self,
289 blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
290 ) {
291 let mut observer = self.blocked_request_observer.write().await;
292 *observer = blocked_request_observer;
293 }
294
295 pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
296 &self.audit_metadata
297 }
298
299 pub fn set_header_transformer(&mut self, t: Arc<dyn RequestHeaderTransformer>) {
300 self.header_transformer = Some(t);
301 }
302
303 pub fn header_transformer(&self) -> Option<&Arc<dyn RequestHeaderTransformer>> {
304 self.header_transformer.as_ref()
305 }
306
307 pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
308 self.reload_if_needed().await?;
311 let guard = self.state.read().await;
312 Ok(guard.config.clone())
313 }
314
315 pub async fn current_patterns(&self) -> Result<(Vec<String>, Vec<String>)> {
316 self.reload_if_needed().await?;
317 let guard = self.state.read().await;
318 Ok((
319 guard.config.network.allowed_domains().unwrap_or_default(),
320 guard.config.network.denied_domains().unwrap_or_default(),
321 ))
322 }
323
324 pub async fn enabled(&self) -> Result<bool> {
325 self.reload_if_needed().await?;
326 let guard = self.state.read().await;
327 Ok(guard.config.network.enabled)
328 }
329
330 pub async fn force_reload(&self) -> Result<()> {
331 let previous_cfg = {
332 let guard = self.state.read().await;
333 guard.config.clone()
334 };
335
336 match self.reloader.reload_now().await {
337 Ok(mut new_state) => {
338 log_policy_changes(&previous_cfg, &new_state.config);
341 {
342 let mut guard = self.state.write().await;
343 new_state.blocked = guard.blocked.clone();
344 *guard = new_state;
345 }
346 let source = self.reloader.source_label();
347 info!("reloaded config from {source}");
348 Ok(())
349 }
350 Err(err) => {
351 let source = self.reloader.source_label();
352 warn!("failed to reload config from {source}: {err}; keeping previous config");
353 Err(err)
354 }
355 }
356 }
357
358 pub async fn replace_config_state(&self, mut new_state: ConfigState) -> Result<()> {
359 self.reload_if_needed().await?;
360 let mut guard = self.state.write().await;
361 log_policy_changes(&guard.config, &new_state.config);
362 new_state.blocked = guard.blocked.clone();
363 new_state.blocked_total = guard.blocked_total;
364 *guard = new_state;
365 info!("updated network proxy config state");
366 Ok(())
367 }
368
369 pub async fn host_blocked(&self, host: &str, port: u16) -> Result<HostBlockDecision> {
370 self.reload_if_needed().await?;
371 let host = match Host::parse(host) {
372 Ok(host) => host,
373 Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)),
374 };
375 let (deny_set, allow_set, allow_local_binding, allowed_domains) = {
376 let guard = self.state.read().await;
377 let allowed_domains = guard.config.network.allowed_domains();
378 (
379 guard.deny_set.clone(),
380 guard.allow_set.clone(),
381 guard.config.network.allow_local_binding,
382 allowed_domains,
383 )
384 };
385 let allowed_domains_empty = allowed_domains.is_none();
386 let allowed_domains = allowed_domains.unwrap_or_default();
387
388 let host_str = host.as_str();
389
390 if globset_matches_host_or_unscoped(&deny_set, host_str) {
395 return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
396 }
397
398 let is_allowlisted = globset_matches_host_or_unscoped(&allow_set, host_str);
399 if !allow_local_binding {
400 let local_literal = {
409 let host_no_scope = unscoped_ip_literal(host_str).unwrap_or(host_str);
410 if is_loopback_host(&host) {
411 true
412 } else if let Ok(ip) = host_no_scope.parse::<IpAddr>() {
413 is_non_public_ip(ip)
414 } else {
415 false
416 }
417 };
418
419 if local_literal {
420 if !is_explicit_local_allowlisted(&allowed_domains, &host) {
421 return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
422 }
423 } else if host_resolves_to_non_public_ip(
424 host_str,
425 port,
426 DNS_LOOKUP_TIMEOUT,
427 |host, port| async move {
428 lookup_host((host.as_str(), port))
429 .await
430 .map(Iterator::collect)
431 },
432 )
433 .await
434 {
435 return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
436 }
437 }
438
439 if allowed_domains_empty || !is_allowlisted {
440 Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed))
441 } else {
442 Ok(HostBlockDecision::Allowed)
443 }
444 }
445
446 pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> {
447 self.reload_if_needed().await?;
448 let blocked_for_observer = entry.clone();
449 let blocked_request_observer = self.blocked_request_observer.read().await.clone();
450 let violation_line = blocked_request_violation_log_line(&entry);
451 let host = entry.host.clone();
452 let reason = entry.reason.clone();
453 let decision = entry.decision.clone();
454 let source = entry.source.clone();
455 let protocol = entry.protocol.clone();
456 let port = entry.port;
457 let (total, buffered) = {
458 let mut guard = self.state.write().await;
459 guard.blocked.push_back(entry);
460 guard.blocked_total = guard.blocked_total.saturating_add(1);
461 let total = guard.blocked_total;
462 while guard.blocked.len() > MAX_BLOCKED_EVENTS {
463 guard.blocked.pop_front();
464 }
465 (total, guard.blocked.len())
466 };
467 debug!(
468 "recorded blocked request telemetry (\
469 total={total}, host={host}, reason={reason}, \
470 decision={decision:?}, source={source:?}, \
471 protocol={protocol}, port={port:?}, buffered={buffered})"
472 );
473 debug!("{violation_line}");
474
475 if let Some(observer) = blocked_request_observer {
476 observer.on_blocked_request(blocked_for_observer).await;
477 }
478 Ok(())
479 }
480
481 pub async fn blocked_snapshot(&self) -> Result<Vec<BlockedRequest>> {
484 self.reload_if_needed().await?;
485 let guard = self.state.read().await;
486 Ok(guard.blocked.iter().cloned().collect())
487 }
488
489 pub async fn drain_blocked(&self) -> Result<Vec<BlockedRequest>> {
491 self.reload_if_needed().await?;
492 let blocked = {
493 let mut guard = self.state.write().await;
494 std::mem::take(&mut guard.blocked)
495 };
496 Ok(blocked.into_iter().collect())
497 }
498
499 pub async fn is_unix_socket_allowed(&self, path: &str) -> Result<bool> {
500 self.reload_if_needed().await?;
501 if !unix_socket_permissions_supported() {
502 return Ok(false);
503 }
504
505 let requested_path = Path::new(path);
508 if !requested_path.is_absolute() {
509 return Ok(false);
510 }
511
512 let guard = self.state.read().await;
513 if guard.config.network.dangerously_allow_all_unix_sockets {
514 return Ok(true);
515 }
516
517 let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) {
519 Ok(path) => path,
520 Err(_) => return Ok(false),
521 };
522 let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok();
523 for allowed in &guard.config.network.allow_unix_sockets() {
524 let allowed_path = match ValidatedUnixSocketPath::parse(allowed) {
525 Ok(ValidatedUnixSocketPath::Native(path)) => path,
526 Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue,
527 Err(err) => {
528 warn!("ignoring invalid network.allow_unix_sockets entry at runtime: {err:#}");
529 continue;
530 }
531 };
532
533 if allowed_path.as_path() == requested_abs.as_path() {
534 return Ok(true);
535 }
536
537 let Some(requested_canonical) = &requested_canonical else {
540 continue;
541 };
542 if let Ok(allowed_canonical) = std::fs::canonicalize(allowed_path.as_path())
543 && &allowed_canonical == requested_canonical
544 {
545 return Ok(true);
546 }
547 }
548 Ok(false)
549 }
550
551 pub async fn method_allowed(&self, method: &str) -> Result<bool> {
552 self.reload_if_needed().await?;
553 let guard = self.state.read().await;
554 Ok(guard.config.network.mode.allows_method(method))
555 }
556
557 pub async fn allow_upstream_proxy(&self) -> Result<bool> {
558 self.reload_if_needed().await?;
559 let guard = self.state.read().await;
560 Ok(guard.config.network.allow_upstream_proxy)
561 }
562
563 pub async fn allow_local_binding(&self) -> Result<bool> {
564 self.reload_if_needed().await?;
565 let guard = self.state.read().await;
566 Ok(guard.config.network.allow_local_binding)
567 }
568
569 pub async fn network_mode(&self) -> Result<NetworkMode> {
570 self.reload_if_needed().await?;
571 let guard = self.state.read().await;
572 Ok(guard.config.network.mode)
573 }
574
575 pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> {
576 loop {
577 self.reload_if_needed().await?;
578 let (candidate, constraints) = {
579 let guard = self.state.read().await;
580 let mut candidate = guard.config.clone();
581 candidate.network.mode = mode;
582 (candidate, guard.constraints.clone())
583 };
584
585 validate_policy_against_constraints(&candidate, &constraints)
586 .map_err(NetworkProxyConstraintError::into_anyhow)
587 .context("network.mode constrained by managed config")?;
588
589 let mut guard = self.state.write().await;
590 if guard.constraints != constraints {
591 drop(guard);
592 continue;
593 }
594 guard.config.network.mode = mode;
595 info!("updated network mode to {mode:?}");
596 return Ok(());
597 }
598 }
599
600 pub async fn mitm_state(&self) -> Result<Option<Arc<MitmState>>> {
601 self.reload_if_needed().await?;
602 let guard = self.state.read().await;
603 Ok(guard.mitm.clone())
604 }
605
606 pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
607 self.update_domain_list(host, DomainListKind::Allow).await
608 }
609
610 pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
611 self.update_domain_list(host, DomainListKind::Deny).await
612 }
613
614 async fn update_domain_list(&self, host: &str, target: DomainListKind) -> Result<()> {
615 let host = Host::parse(host).context("invalid network host")?;
616 let normalized_host = host.as_str().to_string();
617 let list_name = target.list_name();
618 let constraint_field = target.constraint_field();
619
620 loop {
621 self.reload_if_needed().await?;
622 let (previous_cfg, constraints, blocked, blocked_total) = {
623 let guard = self.state.read().await;
624 (
625 guard.config.clone(),
626 guard.constraints.clone(),
627 guard.blocked.clone(),
628 guard.blocked_total,
629 )
630 };
631
632 let mut candidate = previous_cfg.clone();
633 let target_entries = target.entries(&candidate.network);
634 let opposite_entries = target.opposite_entries(&candidate.network);
635 let target_contains = target_entries
636 .iter()
637 .any(|entry| normalize_host(entry) == normalized_host);
638 let opposite_contains = opposite_entries
639 .iter()
640 .any(|entry| normalize_host(entry) == normalized_host);
641 if target_contains && !opposite_contains {
642 return Ok(());
643 }
644
645 candidate.network.upsert_domain_permission(
646 normalized_host.clone(),
647 target.permission(),
648 normalize_host,
649 );
650
651 validate_policy_against_constraints(&candidate, &constraints)
652 .map_err(NetworkProxyConstraintError::into_anyhow)
653 .with_context(|| format!("{constraint_field} constrained by managed config"))?;
654
655 let mut new_state = build_config_state(candidate.clone(), constraints.clone())
656 .with_context(|| format!("failed to compile updated network {list_name}"))?;
657 new_state.blocked = blocked;
658 new_state.blocked_total = blocked_total;
659
660 let mut guard = self.state.write().await;
661 if guard.constraints != constraints || guard.config != previous_cfg {
662 drop(guard);
663 continue;
664 }
665
666 log_policy_changes(&guard.config, &candidate);
667 *guard = new_state;
668 info!("updated network {list_name} with {normalized_host}");
669 return Ok(());
670 }
671 }
672
673 async fn reload_if_needed(&self) -> Result<()> {
674 match self.reloader.maybe_reload().await? {
675 None => Ok(()),
676 Some(mut new_state) => {
677 let (previous_cfg, blocked, blocked_total) = {
678 let guard = self.state.read().await;
679 (
680 guard.config.clone(),
681 guard.blocked.clone(),
682 guard.blocked_total,
683 )
684 };
685 log_policy_changes(&previous_cfg, &new_state.config);
686 new_state.blocked = blocked;
687 new_state.blocked_total = blocked_total;
688 {
689 let mut guard = self.state.write().await;
690 *guard = new_state;
691 }
692 let source = self.reloader.source_label();
693 info!("reloaded config from {source}");
694 Ok(())
695 }
696 }
697 }
698}
699
700#[derive(Clone, Copy)]
701enum DomainListKind {
702 Allow,
703 Deny,
704}
705
706impl DomainListKind {
707 fn list_name(self) -> &'static str {
708 match self {
709 Self::Allow => "allowlist",
710 Self::Deny => "denylist",
711 }
712 }
713
714 fn constraint_field(self) -> &'static str {
715 match self {
716 Self::Allow => "network.allowed_domains",
717 Self::Deny => "network.denied_domains",
718 }
719 }
720
721 fn permission(self) -> NetworkDomainPermission {
722 match self {
723 Self::Allow => NetworkDomainPermission::Allow,
724 Self::Deny => NetworkDomainPermission::Deny,
725 }
726 }
727
728 fn entries(self, network: &crate::config::NetworkProxySettings) -> Vec<String> {
729 match self {
730 Self::Allow => network.allowed_domains().unwrap_or_default(),
731 Self::Deny => network.denied_domains().unwrap_or_default(),
732 }
733 }
734
735 fn opposite_entries(self, network: &crate::config::NetworkProxySettings) -> Vec<String> {
736 match self {
737 Self::Allow => network.denied_domains().unwrap_or_default(),
738 Self::Deny => network.allowed_domains().unwrap_or_default(),
739 }
740 }
741}
742
743pub(crate) fn unix_socket_permissions_supported() -> bool {
744 cfg!(target_os = "macos")
745}
746
747async fn host_resolves_to_non_public_ip<F, Fut>(
748 host: &str,
749 port: u16,
750 lookup_timeout: Duration,
751 lookup: F,
752) -> bool
753where
754 F: FnOnce(String, u16) -> Fut,
755 Fut: Future<Output = std::io::Result<Vec<SocketAddr>>>,
756{
757 if let Ok(ip) = host.parse::<IpAddr>() {
758 return is_non_public_ip(ip);
759 }
760
761 let addrs = match timeout(lookup_timeout, lookup(host.to_string(), port)).await {
764 Ok(Ok(addrs)) => addrs,
765 Ok(Err(err)) => {
766 debug!(
767 "blocking host because DNS lookup failed during local/private IP check (host={host}, port={port}): {err}"
768 );
769 return true;
770 }
771 Err(_) => {
772 debug!(
773 "blocking host because DNS lookup timed out during local/private IP check (host={host}, port={port})"
774 );
775 return true;
776 }
777 };
778
779 for addr in addrs {
780 if is_non_public_ip(addr.ip()) {
781 return true;
782 }
783 }
784
785 false
786}
787
788fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) {
789 let previous_allowed_domains = previous.network.allowed_domains().unwrap_or_default();
790 let next_allowed_domains = next.network.allowed_domains().unwrap_or_default();
791 log_domain_list_changes(
792 "allowlist",
793 &previous_allowed_domains,
794 &next_allowed_domains,
795 );
796 let previous_denied_domains = previous.network.denied_domains().unwrap_or_default();
797 let next_denied_domains = next.network.denied_domains().unwrap_or_default();
798 log_domain_list_changes("denylist", &previous_denied_domains, &next_denied_domains);
799}
800
801fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) {
802 let previous_set: HashSet<String> = previous
803 .iter()
804 .map(|entry| entry.to_ascii_lowercase())
805 .collect();
806 let next_set: HashSet<String> = next
807 .iter()
808 .map(|entry| entry.to_ascii_lowercase())
809 .collect();
810
811 let added = next_set
812 .difference(&previous_set)
813 .cloned()
814 .collect::<HashSet<_>>();
815 let removed = previous_set
816 .difference(&next_set)
817 .cloned()
818 .collect::<HashSet<_>>();
819
820 let mut seen_next = HashSet::new();
821 for entry in next {
822 let key = entry.to_ascii_lowercase();
823 if seen_next.insert(key.clone()) && added.contains(&key) {
824 info!("config entry added to {list_name}: {entry}");
825 }
826 }
827
828 let mut seen_previous = HashSet::new();
829 for entry in previous {
830 let key = entry.to_ascii_lowercase();
831 if seen_previous.insert(key.clone()) && removed.contains(&key) {
832 info!("config entry removed from {list_name}: {entry}");
833 }
834 }
835}
836
837fn globset_matches_host_or_unscoped(set: &GlobSet, host: &str) -> bool {
838 set.is_match(host) || unscoped_ip_literal(host).is_some_and(|ip| set.is_match(ip))
839}
840
841fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool {
842 let normalized_host = host.as_str();
843 let unscoped_host = unscoped_ip_literal(normalized_host);
844 allowed_domains.iter().any(|pattern| {
845 let pattern = pattern.trim();
846 if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") {
847 return false;
848 }
849 if pattern.contains('*') || pattern.contains('?') {
850 return false;
851 }
852 let normalized_pattern = normalize_host(pattern);
853 normalized_pattern == normalized_host
854 || unscoped_host.is_some_and(|ip| normalized_pattern == ip)
855 })
856}
857
858fn unix_timestamp() -> i64 {
859 OffsetDateTime::now_utc().unix_timestamp()
860}
861
862#[cfg(test)]
863pub(crate) fn network_proxy_state_for_policy(
864 mut network: crate::config::NetworkProxySettings,
865) -> NetworkProxyState {
866 network.enabled = true;
867 network.mode = NetworkMode::Full;
868 let config = NetworkProxyConfig { network };
869 let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
870
871 NetworkProxyState::with_reloader(state, Arc::new(NoopReloader))
872}
873
874#[cfg(test)]
875struct NoopReloader;
876
877#[cfg(test)]
878#[async_trait]
879impl ConfigReloader for NoopReloader {
880 fn source_label(&self) -> String {
881 "test config state".to_string()
882 }
883
884 async fn maybe_reload(&self) -> Result<Option<ConfigState>> {
885 Ok(None)
886 }
887
888 async fn reload_now(&self) -> Result<ConfigState> {
889 Err(anyhow::anyhow!("force reload is not supported in tests"))
890 }
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896
897 use crate::config::NetworkProxyConfig;
898 use crate::config::NetworkProxySettings;
899 use crate::policy::compile_allowlist_globset;
900 use crate::policy::compile_denylist_globset;
901 use crate::state::NetworkProxyConstraints;
902 use crate::state::build_config_state;
903 use crate::state::validate_policy_against_constraints;
904 use pretty_assertions::assert_eq;
905
906 fn strings(entries: &[&str]) -> Vec<String> {
907 entries.iter().map(|entry| (*entry).to_string()).collect()
908 }
909
910 fn network_settings(allowed_domains: &[&str], denied_domains: &[&str]) -> NetworkProxySettings {
911 let mut network = NetworkProxySettings::default();
912 if !allowed_domains.is_empty() {
913 network.set_allowed_domains(strings(allowed_domains));
914 }
915 if !denied_domains.is_empty() {
916 network.set_denied_domains(strings(denied_domains));
917 }
918 network
919 }
920
921 fn network_settings_with_unix_sockets(
922 allowed_domains: &[&str],
923 denied_domains: &[&str],
924 unix_sockets: &[String],
925 ) -> NetworkProxySettings {
926 let mut network = network_settings(allowed_domains, denied_domains);
927 if !unix_sockets.is_empty() {
928 network.set_allow_unix_sockets(unix_sockets.to_vec());
929 }
930 network
931 }
932
933 #[tokio::test]
934 async fn host_blocked_denied_wins_over_allowed() {
935 let state =
936 network_proxy_state_for_policy(network_settings(&["example.com"], &["example.com"]));
937
938 assert_eq!(
939 state
940 .host_blocked("example.com", 80)
941 .await
942 .unwrap(),
943 HostBlockDecision::Blocked(HostBlockReason::Denied)
944 );
945 }
946
947 #[tokio::test]
948 async fn host_blocked_requires_allowlist_match() {
949 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
950
951 assert_eq!(
952 state
953 .host_blocked("example.com", 80)
954 .await
955 .unwrap(),
956 HostBlockDecision::Allowed
957 );
958 assert_eq!(
959 state.host_blocked("8.8.8.8", 80).await.unwrap(),
962 HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
963 );
964 }
965
966 #[tokio::test]
967 async fn add_allowed_domain_removes_matching_deny_entry() {
968 let state = network_proxy_state_for_policy(network_settings(&[], &["example.com"]));
969
970 state.add_allowed_domain("ExAmPlE.CoM").await.unwrap();
971
972 let (allowed, denied) = state.current_patterns().await.unwrap();
973 assert_eq!(allowed, vec!["example.com".to_string()]);
974 assert!(denied.is_empty());
975 assert_eq!(
976 state
977 .host_blocked("example.com", 80)
978 .await
979 .unwrap(),
980 HostBlockDecision::Allowed
981 );
982 }
983
984 #[tokio::test]
985 async fn add_denied_domain_removes_matching_allow_entry() {
986 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
987
988 state.add_denied_domain("EXAMPLE.COM").await.unwrap();
989
990 let (allowed, denied) = state.current_patterns().await.unwrap();
991 assert!(allowed.is_empty());
992 assert_eq!(denied, vec!["example.com".to_string()]);
993 assert_eq!(
994 state
995 .host_blocked("example.com", 80)
996 .await
997 .unwrap(),
998 HostBlockDecision::Blocked(HostBlockReason::Denied)
999 );
1000 }
1001
1002 #[tokio::test]
1003 async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() {
1004 let state = network_proxy_state_for_policy(network_settings(&["*"], &[]));
1005
1006 assert_eq!(
1007 state.host_blocked("8.8.8.8", 80).await.unwrap(),
1009 HostBlockDecision::Allowed
1010 );
1011
1012 state.add_denied_domain("8.8.8.8").await.unwrap();
1013
1014 let (allowed, denied) = state.current_patterns().await.unwrap();
1015 assert_eq!(allowed, vec!["*".to_string()]);
1016 assert_eq!(denied, vec!["8.8.8.8".to_string()]);
1017 assert_eq!(
1018 state.host_blocked("8.8.8.8", 80).await.unwrap(),
1019 HostBlockDecision::Blocked(HostBlockReason::Denied)
1020 );
1021 }
1022
1023 #[tokio::test]
1024 async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() {
1025 let config = NetworkProxyConfig {
1026 network: {
1027 let mut network = network_settings(&["managed.example.com"], &[]);
1028 network.enabled = true;
1029 network
1030 },
1031 };
1032 let constraints = NetworkProxyConstraints {
1033 allowed_domains: Some(vec!["managed.example.com".to_string()]),
1034 allowlist_expansion_enabled: Some(true),
1035 ..NetworkProxyConstraints::default()
1036 };
1037 let state = NetworkProxyState::with_reloader(
1038 build_config_state(config, constraints).unwrap(),
1039 Arc::new(NoopReloader),
1040 );
1041
1042 state.add_allowed_domain("user.example.com").await.unwrap();
1043
1044 let (allowed, denied) = state.current_patterns().await.unwrap();
1045 assert_eq!(
1046 allowed,
1047 vec![
1048 "managed.example.com".to_string(),
1049 "user.example.com".to_string()
1050 ]
1051 );
1052 assert!(denied.is_empty());
1053 }
1054
1055 #[tokio::test]
1056 async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() {
1057 let config = NetworkProxyConfig {
1058 network: {
1059 let mut network = network_settings(&["managed.example.com"], &[]);
1060 network.enabled = true;
1061 network
1062 },
1063 };
1064 let constraints = NetworkProxyConstraints {
1065 allowed_domains: Some(vec!["managed.example.com".to_string()]),
1066 allowlist_expansion_enabled: Some(false),
1067 ..NetworkProxyConstraints::default()
1068 };
1069 let state = NetworkProxyState::with_reloader(
1070 build_config_state(config, constraints).unwrap(),
1071 Arc::new(NoopReloader),
1072 );
1073
1074 let err = state
1075 .add_allowed_domain("user.example.com")
1076 .await
1077 .expect_err("managed baseline should reject allowlist expansion");
1078
1079 assert!(
1080 format!("{err:#}").contains("network.allowed_domains constrained by managed config"),
1081 "unexpected error: {err:#}"
1082 );
1083 }
1084
1085 #[tokio::test]
1086 async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() {
1087 let config = NetworkProxyConfig {
1088 network: {
1089 let mut network = network_settings(&[], &["managed.example.com"]);
1090 network.enabled = true;
1091 network
1092 },
1093 };
1094 let constraints = NetworkProxyConstraints {
1095 denied_domains: Some(vec!["managed.example.com".to_string()]),
1096 denylist_expansion_enabled: Some(false),
1097 ..NetworkProxyConstraints::default()
1098 };
1099 let state = NetworkProxyState::with_reloader(
1100 build_config_state(config, constraints).unwrap(),
1101 Arc::new(NoopReloader),
1102 );
1103
1104 let err = state
1105 .add_denied_domain("user.example.com")
1106 .await
1107 .expect_err("managed baseline should reject denylist expansion");
1108
1109 assert!(
1110 format!("{err:#}").contains("network.denied_domains constrained by managed config"),
1111 "unexpected error: {err:#}"
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn blocked_snapshot_does_not_consume_entries() {
1117 let state = network_proxy_state_for_policy(NetworkProxySettings::default());
1118
1119 state
1120 .record_blocked(BlockedRequest::new(BlockedRequestArgs {
1121 host: "google.com".to_string(),
1122 reason: "not_allowed".to_string(),
1123 client: None,
1124 method: Some("GET".to_string()),
1125 mode: None,
1126 protocol: "http".to_string(),
1127 decision: Some("ask".to_string()),
1128 source: Some("decider".to_string()),
1129 port: Some(80),
1130 }))
1131 .await
1132 .expect("entry should be recorded");
1133
1134 let snapshot = state
1135 .blocked_snapshot()
1136 .await
1137 .expect("snapshot should succeed");
1138 assert_eq!(snapshot.len(), 1);
1139 assert_eq!(snapshot[0].host, "google.com");
1140 assert_eq!(snapshot[0].decision.as_deref(), Some("ask"));
1141
1142 let drained = state
1143 .drain_blocked()
1144 .await
1145 .expect("drain should include snapshot entry");
1146 assert_eq!(drained.len(), 1);
1147 assert_eq!(drained[0].host, snapshot[0].host);
1148 assert_eq!(drained[0].reason, snapshot[0].reason);
1149 assert_eq!(drained[0].decision, snapshot[0].decision);
1150 assert_eq!(drained[0].source, snapshot[0].source);
1151 assert_eq!(drained[0].port, snapshot[0].port);
1152 }
1153
1154 #[tokio::test]
1155 async fn drain_blocked_returns_buffered_window() {
1156 let state = network_proxy_state_for_policy(NetworkProxySettings::default());
1157
1158 for idx in 0..(MAX_BLOCKED_EVENTS + 5) {
1159 state
1160 .record_blocked(BlockedRequest::new(BlockedRequestArgs {
1161 host: format!("example{idx}.com"),
1162 reason: "not_allowed".to_string(),
1163 client: None,
1164 method: Some("GET".to_string()),
1165 mode: None,
1166 protocol: "http".to_string(),
1167 decision: Some("ask".to_string()),
1168 source: Some("decider".to_string()),
1169 port: Some(80),
1170 }))
1171 .await
1172 .expect("entry should be recorded");
1173 }
1174
1175 let blocked = state.drain_blocked().await.expect("drain should succeed");
1176 assert_eq!(blocked.len(), MAX_BLOCKED_EVENTS);
1177 assert_eq!(blocked[0].host, "example5.com");
1178 }
1179
1180 #[test]
1181 fn blocked_request_violation_log_line_serializes_payload() {
1182 let entry = BlockedRequest {
1183 host: "google.com".to_string(),
1184 reason: "not_allowed".to_string(),
1185 client: Some("127.0.0.1".to_string()),
1186 method: Some("GET".to_string()),
1187 mode: Some(NetworkMode::Full),
1188 protocol: "http".to_string(),
1189 decision: Some("ask".to_string()),
1190 source: Some("decider".to_string()),
1191 port: Some(80),
1192 timestamp: 1_735_689_600,
1193 };
1194
1195 assert_eq!(
1196 blocked_request_violation_log_line(&entry),
1197 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}"#
1198 );
1199 }
1200
1201 #[tokio::test]
1202 async fn host_blocked_subdomain_wildcards_exclude_apex() {
1203 let state = network_proxy_state_for_policy(network_settings(&["*.openai.com"], &[]));
1204
1205 assert_eq!(
1206 state
1207 .host_blocked("api.openai.com", 80)
1208 .await
1209 .unwrap(),
1210 HostBlockDecision::Allowed
1211 );
1212 assert_eq!(
1213 state.host_blocked("openai.com", 80).await.unwrap(),
1214 HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
1215 );
1216 }
1217
1218 #[tokio::test]
1219 async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() {
1220 let state = network_proxy_state_for_policy(network_settings(&["*"], &["evil.example"]));
1221
1222 assert_eq!(
1223 state
1224 .host_blocked("example.com", 80)
1225 .await
1226 .unwrap(),
1227 HostBlockDecision::Allowed
1228 );
1229 assert_eq!(
1230 state
1231 .host_blocked("api.openai.com", 443)
1232 .await
1233 .unwrap(),
1234 HostBlockDecision::Allowed
1235 );
1236 assert_eq!(
1237 state
1238 .host_blocked("evil.example", 80)
1239 .await
1240 .unwrap(),
1241 HostBlockDecision::Blocked(HostBlockReason::Denied)
1242 );
1243 }
1244
1245 #[tokio::test]
1246 async fn host_blocked_rejects_loopback_when_local_binding_disabled() {
1247 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1248
1249 assert_eq!(
1250 state.host_blocked("127.0.0.1", 80).await.unwrap(),
1251 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1252 );
1253 assert_eq!(
1254 state.host_blocked("localhost", 80).await.unwrap(),
1255 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() {
1261 let state = network_proxy_state_for_policy(network_settings(&["localhost"], &[]));
1262
1263 assert_eq!(
1264 state.host_blocked("localhost", 80).await.unwrap(),
1265 HostBlockDecision::Allowed
1266 );
1267 }
1268
1269 #[tokio::test]
1270 async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() {
1271 let state = network_proxy_state_for_policy(network_settings(&["10.0.0.1"], &[]));
1272
1273 assert_eq!(
1274 state.host_blocked("10.0.0.1", 80).await.unwrap(),
1275 HostBlockDecision::Allowed
1276 );
1277 }
1278
1279 #[tokio::test]
1280 async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() {
1281 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1282
1283 assert_eq!(
1284 state
1285 .host_blocked("fe80::1%lo0", 80)
1286 .await
1287 .unwrap(),
1288 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1289 );
1290 }
1291
1292 #[tokio::test]
1293 async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() {
1294 let state = network_proxy_state_for_policy(network_settings(&["fe80::1"], &[]));
1295
1296 assert_eq!(
1297 state
1298 .host_blocked("fe80::1%lo0", 80)
1299 .await
1300 .unwrap(),
1301 HostBlockDecision::Allowed
1302 );
1303 }
1304
1305 #[tokio::test]
1306 async fn host_blocked_requires_exact_scoped_ipv6_allowlist_match() {
1307 let state = network_proxy_state_for_policy(NetworkProxySettings {
1308 allow_local_binding: true,
1309 ..network_settings(&["fe80::1%eth0"], &[])
1310 });
1311
1312 assert_eq!(
1313 state
1314 .host_blocked("fe80::1%eth0", 80)
1315 .await
1316 .unwrap(),
1317 HostBlockDecision::Allowed
1318 );
1319 assert_eq!(
1320 state
1321 .host_blocked("fe80::1%eth1", 80)
1322 .await
1323 .unwrap(),
1324 HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
1325 );
1326 }
1327
1328 #[tokio::test]
1329 async fn host_blocked_denies_scoped_ipv6_literal_before_local_binding() {
1330 let state = network_proxy_state_for_policy(NetworkProxySettings {
1331 allow_local_binding: true,
1332 ..network_settings(&["*"], &["fd00::1"])
1333 });
1334
1335 for host in ["fd00::1%eth0", "[fd00::1%eth0]", "[fd00::1%25eth0]"] {
1336 assert_eq!(
1337 state.host_blocked(host, 80).await.unwrap(),
1338 HostBlockDecision::Blocked(HostBlockReason::Denied),
1339 "host should be denied after normalization: {host}"
1340 );
1341 }
1342 }
1343
1344 #[tokio::test]
1345 async fn host_blocked_requires_exact_scoped_ipv6_denylist_match() {
1346 let state = network_proxy_state_for_policy(NetworkProxySettings {
1347 allow_local_binding: true,
1348 ..network_settings(&["*"], &["fd00::1%eth0"])
1349 });
1350
1351 assert_eq!(
1352 state
1353 .host_blocked("fd00::1%eth0", 80)
1354 .await
1355 .unwrap(),
1356 HostBlockDecision::Blocked(HostBlockReason::Denied)
1357 );
1358 assert_eq!(
1359 state
1360 .host_blocked("fd00::1%eth1", 80)
1361 .await
1362 .unwrap(),
1363 HostBlockDecision::Allowed
1364 );
1365 }
1366
1367 #[tokio::test]
1368 async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() {
1369 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1370
1371 assert_eq!(
1372 state.host_blocked("10.0.0.1", 80).await.unwrap(),
1373 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1374 );
1375 }
1376
1377 #[tokio::test]
1378 async fn host_blocked_rejects_loopback_when_allowlist_empty() {
1379 let state = network_proxy_state_for_policy(NetworkProxySettings::default());
1380
1381 assert_eq!(
1382 state.host_blocked("127.0.0.1", 80).await.unwrap(),
1383 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1384 );
1385 }
1386
1387 #[tokio::test]
1388 async fn host_blocked_rejects_allowlisted_hostname_when_dns_lookup_fails() {
1389 let mut network = NetworkProxySettings::default();
1390 network.set_allowed_domains(vec!["does-not-resolve.invalid".to_string()]);
1391 let state = network_proxy_state_for_policy(network);
1392
1393 assert_eq!(
1394 state
1395 .host_blocked("does-not-resolve.invalid", 80)
1396 .await
1397 .unwrap(),
1398 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1399 );
1400 }
1401
1402 #[tokio::test]
1403 async fn host_resolves_to_non_public_ip_blocks_on_dns_lookup_timeout() {
1404 let blocked = host_resolves_to_non_public_ip(
1405 "slow.example",
1406 80,
1407 Duration::from_millis(1),
1408 |_host, _port| async {
1409 std::future::pending::<std::io::Result<Vec<SocketAddr>>>().await
1410 },
1411 )
1412 .await;
1413
1414 assert!(blocked);
1415 }
1416
1417 #[tokio::test]
1418 async fn host_resolves_to_non_public_ip_blocks_on_dns_lookup_error() {
1419 let blocked = host_resolves_to_non_public_ip(
1420 "error.example",
1421 80,
1422 Duration::from_millis(10),
1423 |_host, _port| async {
1424 Err::<Vec<SocketAddr>, std::io::Error>(std::io::Error::new(
1425 std::io::ErrorKind::TimedOut,
1426 "forced failure",
1427 ))
1428 },
1429 )
1430 .await;
1431
1432 assert!(blocked);
1433 }
1434
1435 #[tokio::test]
1436 async fn host_resolves_to_non_public_ip_blocks_private_resolution() {
1437 let blocked = host_resolves_to_non_public_ip(
1438 "local.example",
1439 80,
1440 Duration::from_millis(10),
1441 |_host, _port| async { Ok(vec!["127.0.0.1:80".parse().unwrap()]) },
1442 )
1443 .await;
1444
1445 assert!(blocked);
1446 }
1447
1448 #[tokio::test]
1449 async fn host_resolves_to_non_public_ip_allows_public_resolution() {
1450 let blocked = host_resolves_to_non_public_ip(
1451 "public.example",
1452 80,
1453 Duration::from_millis(10),
1454 |_host, _port| async { Ok(vec!["8.8.8.8:80".parse().unwrap()]) },
1455 )
1456 .await;
1457
1458 assert!(!blocked);
1459 }
1460
1461 #[test]
1462 fn validate_policy_against_constraints_disallows_widening_allowed_domains() {
1463 let constraints = NetworkProxyConstraints {
1464 allowed_domains: Some(vec!["example.com".to_string()]),
1465 ..NetworkProxyConstraints::default()
1466 };
1467
1468 let config = NetworkProxyConfig {
1469 network: {
1470 let mut network = network_settings(&["example.com", "evil.com"], &[]);
1471 network.enabled = true;
1472 network
1473 },
1474 };
1475
1476 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1477 }
1478
1479 #[test]
1480 fn validate_policy_against_constraints_allows_expanding_allowed_domains_when_enabled() {
1481 let constraints = NetworkProxyConstraints {
1482 allowed_domains: Some(vec!["example.com".to_string()]),
1483 allowlist_expansion_enabled: Some(true),
1484 ..NetworkProxyConstraints::default()
1485 };
1486
1487 let config = NetworkProxyConfig {
1488 network: {
1489 let mut network = network_settings(&["example.com", "api.openai.com"], &[]);
1490 network.enabled = true;
1491 network
1492 },
1493 };
1494
1495 assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1496 }
1497
1498 #[test]
1499 fn validate_policy_against_constraints_disallows_widening_mode() {
1500 let constraints = NetworkProxyConstraints {
1501 mode: Some(NetworkMode::Limited),
1502 ..NetworkProxyConstraints::default()
1503 };
1504
1505 let config = NetworkProxyConfig {
1506 network: NetworkProxySettings {
1507 enabled: true,
1508 mode: NetworkMode::Full,
1509 ..NetworkProxySettings::default()
1510 },
1511 };
1512
1513 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1514 }
1515
1516 #[test]
1517 fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() {
1518 let constraints = NetworkProxyConstraints {
1519 allowed_domains: Some(vec!["*.example.com".to_string()]),
1520 ..NetworkProxyConstraints::default()
1521 };
1522
1523 let config = NetworkProxyConfig {
1524 network: {
1525 let mut network = network_settings(&["api.example.com"], &[]);
1526 network.enabled = true;
1527 network
1528 },
1529 };
1530
1531 assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1532 }
1533
1534 #[test]
1535 fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() {
1536 let constraints = NetworkProxyConstraints {
1537 allowed_domains: Some(vec!["*.example.com".to_string()]),
1538 ..NetworkProxyConstraints::default()
1539 };
1540
1541 let config = NetworkProxyConfig {
1542 network: {
1543 let mut network = network_settings(&["**.example.com"], &[]);
1544 network.enabled = true;
1545 network
1546 },
1547 };
1548
1549 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1550 }
1551
1552 #[test]
1553 fn validate_policy_against_constraints_rejects_global_wildcard_in_managed_allowlist() {
1554 let constraints = NetworkProxyConstraints {
1555 allowed_domains: Some(vec!["*".to_string()]),
1556 ..NetworkProxyConstraints::default()
1557 };
1558
1559 let config = NetworkProxyConfig {
1560 network: {
1561 let mut network = network_settings(&["api.example.com"], &[]);
1562 network.enabled = true;
1563 network
1564 },
1565 };
1566
1567 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1568 }
1569
1570 #[test]
1571 fn validate_policy_against_constraints_rejects_bracketed_global_wildcard_in_managed_allowlist()
1572 {
1573 let constraints = NetworkProxyConstraints {
1574 allowed_domains: Some(vec!["[*]".to_string()]),
1575 ..NetworkProxyConstraints::default()
1576 };
1577
1578 let config = NetworkProxyConfig {
1579 network: {
1580 let mut network = network_settings(&["api.example.com"], &[]);
1581 network.enabled = true;
1582 network
1583 },
1584 };
1585
1586 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1587 }
1588
1589 #[test]
1590 fn validate_policy_against_constraints_rejects_double_wildcard_bracketed_global_wildcard_in_managed_allowlist()
1591 {
1592 let constraints = NetworkProxyConstraints {
1593 allowed_domains: Some(vec!["**.[*]".to_string()]),
1594 ..NetworkProxyConstraints::default()
1595 };
1596
1597 let config = NetworkProxyConfig {
1598 network: {
1599 let mut network = network_settings(&["api.example.com"], &[]);
1600 network.enabled = true;
1601 network
1602 },
1603 };
1604
1605 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1606 }
1607
1608 #[test]
1609 fn validate_policy_against_constraints_requires_managed_denied_domains_entries() {
1610 let constraints = NetworkProxyConstraints {
1611 denied_domains: Some(vec!["evil.com".to_string()]),
1612 ..NetworkProxyConstraints::default()
1613 };
1614
1615 let config = NetworkProxyConfig {
1616 network: NetworkProxySettings {
1617 enabled: true,
1618 ..NetworkProxySettings::default()
1619 },
1620 };
1621
1622 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1623 }
1624
1625 #[test]
1626 fn validate_policy_against_constraints_disallows_expanding_denied_domains_when_fixed() {
1627 let constraints = NetworkProxyConstraints {
1628 denied_domains: Some(vec!["evil.com".to_string()]),
1629 denylist_expansion_enabled: Some(false),
1630 ..NetworkProxyConstraints::default()
1631 };
1632
1633 let config = NetworkProxyConfig {
1634 network: {
1635 let mut network = network_settings(&[], &["evil.com", "more-evil.com"]);
1636 network.enabled = true;
1637 network
1638 },
1639 };
1640
1641 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1642 }
1643
1644 #[test]
1645 fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() {
1646 let constraints = NetworkProxyConstraints {
1647 enabled: Some(false),
1648 ..NetworkProxyConstraints::default()
1649 };
1650
1651 let config = NetworkProxyConfig {
1652 network: NetworkProxySettings {
1653 enabled: true,
1654 ..NetworkProxySettings::default()
1655 },
1656 };
1657
1658 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1659 }
1660
1661 #[test]
1662 fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() {
1663 let constraints = NetworkProxyConstraints {
1664 allow_local_binding: Some(false),
1665 ..NetworkProxyConstraints::default()
1666 };
1667
1668 let config = NetworkProxyConfig {
1669 network: NetworkProxySettings {
1670 enabled: true,
1671 allow_local_binding: true,
1672 ..NetworkProxySettings::default()
1673 },
1674 };
1675
1676 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1677 }
1678
1679 #[test]
1680 fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_without_managed_opt_in()
1681 {
1682 let constraints = NetworkProxyConstraints {
1683 dangerously_allow_all_unix_sockets: Some(false),
1684 ..NetworkProxyConstraints::default()
1685 };
1686
1687 let config = NetworkProxyConfig {
1688 network: NetworkProxySettings {
1689 enabled: true,
1690 dangerously_allow_all_unix_sockets: true,
1691 ..NetworkProxySettings::default()
1692 },
1693 };
1694
1695 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1696 }
1697
1698 #[test]
1699 fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_when_allowlist_is_managed()
1700 {
1701 let constraints = NetworkProxyConstraints {
1702 allow_unix_sockets: Some(vec!["/tmp/allowed.sock".to_string()]),
1703 ..NetworkProxyConstraints::default()
1704 };
1705
1706 let config = NetworkProxyConfig {
1707 network: NetworkProxySettings {
1708 enabled: true,
1709 dangerously_allow_all_unix_sockets: true,
1710 ..NetworkProxySettings::default()
1711 },
1712 };
1713
1714 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1715 }
1716
1717 #[test]
1718 fn validate_policy_against_constraints_allows_allow_all_unix_sockets_with_managed_opt_in() {
1719 let constraints = NetworkProxyConstraints {
1720 dangerously_allow_all_unix_sockets: Some(true),
1721 ..NetworkProxyConstraints::default()
1722 };
1723
1724 let config = NetworkProxyConfig {
1725 network: NetworkProxySettings {
1726 enabled: true,
1727 dangerously_allow_all_unix_sockets: true,
1728 ..NetworkProxySettings::default()
1729 },
1730 };
1731
1732 assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1733 }
1734
1735 #[test]
1736 fn validate_policy_against_constraints_allows_allow_all_unix_sockets_when_unmanaged() {
1737 let constraints = NetworkProxyConstraints::default();
1738
1739 let config = NetworkProxyConfig {
1740 network: NetworkProxySettings {
1741 enabled: true,
1742 dangerously_allow_all_unix_sockets: true,
1743 ..NetworkProxySettings::default()
1744 },
1745 };
1746
1747 assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1748 }
1749
1750 #[test]
1751 fn compile_globset_is_case_insensitive() {
1752 let patterns = vec!["ExAmPle.CoM".to_string()];
1753 let set = compile_denylist_globset(&patterns).unwrap();
1754 assert!(set.is_match("example.com"));
1755 assert!(set.is_match("EXAMPLE.COM"));
1756 }
1757
1758 #[test]
1759 fn compile_globset_excludes_apex_for_subdomain_patterns() {
1760 let patterns = vec!["*.openai.com".to_string()];
1761 let set = compile_denylist_globset(&patterns).unwrap();
1762 assert!(set.is_match("api.openai.com"));
1763 assert!(!set.is_match("openai.com"));
1764 assert!(!set.is_match("evilopenai.com"));
1765 }
1766
1767 #[test]
1768 fn compile_globset_includes_apex_for_double_wildcard_patterns() {
1769 let patterns = vec!["**.openai.com".to_string()];
1770 let set = compile_denylist_globset(&patterns).unwrap();
1771 assert!(set.is_match("openai.com"));
1772 assert!(set.is_match("api.openai.com"));
1773 assert!(!set.is_match("evilopenai.com"));
1774 }
1775
1776 #[test]
1777 fn compile_globset_rejects_global_wildcard() {
1778 let patterns = vec!["*".to_string()];
1779 assert!(compile_denylist_globset(&patterns).is_err());
1780 }
1781
1782 #[test]
1783 fn compile_globset_allows_global_wildcard_when_enabled() {
1784 let patterns = vec!["*".to_string()];
1785 let set = compile_allowlist_globset(&patterns).unwrap();
1786 assert!(set.is_match("example.com"));
1787 assert!(set.is_match("api.openai.com"));
1788 assert!(set.is_match("localhost"));
1789 }
1790
1791 #[test]
1792 fn compile_globset_rejects_bracketed_global_wildcard() {
1793 let patterns = vec!["[*]".to_string()];
1794 assert!(compile_denylist_globset(&patterns).is_err());
1795 }
1796
1797 #[test]
1798 fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() {
1799 let patterns = vec!["**.[*]".to_string()];
1800 assert!(compile_denylist_globset(&patterns).is_err());
1801 }
1802
1803 #[test]
1804 fn compile_globset_dedupes_patterns_without_changing_behavior() {
1805 let patterns = vec!["example.com".to_string(), "example.com".to_string()];
1806 let set = compile_denylist_globset(&patterns).unwrap();
1807 assert!(set.is_match("example.com"));
1808 assert!(set.is_match("EXAMPLE.COM"));
1809 assert!(!set.is_match("not-example.com"));
1810 }
1811
1812 #[test]
1813 fn compile_globset_rejects_invalid_patterns() {
1814 let patterns = vec!["[".to_string()];
1815 assert!(compile_denylist_globset(&patterns).is_err());
1816 }
1817
1818 #[test]
1819 fn build_config_state_allows_global_wildcard_allowed_domains() {
1820 let config = NetworkProxyConfig {
1821 network: {
1822 let mut network = network_settings(&["*"], &[]);
1823 network.enabled = true;
1824 network
1825 },
1826 };
1827
1828 assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok());
1829 }
1830
1831 #[test]
1832 fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() {
1833 let config = NetworkProxyConfig {
1834 network: {
1835 let mut network = network_settings(&["[*]"], &[]);
1836 network.enabled = true;
1837 network
1838 },
1839 };
1840
1841 assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok());
1842 }
1843
1844 #[test]
1845 fn build_config_state_rejects_global_wildcard_denied_domains() {
1846 let config = NetworkProxyConfig {
1847 network: {
1848 let mut network = network_settings(&["example.com"], &["*"]);
1849 network.enabled = true;
1850 network
1851 },
1852 };
1853
1854 assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
1855 }
1856
1857 #[test]
1858 fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() {
1859 let config = NetworkProxyConfig {
1860 network: {
1861 let mut network = network_settings(&["example.com"], &["[*]"]);
1862 network.enabled = true;
1863 network
1864 },
1865 };
1866
1867 assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
1868 }
1869
1870 #[cfg(target_os = "macos")]
1871 #[tokio::test]
1872 async fn unix_socket_allowlist_is_respected_on_macos() {
1873 let socket_path = "/tmp/example.sock".to_string();
1874 let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
1875 &["example.com"],
1876 &[],
1877 std::slice::from_ref(&socket_path),
1878 ));
1879
1880 assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap());
1881 assert!(
1882 !state
1883 .is_unix_socket_allowed("/tmp/not-allowed.sock")
1884 .await
1885 .unwrap()
1886 );
1887 }
1888
1889 #[cfg(target_os = "macos")]
1890 #[tokio::test]
1891 async fn unix_socket_allowlist_resolves_symlinks() {
1892 use std::os::unix::fs::symlink;
1893 use tempfile::tempdir;
1894
1895 let temp_dir = tempdir().unwrap();
1896 let dir = temp_dir.path();
1897
1898 let real = dir.join("real.sock");
1899 let link = dir.join("link.sock");
1900
1901 std::fs::write(&real, b"not a socket").unwrap();
1904 symlink(&real, &link).unwrap();
1905
1906 let real_s = real.to_str().unwrap().to_string();
1907 let link_s = link.to_str().unwrap().to_string();
1908
1909 let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
1910 &["example.com"],
1911 &[],
1912 std::slice::from_ref(&real_s),
1913 ));
1914
1915 assert!(state.is_unix_socket_allowed(&link_s).await.unwrap());
1916 }
1917
1918 #[cfg(target_os = "macos")]
1919 #[tokio::test]
1920 async fn unix_socket_allow_all_flag_bypasses_allowlist() {
1921 let state = network_proxy_state_for_policy({
1922 let mut network = network_settings(&["example.com"], &[]);
1923 network.dangerously_allow_all_unix_sockets = true;
1924 network
1925 });
1926
1927 assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap());
1928 assert!(!state.is_unix_socket_allowed("relative.sock").await.unwrap());
1929 }
1930
1931 #[cfg(not(target_os = "macos"))]
1932 #[tokio::test]
1933 async fn unix_socket_allowlist_is_rejected_on_non_macos() {
1934 let socket_path = "/tmp/example.sock".to_string();
1935 let state = network_proxy_state_for_policy({
1936 let mut network = network_settings_with_unix_sockets(
1937 &["example.com"],
1938 &[],
1939 std::slice::from_ref(&socket_path),
1940 );
1941 network.dangerously_allow_all_unix_sockets = true;
1942 network
1943 });
1944
1945 assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap());
1946 }
1947}