Skip to main content

rightkit_browser/
policy.rs

1//! Effect admission and network (SSRF) policy.
2//!
3//! Every navigation, input, and script action passes a caller-supplied
4//! [`AdmissionHook`] as a typed [`AdmissionRequest`] before any side effect
5//! (validate, approve, execute, settle). Every network request the page makes,
6//! including redirect hops and subresources, is checked against a
7//! [`NetworkPolicy`] inside the browser, before it leaves the machine.
8//!
9//! Default policy blocks loopback, link-local, private, unspecified, multicast
10//! addresses, `localhost`, and every scheme that reaches local state
11//! (`file:`, `chrome:`, `ftp:` ...). A caller opens specific targets with
12//! `allow_hosts` or a per-request allow callback.
13//!
14//! Known limit: hostnames are vetted by a resolver lookup at request time and
15//! Chrome may resolve again when it connects (DNS rebinding window); dedicated
16//! workers are not intercepted. Pop-up windows are blocked outright so no
17//! unmanaged page can bypass interception.
18
19use futures::future::BoxFuture;
20use std::collections::HashMap;
21use std::fmt;
22use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
27pub enum ActionKind {
28    Navigate,
29    Reload,
30    History,
31    Click,
32    DoubleClick,
33    Hover,
34    Drag,
35    Wheel,
36    Press,
37    TypeText,
38    Fill,
39    Select,
40    Upload,
41    Eval,
42}
43
44/// One typed request to perform an effect. Typed text is never included, only
45/// its length, so admission logs stay content-free.
46#[derive(Clone, Debug)]
47pub struct AdmissionRequest {
48    pub session_id: String,
49    pub page_id: String,
50    pub action: ActionKind,
51    /// Destination for `Navigate`; otherwise the page's current URL.
52    pub url: Option<String>,
53    /// Human-readable target (selector, ref, point, key chord, file path).
54    pub target: Option<String>,
55    /// Extra detail: script source for `Eval`, `chars=N` for typing.
56    pub detail: Option<String>,
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum Decision {
61    Allow,
62    Deny(String),
63}
64
65pub type AdmissionHook =
66    Arc<dyn Fn(AdmissionRequest) -> BoxFuture<'static, Decision> + Send + Sync>;
67
68/// Why an address class is blocked.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum AddressClass {
71    Public,
72    Loopback,
73    LinkLocal,
74    Private,
75    Unspecified,
76    Multicast,
77}
78
79/// One network request about to leave the browser.
80#[derive(Clone, Debug)]
81pub struct NetworkRequest {
82    pub url: String,
83    pub scheme: String,
84    pub host: String,
85    pub port: Option<u16>,
86    pub class: AddressClass,
87    /// `Document`, `Image`, `Script`, `Fetch`, ... or `Navigation`.
88    pub resource_type: String,
89}
90
91pub type AllowCallback = Arc<dyn Fn(&NetworkRequest) -> bool + Send + Sync>;
92
93/// Per-host resolution shared by the request check and the pinning proxy.
94type ResolvedCache = Arc<Mutex<HashMap<String, (Instant, Vec<IpAddr>)>>>;
95
96#[derive(Clone, Default)]
97pub struct NetworkPolicy {
98    /// Hosts (exact, case-insensitive) allowed even when their address class is blocked.
99    pub allow_hosts: Vec<String>,
100    /// Called only for requests the defaults would block; `true` allows.
101    pub allow_request: Option<AllowCallback>,
102    /// Disable every check (trusted callers only).
103    pub unrestricted: bool,
104    /// Replaces the system resolver (offline tests, pinned DNS, split-horizon setups).
105    pub resolver: Option<Resolver>,
106    /// One resolution per host is shared by the request check and the pinning
107    /// proxy, so the address that was vetted is the address that is connected.
108    resolved: ResolvedCache,
109    /// Non-public addresses an explicit allow let through, per host.
110    approved: Arc<Mutex<HashMap<String, Vec<IpAddr>>>>,
111}
112
113pub type Resolver = Arc<dyn Fn(&str) -> Vec<IpAddr> + Send + Sync>;
114
115impl fmt::Debug for NetworkPolicy {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.debug_struct("NetworkPolicy")
118            .field("allow_hosts", &self.allow_hosts)
119            .field("allow_request", &self.allow_request.is_some())
120            .field("unrestricted", &self.unrestricted)
121            .finish()
122    }
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct BlockedRequest {
127    pub url: String,
128    pub reason: String,
129}
130
131pub fn classify_ip(ip: IpAddr) -> AddressClass {
132    match ip {
133        IpAddr::V4(v4) => classify_v4(v4),
134        IpAddr::V6(v6) => {
135            if let Some(v4) = v6.to_ipv4_mapped() {
136                return classify_v4(v4);
137            }
138            let seg = v6.segments();
139            if v6.is_loopback() {
140                AddressClass::Loopback
141            } else if v6.is_unspecified() {
142                AddressClass::Unspecified
143            } else if v6.is_multicast() {
144                AddressClass::Multicast
145            } else if seg[0] & 0xffc0 == 0xfe80 {
146                AddressClass::LinkLocal
147            } else if seg[0] & 0xfe00 == 0xfc00 || is_nat64_private(v6) {
148                AddressClass::Private
149            } else {
150                AddressClass::Public
151            }
152        }
153    }
154}
155
156/// 64:ff9b::/96 embeds an IPv4 address; classify the embedded one.
157fn is_nat64_private(v6: Ipv6Addr) -> bool {
158    let s = v6.segments();
159    if s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
160        let v4 = Ipv4Addr::new((s[6] >> 8) as u8, s[6] as u8, (s[7] >> 8) as u8, s[7] as u8);
161        return classify_v4(v4) != AddressClass::Public;
162    }
163    false
164}
165
166fn classify_v4(ip: Ipv4Addr) -> AddressClass {
167    let o = ip.octets();
168    if ip.is_loopback() {
169        AddressClass::Loopback
170    } else if ip.is_unspecified() || o[0] == 0 {
171        AddressClass::Unspecified
172    } else if ip.is_link_local() {
173        AddressClass::LinkLocal
174    } else if ip.is_private() || (o[0] == 100 && (64..128).contains(&o[1])) {
175        AddressClass::Private
176    } else if ip.is_multicast() || ip.is_broadcast() || o[0] >= 240 {
177        AddressClass::Multicast
178    } else {
179        AddressClass::Public
180    }
181}
182
183impl NetworkPolicy {
184    /// Policy with every check off.
185    pub fn unrestricted() -> Self {
186        Self {
187            unrestricted: true,
188            ..Self::default()
189        }
190    }
191
192    pub fn allow_host(mut self, host: impl Into<String>) -> Self {
193        self.allow_hosts.push(host.into().to_ascii_lowercase());
194        self
195    }
196
197    pub fn with_resolver(
198        mut self,
199        f: impl Fn(&str) -> Vec<IpAddr> + Send + Sync + 'static,
200    ) -> Self {
201        self.resolver = Some(Arc::new(f));
202        self
203    }
204
205    pub fn allow_with(
206        mut self,
207        f: impl Fn(&NetworkRequest) -> bool + Send + Sync + 'static,
208    ) -> Self {
209        self.allow_request = Some(Arc::new(f));
210        self
211    }
212
213    /// `Ok(())` when the request may proceed, otherwise the reason it is blocked.
214    pub async fn check(&self, url: &str, resource_type: &str) -> std::result::Result<(), String> {
215        if self.unrestricted {
216            return Ok(());
217        }
218        let parsed = url::Url::parse(url).map_err(|e| format!("unparseable url: {e}"))?;
219        let scheme = parsed.scheme().to_ascii_lowercase();
220        // Schemes that never touch the network or other local state.
221        if matches!(scheme.as_str(), "about" | "data" | "blob") {
222            if scheme == "about" && parsed.path() != "blank" {
223                return self.decide(
224                    url,
225                    &scheme,
226                    "",
227                    None,
228                    AddressClass::Loopback,
229                    resource_type,
230                    "about: page",
231                );
232            }
233            return Ok(());
234        }
235        if !matches!(scheme.as_str(), "http" | "https" | "ws" | "wss") {
236            let reason = format!("scheme '{scheme}' is blocked");
237            return self.decide(
238                url,
239                &scheme,
240                "",
241                None,
242                AddressClass::Loopback,
243                resource_type,
244                &reason,
245            );
246        }
247        let host = parsed
248            .host_str()
249            .unwrap_or("")
250            .trim_matches(['[', ']'])
251            .to_ascii_lowercase();
252        if host.is_empty() {
253            return Err("request has no host".into());
254        }
255        let (class, ips) = self.host_class(&host).await;
256        if class == AddressClass::Public {
257            return Ok(());
258        }
259        let reason = format!("host '{host}' is {class:?}");
260        self.decide(
261            url,
262            &scheme,
263            &host,
264            parsed.port_or_known_default(),
265            class,
266            resource_type,
267            &reason,
268        )?;
269        self.approved.lock().unwrap().insert(host, ips);
270        Ok(())
271    }
272
273    /// Addresses the proxy may connect to for `host`: the same cached
274    /// resolution the request check used. A non-public address passes only if
275    /// the request check explicitly allowed it, so anything that bypassed
276    /// interception (workers, rebinding) cannot reach private ranges.
277    pub(crate) async fn pin(&self, host: &str) -> std::result::Result<Vec<IpAddr>, String> {
278        let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
279        let (_, ips) = self.host_class(&host).await;
280        if ips.is_empty() {
281            return Err(format!("host '{host}' did not resolve"));
282        }
283        let approved = self
284            .approved
285            .lock()
286            .unwrap()
287            .get(&host)
288            .cloned()
289            .unwrap_or_default();
290        let listed = self.allow_hosts.contains(&host);
291        let ok: Vec<IpAddr> = ips
292            .iter()
293            .copied()
294            .filter(|ip| {
295                classify_ip(*ip) == AddressClass::Public || listed || approved.contains(ip)
296            })
297            .collect();
298        if ok.len() == ips.len() {
299            Ok(ok)
300        } else {
301            Err(format!("host '{host}' resolves to a blocked address"))
302        }
303    }
304
305    #[allow(clippy::too_many_arguments)]
306    fn decide(
307        &self,
308        url: &str,
309        scheme: &str,
310        host: &str,
311        port: Option<u16>,
312        class: AddressClass,
313        resource_type: &str,
314        reason: &str,
315    ) -> std::result::Result<(), String> {
316        if !host.is_empty() && self.allow_hosts.iter().any(|h| h == host) {
317            return Ok(());
318        }
319        if let Some(cb) = &self.allow_request {
320            let req = NetworkRequest {
321                url: url.to_string(),
322                scheme: scheme.to_string(),
323                host: host.to_string(),
324                port,
325                class,
326                resource_type: resource_type.to_string(),
327            };
328            if cb(&req) {
329                return Ok(());
330            }
331        }
332        Err(reason.to_string())
333    }
334
335    /// Worst class across the host's addresses (any blocked address blocks the host).
336    async fn host_class(&self, host: &str) -> (AddressClass, Vec<IpAddr>) {
337        if let Ok(ip) = host.parse::<IpAddr>() {
338            return (classify_ip(ip), vec![ip]);
339        }
340        if host == "localhost" || host.ends_with(".localhost") {
341            return (
342                AddressClass::Loopback,
343                vec![IpAddr::V4(Ipv4Addr::LOCALHOST)],
344            );
345        }
346        let addrs = self.resolve(host).await;
347        let class = addrs
348            .iter()
349            .copied()
350            .map(classify_ip)
351            .find(|c| *c != AddressClass::Public)
352            .unwrap_or(AddressClass::Public);
353        (class, addrs)
354    }
355
356    async fn resolve(&self, host: &str) -> Vec<IpAddr> {
357        const TTL: Duration = Duration::from_secs(30);
358        if let Some((at, v)) = self.resolved.lock().unwrap().get(host) {
359            if at.elapsed() < TTL {
360                return v.clone();
361            }
362        }
363        let found: Vec<IpAddr> = if let Some(r) = &self.resolver {
364            r(host)
365        } else {
366            match tokio::time::timeout(Duration::from_secs(3), tokio::net::lookup_host((host, 0)))
367                .await
368            {
369                Ok(Ok(it)) => it.map(|a| a.ip()).collect(),
370                _ => Vec::new(), // unresolved names cannot connect; Chrome reports the failure.
371            }
372        };
373        self.resolved
374            .lock()
375            .unwrap()
376            .insert(host.to_string(), (Instant::now(), found.clone()));
377        found
378    }
379}
380
381/// Lifecycle notifications for the caller's journal. Content-free.
382#[derive(Clone, Debug)]
383pub enum BrowserEvent {
384    Started {
385        session_id: String,
386        pid: Option<u32>,
387    },
388    Stopped {
389        session_id: String,
390    },
391    /// Admission or network policy refused something before any side effect.
392    Denied {
393        session_id: String,
394        what: String,
395        reason: String,
396    },
397}
398
399pub type EventSink = Arc<dyn Fn(&BrowserEvent) + Send + Sync>;
400
401/// Everything pages share about effect control.
402pub(crate) struct Guard {
403    pub session_id: String,
404    pub admission: Option<AdmissionHook>,
405    pub network: NetworkPolicy,
406    pub events: Option<EventSink>,
407}
408
409impl Guard {
410    pub fn emit(&self, e: BrowserEvent) {
411        if let Some(s) = &self.events {
412            s(&e);
413        }
414    }
415
416    pub async fn admit(&self, req: AdmissionRequest) -> crate::error::Result<()> {
417        let what = format!("{:?}", req.action);
418        if let Some(hook) = &self.admission {
419            if let Decision::Deny(reason) = hook(req).await {
420                self.emit(BrowserEvent::Denied {
421                    session_id: self.session_id.clone(),
422                    what,
423                    reason: reason.clone(),
424                });
425                return Err(crate::error::BrowserError::Denied(reason));
426            }
427        }
428        Ok(())
429    }
430
431    pub async fn check_url(&self, url: &str, kind: &str) -> crate::error::Result<()> {
432        match self.network.check(url, kind).await {
433            Ok(()) => Ok(()),
434            Err(reason) => {
435                self.emit(BrowserEvent::Denied {
436                    session_id: self.session_id.clone(),
437                    what: kind.into(),
438                    reason: reason.clone(),
439                });
440                Err(crate::error::BrowserError::Denied(format!(
441                    "{url}: {reason}"
442                )))
443            }
444        }
445    }
446}