Skip to main content

rightkit_browser/
policy.rs

1//! Effect admission, network (SSRF/host) policy, and lifecycle events.
2//!
3//! Order per effect (EFF-001): the [`NetworkPolicy`] (policy stage) decides
4//! first, then the caller-supplied [`AdmissionHook`] (approval stage) sees a
5//! typed [`AdmissionRequest`]; a denial at either stage happens before any side
6//! effect. Every network request a page makes, including redirect hops and
7//! subresources, is checked again inside the browser before it leaves the
8//! machine, and every TCP connection Chrome opens goes through an in-process
9//! pinning proxy that re-checks the address it actually connects to.
10//!
11//! Default policy blocks:
12//! * loopback (`127.0.0.0/8`, `::1`, `localhost`, `*.localhost`),
13//! * link-local (`169.254.0.0/16`, `fe80::/10`) including the cloud metadata
14//!   address `169.254.169.254` and the `metadata.google.internal` names. These
15//!   are *hard* blocks: no allow list, address-class allowance, or callback can
16//!   open them unless [`NetworkPolicy::hard_block_link_local`] is turned off,
17//! * private (`10/8`, `172.16/12`, `192.168/16`, `100.64/10` CGNAT,
18//!   `fc00::/7`, `fec0::/10`), unspecified (`0.0.0.0/8`, `::`), multicast,
19//!   and reserved/documentation/benchmark ranges,
20//! * IPv6 forms that embed one of the above IPv4 addresses
21//!   (`::ffff:a.b.c.d` mapped, `::a.b.c.d` compatible, `64:ff9b::/96` NAT64,
22//!   `2002::/16` 6to4),
23//! * hosts that do not resolve (fail closed),
24//! * every scheme other than `http`/`https` (`file:`, `ws:`, `chrome:`, ...)
25//!   unless listed in [`NetworkPolicy::allowed_schemes`]. `about:blank`, and
26//!   `data:`/`blob:` *subresources*, never touch the network and are allowed;
27//!   `data:`/`blob:` *documents* need the scheme to be allowed.
28//!
29//! Hostnames are resolved and every resolved address is checked (the worst
30//! address decides). The resolution is cached and shared with the pinning
31//! proxy, and the proxy only connects to addresses the request check vetted,
32//! so a name that rebinds from public to private between check and connect
33//! cannot reach a blocked address.
34//!
35//! Known gaps (documented, not silently covered):
36//! * WebSocket handshakes and requests from dedicated/shared/service workers
37//!   are not visible to page-level interception. They still pass the pinning
38//!   proxy, so the address policy holds, but the scheme check and the
39//!   per-request callback are not consulted for them.
40//! * The callback can only *deny* public destinations for requests the page
41//!   interception sees (same limit as above).
42//! * WebRTC UDP is restricted to proxied routes by Chrome flag; this is not
43//!   independently verified by a test.
44
45use futures::future::BoxFuture;
46use std::collections::HashMap;
47use std::fmt;
48use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
49use std::sync::{Arc, Mutex};
50use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
51
52// ---------------------------------------------------------------------------
53// Admission (approval stage)
54// ---------------------------------------------------------------------------
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
57pub enum ActionKind {
58    Navigate,
59    Reload,
60    History,
61    Click,
62    DoubleClick,
63    Hover,
64    Drag,
65    Wheel,
66    Press,
67    TypeText,
68    Fill,
69    Select,
70    Upload,
71    Eval,
72}
73
74/// One typed request to perform an effect. Typed text is never included, only
75/// its length, so admission logs stay content-free.
76#[derive(Clone, Debug)]
77pub struct AdmissionRequest {
78    pub session_id: String,
79    pub page_id: String,
80    pub action: ActionKind,
81    /// Destination for `Navigate`; otherwise the page's current URL.
82    pub url: Option<String>,
83    /// Human-readable target (selector, ref, point, key chord, file path).
84    pub target: Option<String>,
85    /// Extra detail: script source for `Eval`, `chars=N` for typing.
86    pub detail: Option<String>,
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub enum Decision {
91    Allow,
92    Deny(String),
93}
94
95pub type AdmissionHook =
96    Arc<dyn Fn(AdmissionRequest) -> BoxFuture<'static, Decision> + Send + Sync>;
97
98// ---------------------------------------------------------------------------
99// Address classification
100// ---------------------------------------------------------------------------
101
102/// Address class of a destination. Everything except `Public` is blocked by default.
103#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
104pub enum AddressClass {
105    Public,
106    Loopback,
107    /// `169.254/16`, `fe80::/10`; includes the cloud metadata endpoint.
108    LinkLocal,
109    /// RFC 1918, CGNAT `100.64/10`, ULA `fc00::/7`, site-local `fec0::/10`.
110    Private,
111    /// IETF protocol assignments, documentation, benchmarking, `240/4`,
112    /// broadcast, `2001:db8::/32`, Teredo.
113    Reserved,
114    Unspecified,
115    Multicast,
116}
117
118pub fn classify_ip(ip: IpAddr) -> AddressClass {
119    match ip {
120        IpAddr::V4(v4) => classify_v4(v4),
121        IpAddr::V6(v6) => classify_v6(v6),
122    }
123}
124
125fn classify_v4(ip: Ipv4Addr) -> AddressClass {
126    let [a, b, c, _] = ip.octets();
127    if a == 127 {
128        AddressClass::Loopback
129    } else if a == 0 {
130        AddressClass::Unspecified
131    } else if a == 169 && b == 254 {
132        AddressClass::LinkLocal
133    } else if a == 10
134        || (a == 172 && (16..=31).contains(&b))
135        || (a == 192 && b == 168)
136        || (a == 100 && (64..=127).contains(&b))
137    {
138        AddressClass::Private
139    } else if (224..=239).contains(&a) {
140        AddressClass::Multicast
141    } else if a >= 240
142        || (a == 192 && b == 0 && c == 0)
143        || (a == 192 && b == 0 && c == 2)
144        || (a == 198 && (b == 18 || b == 19))
145        || (a == 198 && b == 51 && c == 100)
146        || (a == 203 && b == 0 && c == 113)
147    {
148        AddressClass::Reserved
149    } else {
150        AddressClass::Public
151    }
152}
153
154fn v4_from_segments(hi: u16, lo: u16) -> Ipv4Addr {
155    Ipv4Addr::new((hi >> 8) as u8, hi as u8, (lo >> 8) as u8, lo as u8)
156}
157
158fn classify_v6(v6: Ipv6Addr) -> AddressClass {
159    let s = v6.segments();
160    if v6.is_loopback() {
161        return AddressClass::Loopback;
162    }
163    if v6.is_unspecified() {
164        return AddressClass::Unspecified;
165    }
166    // ::ffff:a.b.c.d (IPv4-mapped).
167    if let Some(v4) = v6.to_ipv4_mapped() {
168        return classify_v4(v4);
169    }
170    // ::a.b.c.d (deprecated IPv4-compatible).
171    if s[..6] == [0, 0, 0, 0, 0, 0] {
172        return classify_v4(v4_from_segments(s[6], s[7]));
173    }
174    // 64:ff9b::/96 (NAT64) embeds an IPv4 address in the low 32 bits.
175    if s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
176        return classify_v4(v4_from_segments(s[6], s[7]));
177    }
178    // 2002::/16 (6to4) embeds an IPv4 address in bits 16..48.
179    if s[0] == 0x2002 {
180        return classify_v4(v4_from_segments(s[1], s[2]));
181    }
182    if v6.is_multicast() {
183        AddressClass::Multicast
184    } else if s[0] & 0xffc0 == 0xfe80 {
185        AddressClass::LinkLocal
186    } else if s[0] & 0xfe00 == 0xfc00 || s[0] & 0xffc0 == 0xfec0 {
187        AddressClass::Private
188    } else if (s[0] == 0x2001 && s[1] == 0x0db8) || (s[0] == 0x2001 && s[1] == 0) {
189        AddressClass::Reserved
190    } else {
191        AddressClass::Public
192    }
193}
194
195/// Hostnames that always mean "cloud instance metadata".
196fn is_metadata_host(host: &str) -> bool {
197    matches!(
198        host,
199        "metadata" | "metadata.google.internal" | "metadata.goog" | "instance-data"
200    ) || host.ends_with(".metadata.google.internal")
201}
202
203/// Lowercase, strip IPv6 brackets and one trailing dot.
204fn normalize_host(host: &str) -> String {
205    let h = host.trim_matches(['[', ']']).to_ascii_lowercase();
206    h.strip_suffix('.').map(str::to_string).unwrap_or(h)
207}
208
209// ---------------------------------------------------------------------------
210// Network policy (policy stage)
211// ---------------------------------------------------------------------------
212
213/// Why the network policy refused a request.
214#[derive(Clone, Debug, Eq, PartialEq)]
215pub enum BlockReason {
216    InvalidUrl(String),
217    /// The scheme is not in `allowed_schemes`.
218    Scheme(String),
219    NoHost,
220    /// The host has no addresses (fail closed).
221    Unresolved {
222        host: String,
223    },
224    /// The host is, or resolves to, a blocked address.
225    Address {
226        host: String,
227        ip: IpAddr,
228        class: AddressClass,
229    },
230    /// A cloud metadata hostname.
231    MetadataHost {
232        host: String,
233    },
234    /// The per-request callback denied it.
235    Callback(String),
236}
237
238impl BlockReason {
239    /// True when no allow list, class allowance, or callback can override it.
240    pub fn is_hard(&self, hard_block_link_local: bool) -> bool {
241        hard_block_link_local
242            && matches!(
243                self,
244                BlockReason::MetadataHost { .. }
245                    | BlockReason::Address {
246                        class: AddressClass::LinkLocal,
247                        ..
248                    }
249            )
250    }
251}
252
253impl fmt::Display for BlockReason {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        match self {
256            BlockReason::InvalidUrl(e) => write!(f, "unparseable url: {e}"),
257            BlockReason::Scheme(s) => write!(f, "scheme '{s}' is blocked"),
258            BlockReason::NoHost => write!(f, "request has no host"),
259            BlockReason::Unresolved { host } => write!(f, "host '{host}' did not resolve"),
260            BlockReason::Address { host, ip, class } => {
261                if host == &ip.to_string() {
262                    write!(f, "address {ip} is {class:?}")
263                } else {
264                    write!(f, "host '{host}' resolves to {class:?} address {ip}")
265                }
266            }
267            BlockReason::MetadataHost { host } => {
268                write!(f, "host '{host}' is a cloud metadata endpoint")
269            }
270            BlockReason::Callback(r) => write!(f, "denied by policy callback: {r}"),
271        }
272    }
273}
274
275/// Typed network-policy denial returned as [`crate::BrowserError::Blocked`].
276#[derive(Clone, Debug, Eq, PartialEq)]
277pub struct PolicyDenial {
278    pub url: String,
279    pub resource_type: String,
280    pub reason: BlockReason,
281}
282
283impl fmt::Display for PolicyDenial {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        write!(f, "{} ({}): {}", self.url, self.resource_type, self.reason)
286    }
287}
288
289/// One network request about to leave the browser, as seen by the allow callback.
290#[derive(Clone, Debug)]
291pub struct BrowserRequest {
292    pub url: String,
293    pub scheme: String,
294    /// Normalized host; empty for host-less schemes (`file:`, `data:`).
295    pub host: String,
296    pub port: Option<u16>,
297    /// Worst class across `addresses`; `None` for host-less schemes.
298    pub class: Option<AddressClass>,
299    /// Every address the host resolved to (all are checked).
300    pub addresses: Vec<IpAddr>,
301    /// `Navigation`, `Document`, `Image`, `Script`, `Fetch`, ...
302    pub resource_type: String,
303    /// What the defaults would do: `None` allows, `Some(reason)` blocks.
304    pub default_block: Option<BlockReason>,
305}
306
307/// Former name, kept for existing callers.
308pub type NetworkRequest = BrowserRequest;
309
310/// Answer of the per-request allow callback.
311#[derive(Clone, Debug, Eq, PartialEq)]
312pub enum PolicyDecision {
313    /// Allow, overriding a default block (hard blocks still apply).
314    Allow,
315    /// Deny, even if the defaults would allow.
316    Deny(String),
317    /// Apply the configured defaults.
318    Default,
319}
320
321/// `true` allows, `false` defers to the defaults.
322impl From<bool> for PolicyDecision {
323    fn from(b: bool) -> Self {
324        if b {
325            PolicyDecision::Allow
326        } else {
327            PolicyDecision::Default
328        }
329    }
330}
331
332pub type AllowCallback = Arc<dyn Fn(&BrowserRequest) -> PolicyDecision + Send + Sync>;
333pub type Resolver = Arc<dyn Fn(&str) -> Vec<IpAddr> + Send + Sync>;
334
335/// Per-host resolution shared by the request check and the pinning proxy.
336type ResolvedCache = Arc<Mutex<HashMap<String, (Instant, Vec<IpAddr>)>>>;
337
338/// Typed SSRF/host policy. See the module docs for defaults.
339#[derive(Clone)]
340pub struct NetworkPolicy {
341    /// Hosts (exact, case-insensitive) allowed even when their address class is blocked.
342    pub allow_hosts: Vec<String>,
343    /// Address classes allowed for every host (e.g. `Loopback` for local dev servers).
344    pub allow_classes: Vec<AddressClass>,
345    /// Lowercase schemes that may be requested. Default `["http", "https"]`.
346    pub allowed_schemes: Vec<String>,
347    /// Called for every checked request; see [`PolicyDecision`].
348    pub allow_request: Option<AllowCallback>,
349    /// Link-local and metadata destinations cannot be allowed (default `true`).
350    pub hard_block_link_local: bool,
351    /// Disable every check (trusted callers only).
352    pub unrestricted: bool,
353    /// Replaces the system resolver (offline tests, pinned DNS, split-horizon setups).
354    pub resolver: Option<Resolver>,
355    resolved: ResolvedCache,
356    /// Non-public addresses an explicit allow let through, per host.
357    approved: Arc<Mutex<HashMap<String, Vec<IpAddr>>>>,
358}
359
360impl Default for NetworkPolicy {
361    fn default() -> Self {
362        Self {
363            allow_hosts: Vec::new(),
364            allow_classes: Vec::new(),
365            allowed_schemes: vec!["http".into(), "https".into()],
366            allow_request: None,
367            hard_block_link_local: true,
368            unrestricted: false,
369            resolver: None,
370            resolved: ResolvedCache::default(),
371            approved: Arc::default(),
372        }
373    }
374}
375
376impl fmt::Debug for NetworkPolicy {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        f.debug_struct("NetworkPolicy")
379            .field("allow_hosts", &self.allow_hosts)
380            .field("allow_classes", &self.allow_classes)
381            .field("allowed_schemes", &self.allowed_schemes)
382            .field("allow_request", &self.allow_request.is_some())
383            .field("hard_block_link_local", &self.hard_block_link_local)
384            .field("unrestricted", &self.unrestricted)
385            .finish()
386    }
387}
388
389/// Copy of the old string-only blocked record, kept per page.
390#[derive(Clone, Debug, Eq, PartialEq)]
391pub struct BlockedRequest {
392    pub url: String,
393    pub reason: String,
394}
395
396impl NetworkPolicy {
397    /// Policy with every check off.
398    pub fn unrestricted() -> Self {
399        Self {
400            unrestricted: true,
401            ..Self::default()
402        }
403    }
404
405    pub fn allow_host(mut self, host: impl Into<String>) -> Self {
406        self.allow_hosts.push(normalize_host(&host.into()));
407        self
408    }
409
410    pub fn allow_class(mut self, class: AddressClass) -> Self {
411        self.allow_classes.push(class);
412        self
413    }
414
415    pub fn allow_scheme(mut self, scheme: impl Into<String>) -> Self {
416        self.allowed_schemes
417            .push(scheme.into().to_ascii_lowercase());
418        self
419    }
420
421    pub fn hard_block_link_local(mut self, on: bool) -> Self {
422        self.hard_block_link_local = on;
423        self
424    }
425
426    pub fn with_resolver(
427        mut self,
428        f: impl Fn(&str) -> Vec<IpAddr> + Send + Sync + 'static,
429    ) -> Self {
430        self.resolver = Some(Arc::new(f));
431        self
432    }
433
434    /// Per-request callback. Returning `bool` works too: `true` allows,
435    /// `false` defers to the defaults.
436    pub fn allow_with<D: Into<PolicyDecision>>(
437        mut self,
438        f: impl Fn(&BrowserRequest) -> D + Send + Sync + 'static,
439    ) -> Self {
440        self.allow_request = Some(Arc::new(move |r| f(r).into()));
441        self
442    }
443
444    fn scheme_allowed(&self, scheme: &str) -> bool {
445        self.allowed_schemes.iter().any(|s| s == scheme)
446    }
447
448    fn address_allowed(&self, host: &str, class: AddressClass) -> bool {
449        class == AddressClass::Public
450            || self.allow_classes.contains(&class)
451            || self.allow_hosts.iter().any(|h| h == host)
452    }
453
454    /// `Ok(())` when the request may proceed, otherwise why it is blocked.
455    pub async fn check(&self, url: &str, resource_type: &str) -> Result<(), BlockReason> {
456        if self.unrestricted {
457            return Ok(());
458        }
459        let parsed = url::Url::parse(url).map_err(|e| BlockReason::InvalidUrl(e.to_string()))?;
460        let scheme = parsed.scheme().to_ascii_lowercase();
461        let is_document = matches!(resource_type, "Navigation" | "Document");
462        if scheme == "about" && parsed.path() == "blank" {
463            return Ok(());
464        }
465        if matches!(scheme.as_str(), "data" | "blob") && !is_document {
466            return Ok(()); // inert subresource: never touches the network
467        }
468        let networked = matches!(scheme.as_str(), "http" | "https" | "ws" | "wss");
469        let mut default_block =
470            (!self.scheme_allowed(&scheme)).then(|| BlockReason::Scheme(scheme.clone()));
471        let mut host = String::new();
472        let mut class = None;
473        let mut addresses = Vec::new();
474        if networked {
475            host = normalize_host(parsed.host_str().unwrap_or(""));
476            if host.is_empty() {
477                return Err(BlockReason::NoHost);
478            }
479            if is_metadata_host(&host) {
480                let r = BlockReason::MetadataHost { host: host.clone() };
481                if r.is_hard(self.hard_block_link_local) {
482                    return Err(r);
483                }
484                default_block.get_or_insert(r);
485            }
486            let (c, worst, ips) = self.host_class(&host).await;
487            class = Some(c);
488            addresses = ips;
489            if addresses.is_empty() {
490                return Err(BlockReason::Unresolved { host });
491            }
492            if !self.address_allowed(&host, c)
493                || (c == AddressClass::LinkLocal && self.hard_block_link_local)
494            {
495                let r = BlockReason::Address {
496                    host: host.clone(),
497                    ip: worst,
498                    class: c,
499                };
500                if r.is_hard(self.hard_block_link_local) {
501                    return Err(r);
502                }
503                default_block.get_or_insert(r);
504            }
505        }
506        let decision = match &self.allow_request {
507            Some(cb) => cb(&BrowserRequest {
508                url: url.to_string(),
509                scheme: scheme.clone(),
510                host: host.clone(),
511                port: parsed.port_or_known_default(),
512                class,
513                addresses: addresses.clone(),
514                resource_type: resource_type.to_string(),
515                default_block: default_block.clone(),
516            }),
517            None => PolicyDecision::Default,
518        };
519        let allowed = match decision {
520            PolicyDecision::Allow => true,
521            PolicyDecision::Deny(r) => return Err(BlockReason::Callback(r)),
522            PolicyDecision::Default => match default_block {
523                Some(r) => return Err(r),
524                None => true,
525            },
526        };
527        if allowed && networked && class != Some(AddressClass::Public) {
528            self.approved
529                .lock()
530                .unwrap()
531                .entry(host)
532                .or_default()
533                .extend(addresses);
534        }
535        Ok(())
536    }
537
538    /// Addresses the proxy may connect to for `host`: the same cached
539    /// resolution the request check used. A non-public address passes only if
540    /// its class is allowed, the host is listed, or the request check
541    /// explicitly approved that exact address, so anything that bypassed
542    /// interception (workers, rebinding) cannot reach a blocked range.
543    pub(crate) async fn pin(&self, host: &str) -> Result<Vec<IpAddr>, BlockReason> {
544        let host = normalize_host(host);
545        if self.hard_block_link_local && is_metadata_host(&host) {
546            return Err(BlockReason::MetadataHost { host });
547        }
548        let (_, _, ips) = self.host_class(&host).await;
549        if ips.is_empty() {
550            return Err(BlockReason::Unresolved { host });
551        }
552        let approved = self
553            .approved
554            .lock()
555            .unwrap()
556            .get(&host)
557            .cloned()
558            .unwrap_or_default();
559        for ip in &ips {
560            let class = classify_ip(*ip);
561            let hard = self.hard_block_link_local && class == AddressClass::LinkLocal;
562            let ok = !hard && (self.address_allowed(&host, class) || approved.contains(ip));
563            if !ok {
564                return Err(BlockReason::Address {
565                    host,
566                    ip: *ip,
567                    class,
568                });
569            }
570        }
571        Ok(ips)
572    }
573
574    /// Worst class across the host's addresses (any blocked address blocks the
575    /// host), the address that produced it, and every address.
576    async fn host_class(&self, host: &str) -> (AddressClass, IpAddr, Vec<IpAddr>) {
577        let unspecified = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
578        if let Ok(ip) = host.parse::<IpAddr>() {
579            return (classify_ip(ip), ip, vec![ip]);
580        }
581        if host == "localhost" || host.ends_with(".localhost") {
582            let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
583            return (AddressClass::Loopback, ip, vec![ip]);
584        }
585        let addrs = self.resolve(host).await;
586        let worst = addrs
587            .iter()
588            .copied()
589            .find(|ip| classify_ip(*ip) != AddressClass::Public);
590        match worst {
591            Some(ip) => (classify_ip(ip), ip, addrs),
592            None => (
593                AddressClass::Public,
594                addrs.first().copied().unwrap_or(unspecified),
595                addrs,
596            ),
597        }
598    }
599
600    async fn resolve(&self, host: &str) -> Vec<IpAddr> {
601        const TTL: Duration = Duration::from_secs(30);
602        if let Some((at, v)) = self.resolved.lock().unwrap().get(host) {
603            if at.elapsed() < TTL {
604                return v.clone();
605            }
606        }
607        let found: Vec<IpAddr> = if let Some(r) = &self.resolver {
608            r(host)
609        } else {
610            match tokio::time::timeout(Duration::from_secs(3), tokio::net::lookup_host((host, 0)))
611                .await
612            {
613                Ok(Ok(it)) => it.map(|a| a.ip()).collect(),
614                _ => Vec::new(), // fail closed: an unresolved host is refused
615            }
616        };
617        self.resolved
618            .lock()
619            .unwrap()
620            .insert(host.to_string(), (Instant::now(), found.clone()));
621        found
622    }
623}
624
625// ---------------------------------------------------------------------------
626// Lifecycle events
627// ---------------------------------------------------------------------------
628
629#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
630pub enum LifecycleKind {
631    Start,
632    Stop,
633    /// Chrome or a renderer died without being asked to.
634    Crash,
635    /// Admission or network policy refused something before any side effect.
636    Denied,
637}
638
639impl LifecycleKind {
640    pub fn label(self) -> &'static str {
641        match self {
642            LifecycleKind::Start => "start",
643            LifecycleKind::Stop => "stop",
644            LifecycleKind::Crash => "crash",
645            LifecycleKind::Denied => "denied",
646        }
647    }
648}
649
650/// One structured, content-free lifecycle event for the caller's journal.
651/// Never carries typed text, page content, or full URLs.
652#[derive(Clone, Debug)]
653pub struct BrowserEvent {
654    pub kind: LifecycleKind,
655    pub session_id: String,
656    pub reason: String,
657    pub at: SystemTime,
658    pub page_id: Option<String>,
659    /// Chrome's OS process id (start/stop/crash).
660    pub pid: Option<u32>,
661    /// What was denied: an [`ActionKind`] name, a resource type, or `Connect`.
662    pub what: Option<String>,
663    /// Typed network-policy reason for `Denied` events from the network policy.
664    pub blocked: Option<BlockReason>,
665}
666
667impl BrowserEvent {
668    pub(crate) fn new(kind: LifecycleKind, session_id: &str, reason: impl Into<String>) -> Self {
669        Self {
670            kind,
671            session_id: session_id.to_string(),
672            reason: reason.into(),
673            at: SystemTime::now(),
674            page_id: None,
675            pid: None,
676            what: None,
677            blocked: None,
678        }
679    }
680
681    pub fn timestamp_ms(&self) -> u64 {
682        self.at
683            .duration_since(UNIX_EPOCH)
684            .map(|d| d.as_millis() as u64)
685            .unwrap_or(0)
686    }
687
688    pub fn to_json(&self) -> serde_json::Value {
689        serde_json::json!({
690            "event": self.kind.label(),
691            "session_id": self.session_id,
692            "reason": self.reason,
693            "ts_ms": self.timestamp_ms(),
694            "page_id": self.page_id,
695            "pid": self.pid,
696            "what": self.what,
697        })
698    }
699}
700
701pub type EventSink = Arc<dyn Fn(&BrowserEvent) + Send + Sync>;
702
703/// Everything pages share about effect control.
704pub(crate) struct Guard {
705    pub session_id: String,
706    pub admission: Option<AdmissionHook>,
707    pub network: NetworkPolicy,
708    pub events: Option<EventSink>,
709}
710
711impl Guard {
712    pub fn emit(&self, e: BrowserEvent) {
713        if let Some(s) = &self.events {
714            s(&e);
715        }
716    }
717
718    pub fn emit_kind(
719        &self,
720        kind: LifecycleKind,
721        reason: impl Into<String>,
722        f: impl FnOnce(&mut BrowserEvent),
723    ) {
724        let mut e = BrowserEvent::new(kind, &self.session_id, reason);
725        f(&mut e);
726        self.emit(e);
727    }
728
729    pub fn denied_network(&self, page_id: Option<&str>, what: &str, reason: &BlockReason) {
730        self.emit_kind(LifecycleKind::Denied, reason.to_string(), |e| {
731            e.page_id = page_id.map(str::to_string);
732            e.what = Some(what.to_string());
733            e.blocked = Some(reason.clone());
734        });
735    }
736
737    pub async fn admit(&self, req: AdmissionRequest) -> crate::error::Result<()> {
738        let what = format!("{:?}", req.action);
739        let page_id = req.page_id.clone();
740        if let Some(hook) = &self.admission {
741            if let Decision::Deny(reason) = hook(req).await {
742                self.emit_kind(LifecycleKind::Denied, reason.clone(), |e| {
743                    e.page_id = Some(page_id);
744                    e.what = Some(what);
745                });
746                return Err(crate::error::BrowserError::Denied(reason));
747            }
748        }
749        Ok(())
750    }
751
752    pub async fn check_url(
753        &self,
754        page_id: Option<&str>,
755        url: &str,
756        kind: &str,
757    ) -> crate::error::Result<()> {
758        match self.network.check(url, kind).await {
759            Ok(()) => Ok(()),
760            Err(reason) => {
761                self.denied_network(page_id, kind, &reason);
762                Err(crate::error::BrowserError::Blocked(PolicyDenial {
763                    url: url.to_string(),
764                    resource_type: kind.to_string(),
765                    reason,
766                }))
767            }
768        }
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    fn ip(s: &str) -> IpAddr {
777        s.parse().unwrap()
778    }
779
780    fn block_on<F: std::future::Future>(f: F) -> F::Output {
781        tokio::runtime::Builder::new_current_thread()
782            .enable_all()
783            .build()
784            .unwrap()
785            .block_on(f)
786    }
787
788    /// Offline policy: `public.example` is public, `rebind.example` resolves to
789    /// one public and one private address, `meta.example` to the metadata IP.
790    fn offline() -> NetworkPolicy {
791        NetworkPolicy::default().with_resolver(|h| match h {
792            "public.example" => vec![ip("93.184.216.34")],
793            "rebind.example" => vec![ip("93.184.216.34"), ip("10.0.0.7")],
794            "meta.example" => vec![ip("169.254.169.254")],
795            "mapped.example" => vec![ip("::ffff:192.168.1.1")],
796            "dev.example" => vec![ip("127.0.0.1")],
797            _ => vec![],
798        })
799    }
800
801    fn check(p: &NetworkPolicy, url: &str) -> Result<(), BlockReason> {
802        block_on(p.check(url, "Navigation"))
803    }
804
805    #[test]
806    fn classifies_every_blocked_v4_range() {
807        let cases = [
808            ("127.0.0.1", AddressClass::Loopback),
809            ("127.255.255.254", AddressClass::Loopback),
810            ("169.254.169.254", AddressClass::LinkLocal),
811            ("169.254.0.1", AddressClass::LinkLocal),
812            ("10.0.0.1", AddressClass::Private),
813            ("10.255.255.255", AddressClass::Private),
814            ("172.16.0.1", AddressClass::Private),
815            ("172.31.255.255", AddressClass::Private),
816            ("192.168.0.1", AddressClass::Private),
817            ("100.64.0.1", AddressClass::Private),
818            ("100.127.255.255", AddressClass::Private),
819            ("0.0.0.0", AddressClass::Unspecified),
820            ("0.1.2.3", AddressClass::Unspecified),
821            ("224.0.0.1", AddressClass::Multicast),
822            ("255.255.255.255", AddressClass::Reserved),
823            ("240.0.0.1", AddressClass::Reserved),
824            ("192.0.2.1", AddressClass::Reserved),
825            ("198.18.0.1", AddressClass::Reserved),
826            ("198.51.100.1", AddressClass::Reserved),
827            ("203.0.113.1", AddressClass::Reserved),
828        ];
829        for (s, want) in cases {
830            assert_eq!(classify_ip(ip(s)), want, "{s}");
831        }
832        for s in [
833            "8.8.8.8",
834            "93.184.216.34",
835            "172.32.0.1",
836            "172.15.255.255",
837            "100.63.255.255",
838            "100.128.0.1",
839            "169.253.1.1",
840        ] {
841            assert_eq!(classify_ip(ip(s)), AddressClass::Public, "{s}");
842        }
843    }
844
845    #[test]
846    fn classifies_every_blocked_v6_range_and_embedded_v4() {
847        let cases = [
848            ("::1", AddressClass::Loopback),
849            ("::", AddressClass::Unspecified),
850            ("fe80::1", AddressClass::LinkLocal),
851            ("febf::1", AddressClass::LinkLocal),
852            ("fc00::1", AddressClass::Private),
853            ("fd00:ec2::254", AddressClass::Private),
854            ("fec0::1", AddressClass::Private),
855            ("ff02::1", AddressClass::Multicast),
856            ("2001:db8::1", AddressClass::Reserved),
857            // IPv4-mapped.
858            ("::ffff:127.0.0.1", AddressClass::Loopback),
859            ("::ffff:169.254.169.254", AddressClass::LinkLocal),
860            ("::ffff:10.1.2.3", AddressClass::Private),
861            ("::ffff:192.168.1.1", AddressClass::Private),
862            ("::ffff:0.0.0.0", AddressClass::Unspecified),
863            // IPv4-compatible, NAT64, 6to4.
864            ("::127.0.0.1", AddressClass::Loopback),
865            ("64:ff9b::a9fe:a9fe", AddressClass::LinkLocal),
866            ("64:ff9b::7f00:1", AddressClass::Loopback),
867            ("2002:c0a8:0101::1", AddressClass::Private),
868            ("2002:7f00:0001::1", AddressClass::Loopback),
869        ];
870        for (s, want) in cases {
871            assert_eq!(classify_ip(ip(s)), want, "{s}");
872        }
873        for s in [
874            "2606:4700:4700::1111",
875            "::ffff:8.8.8.8",
876            "64:ff9b::808:808",
877            "2002:0808:0808::1",
878        ] {
879            assert_eq!(classify_ip(ip(s)), AddressClass::Public, "{s}");
880        }
881    }
882
883    #[test]
884    fn default_policy_blocks_literals_localhost_and_metadata() {
885        let p = offline();
886        for url in [
887            "http://127.0.0.1/",
888            "http://127.1.2.3:8080/x",
889            "http://localhost/",
890            "http://LOCALHOST./",
891            "http://app.localhost/",
892            "http://[::1]/",
893            "http://169.254.169.254/latest/meta-data/",
894            "http://[fe80::1]/",
895            "http://10.0.0.5/",
896            "http://172.20.1.1/",
897            "http://192.168.1.1/",
898            "http://100.64.1.1/",
899            "http://[fc00::1]/",
900            "http://0.0.0.0/",
901            "http://[::]/",
902            "http://[::ffff:127.0.0.1]/",
903            "http://[::ffff:169.254.169.254]/",
904            "http://[::ffff:10.0.0.1]/",
905            "http://2130706433/", // decimal 127.0.0.1
906            "http://0x7f.0.0.1/", // hex octet
907            "http://metadata.google.internal/computeMetadata/v1/",
908        ] {
909            let r = check(&p, url);
910            assert!(r.is_err(), "{url} was allowed");
911        }
912        assert_eq!(check(&p, "https://public.example/"), Ok(()));
913        assert_eq!(check(&p, "http://8.8.8.8/"), Ok(()));
914    }
915
916    #[test]
917    fn every_resolved_address_is_checked_and_mapped_v6_resolution_blocks() {
918        let p = offline();
919        match check(&p, "https://rebind.example/") {
920            Err(BlockReason::Address { ip: a, class, .. }) => {
921                assert_eq!(a, ip("10.0.0.7"));
922                assert_eq!(class, AddressClass::Private);
923            }
924            other => panic!("{other:?}"),
925        }
926        assert!(matches!(
927            check(&p, "https://mapped.example/"),
928            Err(BlockReason::Address {
929                class: AddressClass::Private,
930                ..
931            })
932        ));
933        assert!(matches!(
934            check(&p, "https://meta.example/"),
935            Err(BlockReason::Address {
936                class: AddressClass::LinkLocal,
937                ..
938            })
939        ));
940        // No answer fails closed.
941        assert_eq!(
942            check(&p, "https://nowhere.example/"),
943            Err(BlockReason::Unresolved {
944                host: "nowhere.example".into()
945            })
946        );
947    }
948
949    #[test]
950    fn schemes_other_than_http_are_blocked_unless_allowed() {
951        let p = offline();
952        for url in [
953            "file:///etc/passwd",
954            "chrome://settings",
955            "ftp://public.example/",
956            "ws://public.example/",
957            "wss://public.example/",
958            "javascript:alert(1)",
959            "about:config",
960        ] {
961            assert!(
962                matches!(check(&p, url), Err(BlockReason::Scheme(_))),
963                "{url}: {:?}",
964                check(&p, url)
965            );
966        }
967        // Inert: about:blank, data:/blob: subresources. data: documents are blocked.
968        assert_eq!(check(&p, "about:blank"), Ok(()));
969        assert_eq!(
970            block_on(p.check("data:image/png;base64,AA==", "Image")),
971            Ok(())
972        );
973        assert!(matches!(
974            block_on(p.check("data:text/html,<h1>x</h1>", "Document")),
975            Err(BlockReason::Scheme(_))
976        ));
977        let p = offline().allow_scheme("file").allow_scheme("wss");
978        assert_eq!(check(&p, "file:///tmp/x.html"), Ok(()));
979        assert_eq!(check(&p, "wss://public.example/socket"), Ok(()));
980        // Allowing the scheme does not relax the address check.
981        assert!(check(&p, "wss://127.0.0.1/").is_err());
982    }
983
984    #[test]
985    fn allow_callback_overrides_both_ways_but_not_hard_blocks() {
986        let seen: Arc<Mutex<Vec<BrowserRequest>>> = Arc::default();
987        let s2 = seen.clone();
988        let p = offline().allow_with(move |r: &BrowserRequest| {
989            s2.lock().unwrap().push(r.clone());
990            match r.host.as_str() {
991                "127.0.0.1" if r.port == Some(3000) => PolicyDecision::Allow,
992                "public.example" => PolicyDecision::Deny("not on the list".into()),
993                "169.254.169.254" | "meta.example" => PolicyDecision::Allow,
994                _ => PolicyDecision::Default,
995            }
996        });
997        assert_eq!(check(&p, "http://127.0.0.1:3000/"), Ok(()));
998        assert!(check(&p, "http://127.0.0.1:3001/").is_err());
999        assert_eq!(
1000            check(&p, "https://public.example/"),
1001            Err(BlockReason::Callback("not on the list".into()))
1002        );
1003        // Hard blocks win over the callback, which is not even consulted.
1004        let before = seen.lock().unwrap().len();
1005        assert!(check(&p, "http://169.254.169.254/").is_err());
1006        assert!(check(&p, "https://meta.example/").is_err());
1007        assert_eq!(seen.lock().unwrap().len(), before);
1008        // The callback sees what the defaults would do.
1009        let s = seen.lock().unwrap();
1010        let r = s.iter().find(|r| r.port == Some(3000)).unwrap();
1011        assert_eq!(r.class, Some(AddressClass::Loopback));
1012        assert!(matches!(r.default_block, Some(BlockReason::Address { .. })));
1013        let r = s.iter().find(|r| r.host == "public.example").unwrap();
1014        assert_eq!(r.default_block, None);
1015        drop(s);
1016
1017        // bool callbacks keep working: false defers to the defaults.
1018        let p = offline().allow_with(|r: &BrowserRequest| r.host == "10.0.0.5");
1019        assert_eq!(check(&p, "http://10.0.0.5/"), Ok(()));
1020        assert!(check(&p, "http://10.0.0.6/").is_err());
1021        assert_eq!(check(&p, "https://public.example/"), Ok(()));
1022
1023        // Opting out of the hard block makes link-local overridable.
1024        let p = offline()
1025            .hard_block_link_local(false)
1026            .allow_host("169.254.169.254");
1027        assert_eq!(check(&p, "http://169.254.169.254/"), Ok(()));
1028    }
1029
1030    #[test]
1031    fn class_and_host_allowances() {
1032        let p = offline().allow_class(AddressClass::Loopback);
1033        assert_eq!(check(&p, "http://localhost:5173/"), Ok(()));
1034        assert_eq!(check(&p, "https://dev.example/"), Ok(()));
1035        assert!(check(&p, "http://10.0.0.1/").is_err());
1036        assert!(check(&p, "http://169.254.169.254/").is_err());
1037        let p = offline().allow_host("Rebind.Example.");
1038        assert_eq!(check(&p, "https://rebind.example/"), Ok(()));
1039        // Even listed, a link-local destination stays blocked.
1040        let p = offline()
1041            .allow_host("meta.example")
1042            .allow_class(AddressClass::LinkLocal);
1043        assert!(check(&p, "https://meta.example/").is_err());
1044    }
1045
1046    #[test]
1047    fn pin_only_connects_to_vetted_addresses() {
1048        let p = offline();
1049        // Public host pins.
1050        assert_eq!(
1051            block_on(p.pin("public.example")).unwrap(),
1052            vec![ip("93.184.216.34")]
1053        );
1054        // Private resolution that the request check never approved is refused at connect time.
1055        assert!(block_on(p.pin("rebind.example")).is_err());
1056        assert!(block_on(p.pin("dev.example")).is_err());
1057        assert!(block_on(p.pin("metadata.google.internal")).is_err());
1058        assert!(block_on(p.pin("nowhere.example")).is_err());
1059        // After an explicit per-request allow, exactly those addresses pin.
1060        let p = offline().allow_with(|r: &BrowserRequest| r.host == "dev.example");
1061        assert_eq!(check(&p, "https://dev.example/"), Ok(()));
1062        assert_eq!(
1063            block_on(p.pin("dev.example")).unwrap(),
1064            vec![ip("127.0.0.1")]
1065        );
1066    }
1067
1068    #[test]
1069    fn rebinding_after_check_is_refused_at_connect() {
1070        // First answer public, every later answer private: the check passes,
1071        // the cache is bypassed by expiry, and pin must still refuse.
1072        let n = Arc::new(Mutex::new(0u32));
1073        let n2 = n.clone();
1074        let p = NetworkPolicy::default().with_resolver(move |_| {
1075            let mut g = n2.lock().unwrap();
1076            *g += 1;
1077            if *g == 1 {
1078                vec![ip("93.184.216.34")]
1079            } else {
1080                vec![ip("10.9.9.9")]
1081            }
1082        });
1083        assert_eq!(check(&p, "https://flip.example/"), Ok(()));
1084        p.resolved.lock().unwrap().clear(); // simulate TTL expiry
1085        assert!(matches!(
1086            block_on(p.pin("flip.example")),
1087            Err(BlockReason::Address {
1088                class: AddressClass::Private,
1089                ..
1090            })
1091        ));
1092    }
1093
1094    #[test]
1095    fn unrestricted_allows_everything() {
1096        let p = NetworkPolicy::unrestricted();
1097        assert_eq!(check(&p, "file:///etc/hosts"), Ok(()));
1098        assert_eq!(check(&p, "http://169.254.169.254/"), Ok(()));
1099    }
1100
1101    #[test]
1102    fn denials_emit_typed_events_with_session_reason_and_timestamp() {
1103        let events: Arc<Mutex<Vec<BrowserEvent>>> = Arc::default();
1104        let e2 = events.clone();
1105        let g = Guard {
1106            session_id: "bs-test".into(),
1107            admission: Some(Arc::new(|_req| {
1108                Box::pin(async { Decision::Deny("needs approval".into()) })
1109            })),
1110            network: offline(),
1111            events: Some(Arc::new(move |e: &BrowserEvent| {
1112                e2.lock().unwrap().push(e.clone())
1113            })),
1114        };
1115        let before = SystemTime::now();
1116        let err = block_on(g.check_url(Some("p1"), "http://10.0.0.1/", "Navigation")).unwrap_err();
1117        match err {
1118            crate::BrowserError::Blocked(d) => {
1119                assert_eq!(d.resource_type, "Navigation");
1120                assert!(matches!(
1121                    d.reason,
1122                    BlockReason::Address {
1123                        class: AddressClass::Private,
1124                        ..
1125                    }
1126                ));
1127            }
1128            other => panic!("{other:?}"),
1129        }
1130        let err = block_on(g.admit(AdmissionRequest {
1131            session_id: "bs-test".into(),
1132            page_id: "p1".into(),
1133            action: ActionKind::Click,
1134            url: None,
1135            target: None,
1136            detail: None,
1137        }))
1138        .unwrap_err();
1139        assert!(matches!(err, crate::BrowserError::Denied(ref r) if r == "needs approval"));
1140        let ev = events.lock().unwrap();
1141        assert_eq!(ev.len(), 2);
1142        for e in ev.iter() {
1143            assert_eq!(e.kind, LifecycleKind::Denied);
1144            assert_eq!(e.session_id, "bs-test");
1145            assert_eq!(e.page_id.as_deref(), Some("p1"));
1146            assert!(!e.reason.is_empty());
1147            assert!(e.at >= before);
1148            assert!(e.timestamp_ms() > 0);
1149            let j = e.to_json();
1150            assert_eq!(j["event"], "denied");
1151            assert_eq!(j["session_id"], "bs-test");
1152        }
1153        assert!(ev[0].blocked.is_some());
1154        assert_eq!(ev[0].what.as_deref(), Some("Navigation"));
1155        assert_eq!(ev[1].what.as_deref(), Some("Click"));
1156        assert!(ev[1].blocked.is_none());
1157    }
1158}