Skip to main content

cageforge_policy/
network.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Domain, resolved-address, and Unix-socket network policy.
4//!
5//! [`crate::NetworkPolicy`] provides declarative queries and the safe
6//! [`crate::NetworkPolicy::authorize_connection`] handoff. The latter consumes
7//! a [`crate::ResolvedNetworkTarget`] and returns a non-copyable
8//! [`crate::AuthorizedSocketAddr`] so the backend can connect only to the
9//! checked address.
10
11use crate::PolicyError;
12use crate::{LocalIpcEndpoint, PathSelector};
13use cageforge_path::{NativePathKey, paths_equal};
14use globset::GlobBuilder;
15use globset::GlobMatcher;
16use std::collections::{HashMap, HashSet};
17use std::hash::{Hash, Hasher};
18use std::net::{IpAddr, Ipv4Addr, SocketAddr};
19use std::path::Path;
20use std::path::PathBuf;
21
22mod model;
23
24use model::DomainMatcher;
25pub use model::{
26    AuthorizedSocketAddr, ConnectionAuthorization, DomainAccess, DomainMode, DomainRule,
27    LocalIpcRule, LocalNetworkAccess, NetworkDecision, NetworkMode, NetworkPolicy,
28    ResolvedNetworkTarget, UnixSocketMode, UnixSocketRule,
29};
30
31impl NetworkDecision {
32    pub(crate) const fn is_allowed(self) -> bool {
33        matches!(self, Self::Allow)
34    }
35
36    /// Returns whether another trusted component owns enforcement.
37    pub const fn is_externally_enforced(self) -> bool {
38        matches!(self, Self::ExternallyEnforced)
39    }
40}
41
42impl AuthorizedSocketAddr {
43    /// Consumes the authorization and returns the exact checked address.
44    ///
45    /// The value is intentionally neither `Copy` nor `Clone`: an adapter
46    /// should hand it directly to its connection operation instead of keeping
47    /// a reusable authorization token.
48    ///
49    /// ```compile_fail
50    /// use cageforge_policy::AuthorizedSocketAddr;
51    ///
52    /// fn require_clone<T: Clone>() {}
53    /// require_clone::<AuthorizedSocketAddr>();
54    /// ```
55    pub const fn into_socket_addr(self) -> SocketAddr {
56        self.0
57    }
58}
59
60impl ResolvedNetworkTarget {
61    /// Creates a target from a host and the addresses resolved for it.
62    ///
63    /// An empty address list represents failed or timed-out resolution and is
64    /// retained so policy evaluation can fail closed with `Deny`.
65    pub fn new(
66        domain: impl Into<String>,
67        addresses: impl IntoIterator<Item = SocketAddr>,
68    ) -> Result<Self, PolicyError> {
69        let domain = normalize_domain(&domain.into())?;
70        let literal = parse_ip_literal(&domain);
71        let mut seen = HashSet::new();
72        let mut unique_addresses = Vec::new();
73        for address in addresses {
74            if literal.is_some_and(|literal| literal != address.ip()) {
75                return Err(PolicyError::ResolvedAddressMismatch {
76                    literal: domain,
77                    address,
78                });
79            }
80            if seen.insert(address) {
81                unique_addresses.push(address);
82            }
83        }
84        Ok(Self {
85            domain,
86            addresses: unique_addresses,
87        })
88    }
89
90    /// Returns the normalized host used for policy matching.
91    pub fn domain(&self) -> &str {
92        &self.domain
93    }
94
95    /// Returns the exact addresses captured for this resolution attempt.
96    pub fn addresses(&self) -> &[SocketAddr] {
97        &self.addresses
98    }
99
100    /// Returns whether an actual connection address belongs to this snapshot.
101    pub fn contains_address(&self, address: SocketAddr) -> bool {
102        self.addresses.contains(&address)
103    }
104}
105
106impl PartialEq for DomainRule {
107    fn eq(&self, other: &Self) -> bool {
108        self.pattern == other.pattern && self.access == other.access
109    }
110}
111
112impl Eq for DomainRule {}
113
114impl Hash for DomainRule {
115    fn hash<H: Hasher>(&self, state: &mut H) {
116        self.pattern.hash(state);
117        self.access.hash(state);
118    }
119}
120
121impl DomainRule {
122    /// Creates and validates a domain rule.
123    pub fn new(pattern: impl Into<String>, access: DomainAccess) -> Result<Self, PolicyError> {
124        let pattern = normalize_domain_pattern(&pattern.into())?;
125        let matcher = compile_domain_matcher(&pattern)?;
126        Ok(Self {
127            pattern,
128            access,
129            matcher,
130        })
131    }
132
133    /// Returns the normalized pattern.
134    pub fn pattern(&self) -> &str {
135        &self.pattern
136    }
137
138    /// Returns the access decision.
139    pub const fn access(&self) -> DomainAccess {
140        self.access
141    }
142
143    fn matches(&self, domain: &str) -> bool {
144        match &self.matcher {
145            DomainMatcher::Any => true,
146            DomainMatcher::Full(matcher) => matcher.is_match(domain),
147            DomainMatcher::Suffix {
148                labels,
149                include_apex,
150            } => {
151                let domain_label_count = domain.split('.').count();
152                if domain_label_count < labels.len()
153                    || (!include_apex && domain_label_count == labels.len())
154                {
155                    return false;
156                }
157                domain
158                    .rsplit('.')
159                    .zip(labels.iter().rev())
160                    .all(|(domain, matcher)| matcher.is_match(domain))
161            }
162        }
163    }
164}
165
166impl PartialEq for UnixSocketRule {
167    fn eq(&self, other: &Self) -> bool {
168        NativePathKey::new(&self.path) == NativePathKey::new(&other.path)
169            && self.access == other.access
170    }
171}
172
173impl Eq for UnixSocketRule {}
174
175impl Hash for UnixSocketRule {
176    fn hash<H: Hasher>(&self, state: &mut H) {
177        NativePathKey::new(&self.path).hash(state);
178        self.access.hash(state);
179    }
180}
181
182impl UnixSocketRule {
183    /// Creates an absolute Unix socket rule.
184    pub fn new(path: impl Into<PathBuf>, access: DomainAccess) -> Result<Self, PolicyError> {
185        let path = path.into();
186        PathSelector::absolute(path.clone())?;
187        Ok(Self { path, access })
188    }
189
190    /// Returns the socket path.
191    pub fn path(&self) -> &std::path::Path {
192        &self.path
193    }
194
195    /// Returns the access decision.
196    pub const fn access(&self) -> DomainAccess {
197        self.access
198    }
199}
200
201impl LocalIpcRule {
202    /// Creates a validated local-IPC rule.
203    pub fn new(endpoint: LocalIpcEndpoint, access: DomainAccess) -> Result<Self, PolicyError> {
204        endpoint.validate()?;
205        Ok(Self { endpoint, access })
206    }
207
208    /// Returns the typed endpoint.
209    pub fn endpoint(&self) -> &LocalIpcEndpoint {
210        &self.endpoint
211    }
212
213    /// Returns the access decision.
214    pub const fn access(&self) -> DomainAccess {
215        self.access
216    }
217}
218
219impl NetworkPolicy {
220    /// Creates a policy with command networking disabled.
221    pub const fn disabled() -> Self {
222        Self {
223            mode: NetworkMode::Disabled,
224            domain_mode: DomainMode::Disabled,
225            unix_socket_mode: UnixSocketMode::Disabled,
226            local_network_access: LocalNetworkAccess::Deny,
227            domains: Vec::new(),
228            unix_sockets: Vec::new(),
229            local_ipc: Vec::new(),
230        }
231    }
232
233    /// Creates a policy with IP networking enabled and pathname Unix sockets
234    /// disabled.
235    ///
236    /// Call [`Self::with_unix_socket_mode`] explicitly when pathname Unix
237    /// sockets should use an allowlist or be unrestricted.
238    pub const fn enabled() -> Self {
239        Self {
240            mode: NetworkMode::Enabled,
241            domain_mode: DomainMode::Enabled,
242            unix_socket_mode: UnixSocketMode::Disabled,
243            local_network_access: LocalNetworkAccess::Deny,
244            domains: Vec::new(),
245            unix_sockets: Vec::new(),
246            local_ipc: Vec::new(),
247        }
248    }
249
250    /// Creates a network policy with no local restrictions.
251    pub const fn unrestricted() -> Self {
252        Self {
253            mode: NetworkMode::Enabled,
254            domain_mode: DomainMode::Enabled,
255            unix_socket_mode: UnixSocketMode::Enabled,
256            local_network_access: LocalNetworkAccess::Allow,
257            domains: Vec::new(),
258            unix_sockets: Vec::new(),
259            local_ipc: Vec::new(),
260        }
261    }
262
263    /// Creates a policy whose network boundary is owned externally.
264    pub const fn external() -> Self {
265        Self {
266            mode: NetworkMode::External,
267            domain_mode: DomainMode::Disabled,
268            unix_socket_mode: UnixSocketMode::Disabled,
269            local_network_access: LocalNetworkAccess::Deny,
270            domains: Vec::new(),
271            unix_sockets: Vec::new(),
272            local_ipc: Vec::new(),
273        }
274    }
275
276    /// Returns the enforcement mode.
277    pub const fn mode(&self) -> NetworkMode {
278        self.mode
279    }
280
281    /// Returns the default behavior for unmatched domains.
282    pub const fn domain_mode(&self) -> DomainMode {
283        self.domain_mode
284    }
285
286    /// Returns the default behavior for unmatched Unix socket paths.
287    pub const fn unix_socket_mode(&self) -> UnixSocketMode {
288        self.unix_socket_mode
289    }
290
291    /// Returns the policy for non-public IP addresses reached through domains.
292    pub const fn local_network_access(&self) -> LocalNetworkAccess {
293        self.local_network_access
294    }
295
296    /// Sets the default behavior for unmatched domains.
297    pub const fn with_domain_mode(mut self, mode: DomainMode) -> Self {
298        self.domain_mode = mode;
299        self
300    }
301
302    /// Sets the default behavior for unmatched Unix socket paths.
303    pub const fn with_unix_socket_mode(mut self, mode: UnixSocketMode) -> Self {
304        self.unix_socket_mode = mode;
305        self
306    }
307
308    /// Sets whether resolved non-public IP addresses may be reached.
309    pub const fn with_local_network_access(mut self, access: LocalNetworkAccess) -> Self {
310        self.local_network_access = access;
311        self
312    }
313
314    /// Returns domain rules in declaration order.
315    pub fn domains(&self) -> &[DomainRule] {
316        &self.domains
317    }
318
319    /// Returns Unix socket rules in declaration order.
320    pub fn unix_sockets(&self) -> &[UnixSocketRule] {
321        &self.unix_sockets
322    }
323
324    /// Returns typed local-IPC rules in declaration order.
325    pub fn local_ipc(&self) -> &[LocalIpcRule] {
326        &self.local_ipc
327    }
328
329    /// Adds a domain rule.
330    pub fn with_domain(
331        mut self,
332        pattern: impl Into<String>,
333        access: DomainAccess,
334    ) -> Result<Self, PolicyError> {
335        if self.mode == NetworkMode::External {
336            return Err(PolicyError::InvalidRule {
337                message: "network rules cannot be added to an external policy".to_string(),
338            });
339        }
340        self.domains.push(DomainRule::new(pattern, access)?);
341        Ok(self)
342    }
343
344    /// Adds a Unix socket rule.
345    ///
346    /// Rules are evaluated according to [`Self::unix_socket_mode`]. In
347    /// particular, a policy whose socket mode is [`UnixSocketMode::Disabled`]
348    /// remains deny-all even when it carries rules.
349    pub fn with_unix_socket(
350        mut self,
351        path: impl Into<PathBuf>,
352        access: DomainAccess,
353    ) -> Result<Self, PolicyError> {
354        if self.mode == NetworkMode::External {
355            return Err(PolicyError::InvalidRule {
356                message: "network rules cannot be added to an external policy".to_string(),
357            });
358        }
359        self.unix_sockets.push(UnixSocketRule::new(path, access)?);
360        Ok(self)
361    }
362
363    /// Adds a typed local-IPC rule.
364    pub fn with_local_ipc(
365        mut self,
366        endpoint: LocalIpcEndpoint,
367        access: DomainAccess,
368    ) -> Result<Self, PolicyError> {
369        if self.mode == NetworkMode::External {
370            return Err(PolicyError::InvalidRule {
371                message: "local-IPC rules cannot be added to an external policy".to_owned(),
372            });
373        }
374        if self
375            .local_ipc
376            .iter()
377            .any(|rule| rule.endpoint() == &endpoint)
378        {
379            let endpoint_value = endpoint
380                .unix_path()
381                .map(|path| format!("unix:{}", path.display()))
382                .or_else(|| endpoint.named_pipe().map(|name| format!("pipe:{name}")))
383                .unwrap_or_else(|| "local-ipc".to_owned());
384            return Err(PolicyError::InvalidLocalIpcEndpoint {
385                endpoint: endpoint_value,
386                reason: "duplicate local-IPC endpoint",
387            });
388        }
389        let rule = LocalIpcRule::new(endpoint.clone(), access)?;
390        if let Some(path) = endpoint.unix_path() {
391            // Unix sockets are lowered through the network namespace on
392            // POSIX backends, so a typed Unix endpoint opts into the network
393            // boundary when no explicit mode was supplied. Windows named
394            // pipes are kernel objects rather than network sockets and must
395            // remain compatible with NetworkMode::Disabled.
396            if self.mode == NetworkMode::Disabled {
397                self.mode = NetworkMode::Enabled;
398            }
399            if self.unix_socket_mode == UnixSocketMode::Disabled {
400                self.unix_socket_mode = UnixSocketMode::Restricted;
401            }
402            // `LocalIpcEndpoint::UnixSocket` has already validated the path
403            // using the POSIX endpoint grammar. Do not run it through the
404            // host-native `PathSelector` again: a Windows host must be able
405            // to resolve a Linux/macOS platform overlay without interpreting
406            // `/run/service.sock` as a Windows path.
407            self.unix_sockets.push(UnixSocketRule {
408                path: path.to_path_buf(),
409                access,
410            });
411        }
412        self.local_ipc.push(rule);
413        Ok(self)
414    }
415
416    /// Validates that an externally enforced mode has no local rules.
417    pub fn validate(&self) -> Result<(), PolicyError> {
418        if self.mode == NetworkMode::External
419            && (self.domain_mode != DomainMode::Disabled
420                || self.unix_socket_mode != UnixSocketMode::Disabled
421                || self.local_network_access != LocalNetworkAccess::Deny
422                || !self.domains.is_empty()
423                || !self.unix_sockets.is_empty()
424                || !self.local_ipc.is_empty())
425        {
426            return Err(PolicyError::InvalidRule {
427                message: "external network policies cannot contain local settings".to_string(),
428            });
429        }
430        Ok(())
431    }
432
433    /// Returns a policy with semantically duplicate rules collapsed using
434    /// deny precedence.
435    pub fn normalized(&self) -> Result<Self, PolicyError> {
436        self.validate()?;
437        let mut domains: Vec<DomainRule> = Vec::with_capacity(self.domains.len());
438        let mut domain_positions: HashMap<String, usize> =
439            HashMap::with_capacity(self.domains.len());
440        for rule in &self.domains {
441            if let Some(&index) = domain_positions.get(rule.pattern()) {
442                if rule.access() == DomainAccess::Deny {
443                    domains[index].access = DomainAccess::Deny;
444                }
445            } else {
446                domain_positions.insert(rule.pattern().to_owned(), domains.len());
447                domains.push(rule.clone());
448            }
449        }
450
451        let mut unix_sockets: Vec<UnixSocketRule> = Vec::with_capacity(self.unix_sockets.len());
452        let mut socket_positions: HashMap<NativePathKey, usize> =
453            HashMap::with_capacity(self.unix_sockets.len());
454        for rule in &self.unix_sockets {
455            let key = NativePathKey::new(rule.path());
456            if let Some(&index) = socket_positions.get(&key) {
457                if rule.access() == DomainAccess::Deny {
458                    unix_sockets[index].access = DomainAccess::Deny;
459                }
460            } else {
461                socket_positions.insert(key, unix_sockets.len());
462                unix_sockets.push(rule.clone());
463            }
464        }
465
466        let mut local_ipc: Vec<LocalIpcRule> = Vec::with_capacity(self.local_ipc.len());
467        let mut local_ipc_positions: HashMap<LocalIpcEndpoint, usize> =
468            HashMap::with_capacity(self.local_ipc.len());
469        for rule in &self.local_ipc {
470            if let Some(&index) = local_ipc_positions.get(rule.endpoint()) {
471                if rule.access() == DomainAccess::Deny {
472                    local_ipc[index].access = DomainAccess::Deny;
473                }
474            } else {
475                local_ipc_positions.insert(rule.endpoint().clone(), local_ipc.len());
476                local_ipc.push(rule.clone());
477            }
478        }
479
480        Ok(Self {
481            mode: self.mode,
482            domain_mode: self.domain_mode,
483            unix_socket_mode: self.unix_socket_mode,
484            local_network_access: self.local_network_access,
485            domains,
486            unix_sockets,
487            local_ipc,
488        })
489    }
490
491    /// Evaluates a domain against the complete network policy.
492    ///
493    /// Returns the policy result for a hostname without authorizing a socket
494    /// connection. Use a [`ResolvedNetworkTarget`] and the exact connected
495    /// address methods for connection checks.
496    pub fn decision_for_domain(&self, domain: &str) -> Result<NetworkDecision, PolicyError> {
497        let domain = normalize_domain(domain)?;
498        match self.mode {
499            NetworkMode::Disabled => Ok(NetworkDecision::Deny),
500            NetworkMode::External => Ok(NetworkDecision::ExternallyEnforced),
501            NetworkMode::Enabled => match self.domain_mode {
502                DomainMode::Disabled => Ok(NetworkDecision::Deny),
503                DomainMode::Enabled => Ok(
504                    if matches!(
505                        self.access_for_normalized_domain(&domain),
506                        Some(DomainAccess::Deny)
507                    ) {
508                        NetworkDecision::Deny
509                    } else {
510                        NetworkDecision::Allow
511                    },
512                ),
513                DomainMode::Restricted => Ok(
514                    if matches!(
515                        self.access_for_normalized_domain(&domain),
516                        Some(DomainAccess::Allow)
517                    ) {
518                        NetworkDecision::Allow
519                    } else {
520                        NetworkDecision::Deny
521                    },
522                ),
523            },
524        }
525    }
526
527    /// Evaluates a resolved target without performing DNS or network I/O.
528    ///
529    /// This is the safe policy-level entry point for a backend that has
530    /// already resolved a host. It checks every captured address and fails
531    /// closed for an empty address list.
532    fn decision_for_resolved_target(
533        &self,
534        target: &ResolvedNetworkTarget,
535    ) -> Result<NetworkDecision, PolicyError> {
536        let resolved_ips: Vec<_> = target.addresses().iter().map(SocketAddr::ip).collect();
537        self.decision_for_domain_with_resolved_ips(target.domain(), &resolved_ips)
538    }
539
540    /// Evaluates the exact address a backend is about to connect to.
541    ///
542    /// A locally allowed target is denied when the actual address was not in
543    /// the original resolution snapshot. External ownership remains external
544    /// because the other enforcement boundary owns the connection check.
545    fn decision_for_connected_address(
546        &self,
547        target: &ResolvedNetworkTarget,
548        connected: SocketAddr,
549    ) -> Result<NetworkDecision, PolicyError> {
550        let decision = self.decision_for_resolved_target(target)?;
551        if !decision.is_allowed() {
552            return Ok(decision);
553        }
554        Ok(if target.contains_address(connected) {
555            NetworkDecision::Allow
556        } else {
557            NetworkDecision::Deny
558        })
559    }
560
561    /// Authorizes the exact socket address a backend is about to connect to.
562    ///
563    /// The returned [`ConnectionAuthorization::Allowed`] value contains the
564    /// only address that passed the policy check. A backend must connect using
565    /// that address and must not resolve the hostname again.
566    pub fn authorize_connection(
567        &self,
568        target: &ResolvedNetworkTarget,
569        connected: SocketAddr,
570    ) -> Result<ConnectionAuthorization, PolicyError> {
571        Ok(
572            match self.decision_for_connected_address(target, connected)? {
573                NetworkDecision::Allow => {
574                    ConnectionAuthorization::Allowed(AuthorizedSocketAddr(connected))
575                }
576                NetworkDecision::Deny => ConnectionAuthorization::Denied,
577                NetworkDecision::ExternallyEnforced => ConnectionAuthorization::ExternallyEnforced,
578            },
579        )
580    }
581
582    /// Evaluates a domain together with addresses resolved by a future
583    /// network backend.
584    ///
585    /// This method deliberately performs no DNS lookup. For a hostname, the
586    /// backend must resolve the name, pass every result here, and pass an
587    /// empty slice when resolution fails or times out. Such an empty hostname
588    /// result is denied. Hostnames resolving to any non-public address are
589    /// denied by default to prevent DNS rebinding from bypassing the domain
590    /// policy. An IP literal does not require DNS results: an empty slice is
591    /// valid for the literal itself, while non-public literals still require
592    /// an exact literal allow or [`LocalNetworkAccess::Allow`]. Prefer
593    /// [`ResolvedNetworkTarget`] and [`Self::authorize_connection`] in backend
594    /// code for actual connections. The target must contain the exact socket
595    /// address that will be used, so a target with no addresses cannot be
596    /// connected through the authorization API.
597    pub fn decision_for_domain_with_resolved_ips(
598        &self,
599        domain: &str,
600        resolved_ips: &[IpAddr],
601    ) -> Result<NetworkDecision, PolicyError> {
602        let normalized_domain = normalize_domain(domain)?;
603        let decision = self.decision_for_domain(&normalized_domain)?;
604        if !decision.is_allowed() {
605            return Ok(decision);
606        }
607
608        if let Some(literal) = parse_ip_literal(&normalized_domain) {
609            if resolved_ips.iter().any(|ip| *ip != literal) {
610                return Ok(NetworkDecision::Deny);
611            }
612            return Ok(
613                if self.has_exact_allow(&normalized_domain)
614                    || self.local_network_access == LocalNetworkAccess::Allow
615                    || !is_non_public_ip(literal)
616                {
617                    NetworkDecision::Allow
618                } else {
619                    NetworkDecision::Deny
620                },
621            );
622        }
623
624        let explicit_localhost_allow =
625            normalized_domain == "localhost" && self.has_exact_allow(&normalized_domain);
626        if normalized_domain == "localhost"
627            && self.local_network_access == LocalNetworkAccess::Deny
628            && !explicit_localhost_allow
629        {
630            return Ok(NetworkDecision::Deny);
631        }
632        if resolved_ips.is_empty() {
633            return Ok(NetworkDecision::Deny);
634        }
635        if self.local_network_access == LocalNetworkAccess::Deny
636            && resolved_ips
637                .iter()
638                .copied()
639                .any(|ip| is_non_public_ip(ip) && !(explicit_localhost_allow && ip.is_loopback()))
640        {
641            return Ok(NetworkDecision::Deny);
642        }
643        Ok(NetworkDecision::Allow)
644    }
645
646    /// Evaluates a Unix socket path against the complete network policy.
647    ///
648    /// The path is validated even when enforcement is external so malformed
649    /// input cannot be mistaken for a successful handoff.
650    pub fn decision_for_unix_socket(&self, path: &Path) -> Result<NetworkDecision, PolicyError> {
651        PathSelector::absolute(path.to_path_buf())?;
652        if self.mode == NetworkMode::External {
653            return Ok(NetworkDecision::ExternallyEnforced);
654        }
655        if self.mode == NetworkMode::Disabled || self.unix_socket_mode == UnixSocketMode::Disabled {
656            return Ok(NetworkDecision::Deny);
657        }
658        let mut result = None;
659        for rule in &self.unix_sockets {
660            if paths_equal(path, rule.path()) {
661                result = Some(match (result, rule.access()) {
662                    (Some(DomainAccess::Deny), _) | (_, DomainAccess::Deny) => DomainAccess::Deny,
663                    _ => DomainAccess::Allow,
664                });
665            }
666        }
667        let decision = match self.unix_socket_mode {
668            UnixSocketMode::Disabled => NetworkDecision::Deny,
669            UnixSocketMode::Enabled => {
670                if matches!(result, Some(DomainAccess::Deny)) {
671                    NetworkDecision::Deny
672                } else {
673                    NetworkDecision::Allow
674                }
675            }
676            UnixSocketMode::Restricted => {
677                if matches!(result, Some(DomainAccess::Allow)) {
678                    NetworkDecision::Allow
679                } else {
680                    NetworkDecision::Deny
681                }
682            }
683        };
684        Ok(decision)
685    }
686
687    fn access_for_normalized_domain(&self, domain: &str) -> Option<DomainAccess> {
688        let mut result = None;
689        for rule in &self.domains {
690            if rule.matches(domain) {
691                result = Some(match (result, rule.access()) {
692                    (Some(DomainAccess::Deny), _) | (_, DomainAccess::Deny) => DomainAccess::Deny,
693                    _ => DomainAccess::Allow,
694                });
695            }
696        }
697        result
698    }
699
700    fn has_exact_allow(&self, domain: &str) -> bool {
701        self.domains.iter().any(|rule| {
702            rule.access() == DomainAccess::Allow
703                && !rule.pattern().contains('*')
704                && !rule.pattern().contains('?')
705                && rule.pattern() == domain
706        })
707    }
708}
709
710fn normalize_domain_pattern(raw: &str) -> Result<String, PolicyError> {
711    let raw = raw.trim();
712    let invalid_syntax = raw.is_empty()
713        || raw.contains("://")
714        || raw.contains('/')
715        || raw.contains('#')
716        || raw.chars().any(char::is_whitespace)
717        || raw.chars().any(char::is_control);
718    if invalid_syntax {
719        return Err(PolicyError::InvalidDomainPattern {
720            pattern: raw.to_string(),
721        });
722    }
723
724    let (prefix, remainder) = if let Some(remainder) = raw.strip_prefix("**.") {
725        ("**.", remainder)
726    } else if let Some(remainder) = raw.strip_prefix("*.") {
727        ("*.", remainder)
728    } else {
729        ("", raw)
730    };
731    let remainder = normalize_host(remainder).ok_or_else(|| PolicyError::InvalidDomainPattern {
732        pattern: raw.to_string(),
733    })?;
734    let pattern = if prefix.is_empty() {
735        remainder
736    } else {
737        format!("{prefix}{remainder}")
738    };
739    if valid_domain_pattern(&pattern) {
740        Ok(pattern)
741    } else {
742        Err(PolicyError::InvalidDomainPattern {
743            pattern: raw.to_string(),
744        })
745    }
746}
747
748fn normalize_domain(raw: &str) -> Result<String, PolicyError> {
749    let normalized = normalize_domain_pattern(raw)?;
750    if !valid_concrete_host(&normalized) {
751        return Err(PolicyError::InvalidDomainPattern {
752            pattern: raw.to_string(),
753        });
754    }
755    Ok(normalized)
756}
757
758fn normalize_host(host: &str) -> Option<String> {
759    let host = host.trim();
760    if host.starts_with('[') {
761        let bracketed = host.strip_prefix('[')?;
762        let end = bracketed.find(']')?;
763        let inner = &bracketed[..end];
764        let suffix = &bracketed[end + 1..];
765        if normalize_ip_literal(inner).is_some() {
766            if !suffix.is_empty() && !valid_port_suffix(suffix) {
767                return None;
768            }
769            return normalize_ip_literal(inner).map(|ip| normalize_dns_host_or_ip_literal(&ip));
770        }
771    }
772    match host.bytes().filter(|byte| *byte == b':').count() {
773        0 => Some(normalize_dns_host_or_ip_literal(host)),
774        1 => {
775            let (host, port) = host.split_once(':')?;
776            if !valid_port(port) {
777                return None;
778            }
779            Some(normalize_dns_host_or_ip_literal(host))
780        }
781        _ => normalize_ip_literal(host).map(|ip| normalize_dns_host_or_ip_literal(&ip)),
782    }
783}
784
785fn valid_port_suffix(suffix: &str) -> bool {
786    let Some(port) = suffix.strip_prefix(':') else {
787        return false;
788    };
789    valid_port(port)
790}
791
792fn valid_port(port: &str) -> bool {
793    !port.is_empty()
794        && port.bytes().all(|byte| byte.is_ascii_digit())
795        && port.parse::<u16>().is_ok()
796}
797
798fn normalize_dns_host_or_ip_literal(host: &str) -> String {
799    let host = host.to_ascii_lowercase();
800    let host = host.trim_end_matches('.');
801    if let Some(ip) = normalize_ip_literal(host) {
802        return ip;
803    }
804    host.to_string()
805}
806
807fn normalize_ip_literal(host: &str) -> Option<String> {
808    if host.parse::<IpAddr>().is_ok() {
809        return Some(host.to_string());
810    }
811    for delimiter in ["%25", "%"] {
812        if let Some((ip, scope)) = host.split_once(delimiter)
813            && ip.parse::<IpAddr>().is_ok()
814        {
815            return Some(format!("{ip}%{scope}"));
816        }
817    }
818    None
819}
820
821fn parse_ip_literal(host: &str) -> Option<IpAddr> {
822    host.split_once('%').map_or_else(
823        || host.parse().ok(),
824        |(address, _scope)| address.parse().ok(),
825    )
826}
827
828fn is_non_public_ip(ip: IpAddr) -> bool {
829    match ip {
830        IpAddr::V4(address) => {
831            let value = u32::from(address);
832            address.is_loopback()
833                || address.is_private()
834                || address.is_link_local()
835                || address.is_unspecified()
836                || address.is_multicast()
837                || address.is_broadcast()
838                || (value & 0xff00_0000) == 0
839                || (value & 0xffc0_0000) == 0x6440_0000
840                || ((value & 0xffff_ff00) == 0xc000_0000
841                    && !matches!(value, 0xc000_0009 | 0xc000_000a))
842                || (value & 0xffff_ff00) == 0xc000_0200
843                || (value & 0xffff_ff00) == 0xc058_6300
844                || (value & 0xfffe_0000) == 0xc612_0000
845                || (value & 0xffff_ff00) == 0xc633_6400
846                || (value & 0xffff_ff00) == 0xcb00_7100
847                || (value & 0xf000_0000) == 0xf000_0000
848        }
849        IpAddr::V6(address) => is_non_public_ipv6(address),
850    }
851}
852
853fn is_non_public_ipv6(address: std::net::Ipv6Addr) -> bool {
854    let segments = address.segments();
855    address.is_loopback()
856        || address.is_unspecified()
857        || address.is_multicast()
858        || address.is_unique_local()
859        || address.is_unicast_link_local()
860        || address
861            .to_ipv4()
862            .is_some_and(|address| is_non_public_ip(IpAddr::V4(address)))
863        // The globally routed NAT64 well-known prefix is safe only when its
864        // embedded IPv4 destination is itself globally reachable.
865        || well_known_nat64_ipv4(segments)
866            .is_some_and(|address| is_non_public_ip(IpAddr::V4(address)))
867        // IPv4-IPv6 translation local-use prefix.
868        || matches!(segments, [0x64, 0xff9b, 1, _, _, _, _, _])
869        // Discard-only address block.
870        || matches!(segments, [0x100, 0, 0, 0, _, _, _, _])
871        // Dummy IPv6 prefix.
872        || matches!(segments, [0x100, 0, 0, 1, _, _, _, _])
873        || is_non_public_ietf_protocol_assignment(segments)
874        // 6to4 has no globally-reachable registry designation.
875        || matches!(segments, [0x2002, _, _, _, _, _, _, _])
876        // Documentation prefixes.
877        || matches!(segments, [0x2001, 0x0db8, _, _, _, _, _, _])
878        || matches!(segments, [0x3fff, 0x0000..=0x0fff, _, _, _, _, _, _])
879        // Segment Routing SIDs.
880        || matches!(segments, [0x5f00, _, _, _, _, _, _, _])
881        // Deprecated site-local addresses can remain reachable inside a host
882        // or organization even though new routers should not forward them.
883        || (segments[0] & 0xffc0) == 0xfec0
884}
885
886fn well_known_nat64_ipv4(segments: [u16; 8]) -> Option<Ipv4Addr> {
887    match segments {
888        [0x64, 0xff9b, 0, 0, 0, 0, high, low] => {
889            let [first, second] = high.to_be_bytes();
890            let [third, fourth] = low.to_be_bytes();
891            Some(Ipv4Addr::new(first, second, third, fourth))
892        }
893        _ => None,
894    }
895}
896
897fn is_non_public_ietf_protocol_assignment(segments: [u16; 8]) -> bool {
898    if segments[0] != 0x2001 || segments[1] >= 0x0200 {
899        return false;
900    }
901    !matches!(
902        segments,
903        // PCP and TURN anycast addresses.
904        [0x2001, 0x0001, 0, 0, 0, 0, 0, 1..=3]
905            // AMT.
906            | [0x2001, 0x0003, _, _, _, _, _, _]
907            // AS112-v6.
908            | [0x2001, 0x0004, 0x0112, _, _, _, _, _]
909            // ORCHIDv2 and Drone Remote ID entity tags.
910            | [0x2001, 0x0020..=0x003f, _, _, _, _, _, _]
911    )
912}
913
914fn valid_domain_literal(pattern: &str) -> bool {
915    !pattern.is_empty()
916        && !pattern.contains('*')
917        && !pattern.contains('?')
918        && (!pattern.contains(':') || normalize_ip_literal(pattern).is_some())
919}
920
921fn valid_domain_pattern(pattern: &str) -> bool {
922    if pattern == "*" {
923        return true;
924    }
925    if pattern.contains(':') || pattern.contains('%') {
926        return valid_domain_literal(pattern);
927    }
928    let suffix = pattern
929        .strip_prefix("**.")
930        .or_else(|| pattern.strip_prefix("*."))
931        .unwrap_or(pattern);
932    !suffix.is_empty()
933        && suffix.split('.').all(|label| {
934            !label.is_empty()
935                && label.chars().all(|character| {
936                    !character.is_whitespace()
937                        && !character.is_control()
938                        && !matches!(character, ':' | '%' | '@')
939                })
940        })
941}
942
943fn valid_concrete_host(host: &str) -> bool {
944    if parse_ip_literal(host).is_some() {
945        return true;
946    }
947    host.len() <= 253
948        && host.split('.').all(|label| {
949            !label.is_empty()
950                && label.len() <= 63
951                && !label.starts_with('-')
952                && !label.ends_with('-')
953                && label
954                    .chars()
955                    .all(|character| character.is_alphanumeric() || matches!(character, '-' | '_'))
956        })
957}
958
959fn compile_domain_matcher(pattern: &str) -> Result<DomainMatcher, PolicyError> {
960    if pattern == "*" {
961        return Ok(DomainMatcher::Any);
962    }
963    if let Some(suffix) = pattern.strip_prefix("**.") {
964        return Ok(DomainMatcher::Suffix {
965            labels: compile_domain_labels(suffix, pattern)?,
966            include_apex: true,
967        });
968    }
969    if let Some(suffix) = pattern.strip_prefix("*.") {
970        return Ok(DomainMatcher::Suffix {
971            labels: compile_domain_labels(suffix, pattern)?,
972            include_apex: false,
973        });
974    }
975    let matcher = GlobBuilder::new(pattern)
976        .case_insensitive(true)
977        .build()
978        .map_err(|_| PolicyError::InvalidDomainPattern {
979            pattern: pattern.to_owned(),
980        })?
981        .compile_matcher();
982    Ok(DomainMatcher::Full(matcher))
983}
984
985fn compile_domain_labels(suffix: &str, pattern: &str) -> Result<Vec<GlobMatcher>, PolicyError> {
986    suffix
987        .split('.')
988        .map(|label| {
989            GlobBuilder::new(label)
990                .case_insensitive(true)
991                .build()
992                .map(|glob| glob.compile_matcher())
993                .map_err(|_| PolicyError::InvalidDomainPattern {
994                    pattern: pattern.to_owned(),
995                })
996        })
997        .collect()
998}