Skip to main content

glass/browser/
policy.rs

1//! Security policy engine for browser operations.
2//!
3//! Defines a policy system with presets (development, hardened, custom)
4//! and capability-based gating. Every session operation is checked against
5//! the active policy before execution. Includes network filtering, file
6//! system sandboxing, and per-capability allow/deny controls.
7
8use serde::Serialize;
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt;
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
12use std::path::{Path, PathBuf};
13use url::Url;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, clap::ValueEnum)]
16#[serde(rename_all = "snake_case")]
17pub enum PolicyCapability {
18    Attach,
19    PersistentProfile,
20    Evaluate,
21    Upload,
22    Download,
23    Screenshot,
24    RawCdp,
25    ReadFormValues,
26    ReadSensitiveFormValues,
27    CoordinateClick,
28    ConsentDismissal,
29    DeclaredAgentIdentity,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
33#[serde(rename_all = "snake_case")]
34pub enum PolicyPreset {
35    Development,
36    #[clap(name = "ci")]
37    Ci,
38    Polite,
39    Hardened,
40    #[clap(name = "untrusted-mcp")]
41    UntrustedMcp,
42}
43
44impl std::str::FromStr for PolicyPreset {
45    type Err = PolicyError;
46
47    fn from_str(value: &str) -> Result<Self, Self::Err> {
48        match value {
49            "development" | "dev" => Ok(Self::Development),
50            "ci" => Ok(Self::Ci),
51            "polite" => Ok(Self::Polite),
52            "hardened" => Ok(Self::Hardened),
53            "untrusted-mcp" | "untrusted_mcp" => Ok(Self::UntrustedMcp),
54            _ => Err(PolicyError::InvalidConfiguration {
55                reason: format!(
56                    "policy preset must be dev, ci, polite, hardened, or untrusted-mcp, got: {value}"
57                ),
58            }),
59        }
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64#[serde(tag = "decision", rename_all = "snake_case")]
65pub enum PolicyDecision {
66    Allow,
67    Deny { reason: String },
68    RequireConfirmation { reason: String },
69}
70
71#[derive(Debug, Clone)]
72pub struct BrowserPolicy {
73    preset: PolicyPreset,
74    allowed_capabilities: BTreeSet<PolicyCapability>,
75    confirmed_capabilities: BTreeSet<PolicyCapability>,
76    allowed_hosts: BTreeSet<String>,
77    denied_hosts: BTreeSet<String>,
78    confirmation_tokens: std::sync::Arc<std::sync::Mutex<BTreeMap<PolicyCapability, u32>>>,
79    pinned_hosts: BTreeMap<String, IpAddr>,
80    workspace_root: PathBuf,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84#[serde(tag = "kind", rename_all = "snake_case")]
85pub enum PolicyError {
86    Denied { operation: String, reason: String },
87    ConfirmationRequired { operation: String, reason: String },
88    InvalidConfiguration { reason: String },
89}
90
91impl fmt::Display for PolicyError {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Self::Denied { operation, reason } => {
95                write!(formatter, "policy denied {operation}: {reason}")
96            }
97            Self::ConfirmationRequired { operation, reason } => {
98                write!(
99                    formatter,
100                    "policy confirmation required for {operation}: {reason}"
101                )
102            }
103            Self::InvalidConfiguration { reason } => {
104                write!(formatter, "invalid browser policy: {reason}")
105            }
106        }
107    }
108}
109
110impl std::error::Error for PolicyError {}
111
112impl BrowserPolicy {
113    /// Create a policy from a preset and workspace root.
114    pub fn from_preset(
115        preset: PolicyPreset,
116        workspace_root: impl AsRef<Path>,
117    ) -> Result<Self, PolicyError> {
118        match preset {
119            PolicyPreset::Development => Self::development(workspace_root),
120            PolicyPreset::Ci => Self::ci(workspace_root),
121            PolicyPreset::Polite => Self::polite(workspace_root),
122            PolicyPreset::Hardened => Self::hardened(workspace_root),
123            PolicyPreset::UntrustedMcp => Self::untrusted_mcp(workspace_root),
124        }
125    }
126
127    pub fn development(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
128        Self::new(PolicyPreset::Development, workspace_root, [], [])
129    }
130
131    pub fn ci(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
132        Self::new(PolicyPreset::Ci, workspace_root, [], [])
133    }
134
135    pub fn polite(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
136        Self::new(PolicyPreset::Polite, workspace_root, [], [])
137    }
138
139    pub fn hardened(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
140        Self::new(PolicyPreset::Hardened, workspace_root, [], [])
141    }
142
143    pub fn untrusted_mcp(workspace_root: impl AsRef<Path>) -> Result<Self, PolicyError> {
144        Self::new(PolicyPreset::UntrustedMcp, workspace_root, [], [])
145    }
146
147    pub fn new(
148        preset: PolicyPreset,
149        workspace_root: impl AsRef<Path>,
150        allowed_capabilities: impl IntoIterator<Item = PolicyCapability>,
151        confirmed_capabilities: impl IntoIterator<Item = PolicyCapability>,
152    ) -> Result<Self, PolicyError> {
153        let workspace_root = std::fs::canonicalize(workspace_root.as_ref()).map_err(|error| {
154            PolicyError::InvalidConfiguration {
155                reason: format!("workspace root must exist and be canonicalizable: {error}"),
156            }
157        })?;
158        if !workspace_root.is_dir() {
159            return Err(PolicyError::InvalidConfiguration {
160                reason: "workspace root must be a directory".to_string(),
161            });
162        }
163        let allowed_capabilities: BTreeSet<_> = allowed_capabilities.into_iter().collect();
164        let confirmed_capabilities: BTreeSet<_> = confirmed_capabilities.into_iter().collect();
165        if allowed_capabilities
166            .iter()
167            .any(|capability| confirmed_capabilities.contains(capability))
168        {
169            return Err(PolicyError::InvalidConfiguration {
170                reason: "a capability cannot be both allowed and confirmation-required".to_string(),
171            });
172        }
173        Ok(Self {
174            preset,
175            allowed_capabilities,
176            confirmed_capabilities,
177            allowed_hosts: BTreeSet::new(),
178            denied_hosts: BTreeSet::new(),
179            confirmation_tokens: Default::default(),
180            pinned_hosts: BTreeMap::new(),
181            workspace_root,
182        })
183    }
184
185    pub fn with_host_rules(
186        mut self,
187        allowed_hosts: impl IntoIterator<Item = String>,
188        denied_hosts: impl IntoIterator<Item = String>,
189    ) -> Result<Self, PolicyError> {
190        self.allowed_hosts = normalize_host_rules(allowed_hosts)?;
191        self.denied_hosts = normalize_host_rules(denied_hosts)?;
192        if self
193            .allowed_hosts
194            .iter()
195            .any(|host| self.denied_hosts.contains(host))
196        {
197            return Err(PolicyError::InvalidConfiguration {
198                reason: "a host cannot be both allowed and denied".to_string(),
199            });
200        }
201        Ok(self)
202    }
203
204    pub fn preset(&self) -> PolicyPreset {
205        self.preset
206    }
207
208    pub fn with_confirmation_tokens(
209        self,
210        capabilities: impl IntoIterator<Item = PolicyCapability>,
211    ) -> Result<Self, PolicyError> {
212        let mut tokens =
213            self.confirmation_tokens
214                .lock()
215                .map_err(|_| PolicyError::InvalidConfiguration {
216                    reason: "confirmation token state is unavailable".to_string(),
217                })?;
218        for capability in capabilities {
219            if capability == PolicyCapability::RawCdp {
220                return Err(PolicyError::InvalidConfiguration {
221                    reason: "raw CDP is an unlimited escape hatch and supports explicit allow only"
222                        .to_string(),
223                });
224            }
225            if !self.confirmed_capabilities.contains(&capability) {
226                return Err(PolicyError::InvalidConfiguration {
227                    reason: format!(
228                        "{capability:?} needs --policy-confirm before a one-operation token"
229                    ),
230                });
231            }
232            *tokens.entry(capability).or_default() += 1;
233        }
234        drop(tokens);
235        Ok(self)
236    }
237
238    pub fn workspace_root(&self) -> &Path {
239        &self.workspace_root
240    }
241
242    pub async fn prepare_hardened_session(
243        &mut self,
244        attached: bool,
245    ) -> Result<Option<String>, PolicyError> {
246        // Development and CI do not require host allowlisting
247        if matches!(
248            self.preset,
249            PolicyPreset::Development | PolicyPreset::Ci | PolicyPreset::Polite
250        ) {
251            return Ok(None);
252        }
253        if self.allowed_hosts.is_empty() {
254            return Err(PolicyError::InvalidConfiguration {
255                reason: "hardened and untrusted-mcp modes require at least one exact --policy-allow-host"
256                    .to_string(),
257            });
258        }
259        let mut resolver_rules = Vec::with_capacity(self.allowed_hosts.len());
260        for host in &self.allowed_hosts {
261            let address = if let Ok(address) = host.parse::<IpAddr>() {
262                address
263            } else {
264                if attached {
265                    return Err(PolicyError::InvalidConfiguration {
266                        reason: "hardened attach requires public IP-literal allow rules to avoid DNS rebinding"
267                            .to_string(),
268                    });
269                }
270                let addresses: Vec<IpAddr> = tokio::net::lookup_host((host.as_str(), 443))
271                    .await
272                    .map_err(|error| PolicyError::InvalidConfiguration {
273                        reason: format!("could not resolve allowed host {host}: {error}"),
274                    })?
275                    .map(|address| address.ip())
276                    .collect();
277                if addresses.is_empty() || addresses.iter().copied().any(is_non_public_ip) {
278                    return Err(PolicyError::InvalidConfiguration {
279                        reason: format!(
280                            "allowed host {host} did not resolve only to public addresses"
281                        ),
282                    });
283                }
284                addresses
285                    .iter()
286                    .copied()
287                    .find(IpAddr::is_ipv4)
288                    .ok_or_else(|| PolicyError::InvalidConfiguration {
289                        reason: format!(
290                            "allowed host {host} needs a public IPv4 address for resolver pinning"
291                        ),
292                    })?
293            };
294            if is_non_public_ip(address) {
295                return Err(PolicyError::InvalidConfiguration {
296                    reason: format!("allowed host {host} is not a public address"),
297                });
298            }
299            self.pinned_hosts.insert(host.clone(), address);
300            if !attached {
301                resolver_rules.push(format!("MAP {host} {address}"));
302            }
303        }
304        Ok((!resolver_rules.is_empty()).then(|| resolver_rules.join(",")))
305    }
306
307    pub fn decide(&self, capability: PolicyCapability) -> PolicyDecision {
308        // Untrusted MCP: everything requires confirmation (or is denied)
309        if self.preset == PolicyPreset::UntrustedMcp {
310            if self.allowed_capabilities.contains(&capability) {
311                return PolicyDecision::Allow;
312            }
313            // Always-deny capabilities in untrusted MCP
314            if matches!(
315                capability,
316                PolicyCapability::RawCdp | PolicyCapability::PersistentProfile
317            ) {
318                return PolicyDecision::Deny {
319                    reason: format!("{capability:?} is disabled in untrusted-mcp mode"),
320                };
321            }
322            if self.confirmed_capabilities.contains(&capability) {
323                return PolicyDecision::RequireConfirmation {
324                    reason: format!("{capability:?} requires confirmation in untrusted-mcp mode"),
325                };
326            }
327            return PolicyDecision::Deny {
328                reason: format!("{capability:?} is disabled by the untrusted-mcp preset"),
329            };
330        }
331
332        // CI: allow most capabilities but deny raw CDP and persistent profiles
333        if self.preset == PolicyPreset::Ci {
334            if matches!(
335                capability,
336                PolicyCapability::RawCdp | PolicyCapability::PersistentProfile
337            ) {
338                return PolicyDecision::Deny {
339                    reason: format!("{capability:?} is disabled in CI mode"),
340                };
341            }
342            if self.allowed_capabilities.contains(&capability) {
343                return PolicyDecision::Allow;
344            }
345            return PolicyDecision::Allow;
346        }
347
348        // Development: allow everything
349        if matches!(
350            self.preset,
351            PolicyPreset::Development | PolicyPreset::Polite
352        ) || self.allowed_capabilities.contains(&capability)
353        {
354            PolicyDecision::Allow
355        } else if self.confirmed_capabilities.contains(&capability) {
356            PolicyDecision::RequireConfirmation {
357                reason: format!("{capability:?} is privileged in hardened mode"),
358            }
359        } else {
360            PolicyDecision::Deny {
361                reason: format!("{capability:?} is disabled by the hardened preset"),
362            }
363        }
364    }
365
366    pub fn require(&self, capability: PolicyCapability) -> Result<(), PolicyError> {
367        match self.decide(capability) {
368            PolicyDecision::Allow => Ok(()),
369            PolicyDecision::Deny { reason } => Err(PolicyError::Denied {
370                operation: capability_name(capability).to_string(),
371                reason,
372            }),
373            PolicyDecision::RequireConfirmation { reason } => {
374                let mut tokens = self.confirmation_tokens.lock().map_err(|_| {
375                    PolicyError::InvalidConfiguration {
376                        reason: "confirmation token state is unavailable".to_string(),
377                    }
378                })?;
379                let remaining = tokens.entry(capability).or_default();
380                if *remaining > 0 {
381                    *remaining -= 1;
382                    Ok(())
383                } else {
384                    Err(PolicyError::ConfirmationRequired {
385                        operation: capability_name(capability).to_string(),
386                        reason,
387                    })
388                }
389            }
390        }
391    }
392
393    /// Check a capability without consuming a confirmation token. Batch
394    /// preflight uses this so a token cannot be spent before the batch starts
395    /// or be smuggled into a multi-step request.
396    pub fn require_for_batch(&self, capability: PolicyCapability) -> Result<(), PolicyError> {
397        match self.decide(capability) {
398            PolicyDecision::Allow => Ok(()),
399            PolicyDecision::Deny { reason } => Err(PolicyError::Denied {
400                operation: capability_name(capability).to_string(),
401                reason,
402            }),
403            PolicyDecision::RequireConfirmation { reason } => {
404                Err(PolicyError::ConfirmationRequired {
405                    operation: capability_name(capability).to_string(),
406                    reason,
407                })
408            }
409        }
410    }
411
412    /// Whether sensitive form values (passwords, CC numbers) may be read.
413    /// This capability is denied in ALL presets by default and must be
414    /// explicitly added to `allowed_capabilities`.
415    pub fn allow_sensitive_form_values(&self) -> bool {
416        self.allowed_capabilities
417            .contains(&PolicyCapability::ReadSensitiveFormValues)
418    }
419
420    pub async fn require_url(&self, value: &str) -> Result<Url, PolicyError> {
421        let url = Url::parse(value).map_err(|error| PolicyError::Denied {
422            operation: "navigate".to_string(),
423            reason: format!("URL is invalid: {error}"),
424        })?;
425        let host = url.host_str().map(|host| host.trim_end_matches('.'));
426        if host.is_some_and(|host| self.denied_hosts.contains(host)) {
427            return Err(url_denied("host is explicitly denied"));
428        }
429        if !self.allowed_hosts.is_empty()
430            && !host.is_some_and(|host| self.allowed_hosts.contains(host))
431        {
432            return Err(url_denied("host is not in the explicit allow list"));
433        }
434        if matches!(
435            self.preset,
436            PolicyPreset::Development | PolicyPreset::Polite
437        ) {
438            return Ok(url);
439        }
440        if url.host_str().is_some_and(|host| host.ends_with('.')) {
441            return Err(url_denied(
442                "hardened URLs must use a canonical host without a trailing dot",
443            ));
444        }
445        if !matches!(url.scheme(), "http" | "https") {
446            return Err(url_denied(
447                "hardened navigation permits only http and https",
448            ));
449        }
450        if !url.username().is_empty() || url.password().is_some() {
451            return Err(url_denied("URLs containing credentials are not permitted"));
452        }
453        let host = host.ok_or_else(|| url_denied("URL must contain a host"))?;
454        if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") {
455            return Err(url_denied("localhost destinations are not permitted"));
456        }
457        let Some(address) = self.pinned_hosts.get(host).copied() else {
458            return Err(url_denied(
459                "hardened host was not pinned at session startup",
460            ));
461        };
462        if is_non_public_ip(address) {
463            return Err(url_denied(
464                "host resolves to a non-public or reserved network destination",
465            ));
466        }
467        Ok(url)
468    }
469
470    pub fn require_existing_path(&self, value: &Path) -> Result<PathBuf, PolicyError> {
471        let canonical = std::fs::canonicalize(value).map_err(|error| PolicyError::Denied {
472            operation: "filesystem_read".to_string(),
473            reason: format!("path must exist and be canonicalizable: {error}"),
474        })?;
475        self.require_within_workspace(&canonical, "filesystem_read")?;
476        Ok(canonical)
477    }
478
479    pub fn require_output_path(&self, value: &Path) -> Result<PathBuf, PolicyError> {
480        if std::fs::symlink_metadata(value).is_ok() {
481            let canonical = std::fs::canonicalize(value).map_err(|error| PolicyError::Denied {
482                operation: "filesystem_write".to_string(),
483                reason: format!("existing output must be canonicalizable: {error}"),
484            })?;
485            self.require_within_workspace(&canonical, "filesystem_write")?;
486            return Ok(canonical);
487        }
488        let name = value.file_name().ok_or_else(|| PolicyError::Denied {
489            operation: "filesystem_write".to_string(),
490            reason: "output path must name a file or directory".to_string(),
491        })?;
492        let parent = value.parent().unwrap_or_else(|| Path::new("."));
493        let parent = std::fs::canonicalize(parent).map_err(|error| PolicyError::Denied {
494            operation: "filesystem_write".to_string(),
495            reason: format!("output parent must exist and be canonicalizable: {error}"),
496        })?;
497        self.require_within_workspace(&parent, "filesystem_write")?;
498        Ok(parent.join(name))
499    }
500
501    fn require_within_workspace(
502        &self,
503        canonical: &Path,
504        operation: &str,
505    ) -> Result<(), PolicyError> {
506        if matches!(
507            self.preset,
508            PolicyPreset::Development | PolicyPreset::Polite
509        ) || canonical.starts_with(&self.workspace_root)
510        {
511            Ok(())
512        } else {
513            Err(PolicyError::Denied {
514                operation: operation.to_string(),
515                reason: "path escapes the configured workspace root".to_string(),
516            })
517        }
518    }
519
520    pub fn is_polite(&self) -> bool {
521        self.preset == PolicyPreset::Polite
522    }
523}
524
525fn capability_name(capability: PolicyCapability) -> &'static str {
526    match capability {
527        PolicyCapability::Attach => "attach",
528        PolicyCapability::PersistentProfile => "persistent_profile",
529        PolicyCapability::Evaluate => "evaluate",
530        PolicyCapability::Upload => "upload",
531        PolicyCapability::Download => "download",
532        PolicyCapability::Screenshot => "screenshot",
533        PolicyCapability::RawCdp => "raw_cdp",
534        PolicyCapability::ReadFormValues => "read_form_values",
535        PolicyCapability::ReadSensitiveFormValues => "read_sensitive_form_values",
536        PolicyCapability::CoordinateClick => "coordinate_click",
537        PolicyCapability::ConsentDismissal => "consent_dismissal",
538        PolicyCapability::DeclaredAgentIdentity => "declared_agent_identity",
539    }
540}
541
542fn normalize_host_rules(
543    hosts: impl IntoIterator<Item = String>,
544) -> Result<BTreeSet<String>, PolicyError> {
545    hosts
546        .into_iter()
547        .map(|host| {
548            let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
549            if host.is_empty()
550                || host.contains('/')
551                || host.contains(':')
552                || host.contains('*')
553                || Url::parse(&format!("https://{host}/"))
554                    .ok()
555                    .and_then(|url| url.host_str().map(str::to_string))
556                    .as_deref()
557                    != Some(host.as_str())
558            {
559                return Err(PolicyError::InvalidConfiguration {
560                    reason: format!("invalid exact host rule: {host}"),
561                });
562            }
563            Ok(host)
564        })
565        .collect()
566}
567
568fn url_denied(reason: &str) -> PolicyError {
569    PolicyError::Denied {
570        operation: "navigate".to_string(),
571        reason: reason.to_string(),
572    }
573}
574
575fn is_non_public_ip(address: IpAddr) -> bool {
576    match address {
577        IpAddr::V4(address) => {
578            let value = u32::from(address);
579            [
580                ("0.0.0.0", 8),
581                ("10.0.0.0", 8),
582                ("100.64.0.0", 10),
583                ("127.0.0.0", 8),
584                ("169.254.0.0", 16),
585                ("172.16.0.0", 12),
586                ("192.0.0.0", 24),
587                ("192.0.2.0", 24),
588                ("192.88.99.0", 24),
589                ("192.168.0.0", 16),
590                ("198.18.0.0", 15),
591                ("198.51.100.0", 24),
592                ("203.0.113.0", 24),
593                ("224.0.0.0", 4),
594                ("240.0.0.0", 4),
595            ]
596            .into_iter()
597            .any(|(network, prefix)| {
598                ipv4_in_prefix(
599                    value,
600                    u32::from(network.parse::<Ipv4Addr>().unwrap()),
601                    prefix,
602                )
603            })
604        }
605        IpAddr::V6(address) => {
606            address.is_loopback()
607                || address.is_multicast()
608                || address.is_unspecified()
609                || [
610                    ("64:ff9b::", 96),
611                    ("64:ff9b:1::", 48),
612                    ("100::", 64),
613                    ("2001::", 32),
614                    ("2001:db8::", 32),
615                    ("2002::", 16),
616                    ("fc00::", 7),
617                    ("fe80::", 10),
618                    ("fec0::", 10),
619                ]
620                .into_iter()
621                .any(|(network, prefix)| {
622                    ipv6_in_prefix(
623                        u128::from(address),
624                        u128::from(network.parse::<Ipv6Addr>().unwrap()),
625                        prefix,
626                    )
627                })
628                || address
629                    .to_ipv4_mapped()
630                    .is_some_and(|address| is_non_public_ip(IpAddr::V4(address)))
631        }
632    }
633}
634
635fn ipv4_in_prefix(value: u32, network: u32, prefix: u32) -> bool {
636    let mask = u32::MAX.checked_shl(32 - prefix).unwrap_or(0);
637    value & mask == network & mask
638}
639
640fn ipv6_in_prefix(value: u128, network: u128, prefix: u32) -> bool {
641    let mask = u128::MAX.checked_shl(128 - prefix).unwrap_or(0);
642    value & mask == network & mask
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn hardened_capabilities_are_typed_denials_or_confirmations() {
651        let root = std::env::current_dir().unwrap();
652        let denied = BrowserPolicy::hardened(&root).unwrap();
653        assert!(matches!(
654            denied.decide(PolicyCapability::Evaluate),
655            PolicyDecision::Deny { .. }
656        ));
657        let confirm = BrowserPolicy::new(
658            PolicyPreset::Hardened,
659            &root,
660            [],
661            [PolicyCapability::Evaluate],
662        )
663        .unwrap();
664        assert!(matches!(
665            confirm.require(PolicyCapability::Evaluate),
666            Err(PolicyError::ConfirmationRequired { .. })
667        ));
668        let approved = confirm
669            .with_confirmation_tokens([PolicyCapability::Evaluate])
670            .unwrap();
671        assert!(approved.require(PolicyCapability::Evaluate).is_ok());
672        assert!(matches!(
673            approved.require(PolicyCapability::Evaluate),
674            Err(PolicyError::ConfirmationRequired { .. })
675        ));
676        assert!(
677            BrowserPolicy::new(
678                PolicyPreset::Hardened,
679                &root,
680                [PolicyCapability::Evaluate],
681                [PolicyCapability::Evaluate],
682            )
683            .is_err()
684        );
685    }
686
687    #[tokio::test]
688    async fn hardened_urls_reject_alternate_private_and_local_forms() {
689        let policy = BrowserPolicy::hardened(std::env::current_dir().unwrap()).unwrap();
690        for value in [
691            "file:///etc/passwd",
692            "data:text/plain,secret",
693            "http://localhost/",
694            "http://127.1/",
695            "http://2130706433/",
696            "http://[::1]/",
697            "http://[::ffff:127.0.0.1]/",
698            "https://user:secret@example.com/",
699        ] {
700            assert!(policy.require_url(value).await.is_err(), "accepted {value}");
701        }
702        for value in [
703            "192.0.0.8",
704            "198.18.0.1",
705            "198.51.100.1",
706            "203.0.113.1",
707            "64:ff9b::1",
708            "2001::1",
709            "2002::1",
710            "fec0::1",
711        ] {
712            assert!(is_non_public_ip(value.parse().unwrap()), "accepted {value}");
713        }
714    }
715
716    #[tokio::test]
717    async fn explicit_host_rules_are_canonical_and_pinned() {
718        let root = std::env::current_dir().unwrap();
719        let denied = BrowserPolicy::development(&root)
720            .unwrap()
721            .with_host_rules([], ["example.com".to_string()])
722            .unwrap();
723        assert!(denied.require_url("https://example.com./").await.is_err());
724
725        let mut pinned = BrowserPolicy::hardened(&root)
726            .unwrap()
727            .with_host_rules(["8.8.8.8".to_string()], [])
728            .unwrap();
729        let rules = pinned.prepare_hardened_session(false).await.unwrap();
730        assert_eq!(rules.as_deref(), Some("MAP 8.8.8.8 8.8.8.8"));
731        assert!(pinned.require_url("https://8.8.8.8/").await.is_ok());
732    }
733
734    #[test]
735    fn canonical_paths_reject_symlink_escape() {
736        #[cfg(unix)]
737        {
738            use std::os::unix::fs::symlink;
739            let root = std::env::temp_dir().join(format!("glass-policy-{}", std::process::id()));
740            let _ = std::fs::remove_dir_all(&root);
741            std::fs::create_dir_all(&root).unwrap();
742            symlink("/etc", root.join("escape")).unwrap();
743            let policy = BrowserPolicy::hardened(&root).unwrap();
744            assert!(
745                policy
746                    .require_existing_path(&root.join("escape/passwd"))
747                    .is_err()
748            );
749            assert!(
750                policy
751                    .require_output_path(&root.join("escape/passwd"))
752                    .is_err()
753            );
754            let _ = std::fs::remove_dir_all(root);
755        }
756    }
757}