1use 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#[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#[derive(Clone, Debug)]
77pub struct AdmissionRequest {
78 pub session_id: String,
79 pub page_id: String,
80 pub action: ActionKind,
81 pub url: Option<String>,
83 pub target: Option<String>,
85 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
104pub enum AddressClass {
105 Public,
106 Loopback,
107 LinkLocal,
109 Private,
111 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 if let Some(v4) = v6.to_ipv4_mapped() {
168 return classify_v4(v4);
169 }
170 if s[..6] == [0, 0, 0, 0, 0, 0] {
172 return classify_v4(v4_from_segments(s[6], s[7]));
173 }
174 if s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
176 return classify_v4(v4_from_segments(s[6], s[7]));
177 }
178 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
195fn 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
203fn 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#[derive(Clone, Debug, Eq, PartialEq)]
215pub enum BlockReason {
216 InvalidUrl(String),
217 Scheme(String),
219 NoHost,
220 Unresolved {
222 host: String,
223 },
224 Address {
226 host: String,
227 ip: IpAddr,
228 class: AddressClass,
229 },
230 MetadataHost {
232 host: String,
233 },
234 Callback(String),
236}
237
238impl BlockReason {
239 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#[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#[derive(Clone, Debug)]
291pub struct BrowserRequest {
292 pub url: String,
293 pub scheme: String,
294 pub host: String,
296 pub port: Option<u16>,
297 pub class: Option<AddressClass>,
299 pub addresses: Vec<IpAddr>,
301 pub resource_type: String,
303 pub default_block: Option<BlockReason>,
305}
306
307pub type NetworkRequest = BrowserRequest;
309
310#[derive(Clone, Debug, Eq, PartialEq)]
312pub enum PolicyDecision {
313 Allow,
315 Deny(String),
317 Default,
319}
320
321impl 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
335type ResolvedCache = Arc<Mutex<HashMap<String, (Instant, Vec<IpAddr>)>>>;
337
338#[derive(Clone)]
340pub struct NetworkPolicy {
341 pub allow_hosts: Vec<String>,
343 pub allow_classes: Vec<AddressClass>,
345 pub allowed_schemes: Vec<String>,
347 pub allow_request: Option<AllowCallback>,
349 pub hard_block_link_local: bool,
351 pub unrestricted: bool,
353 pub resolver: Option<Resolver>,
355 resolved: ResolvedCache,
356 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#[derive(Clone, Debug, Eq, PartialEq)]
391pub struct BlockedRequest {
392 pub url: String,
393 pub reason: String,
394}
395
396impl NetworkPolicy {
397 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 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 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(()); }
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 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 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(), }
616 };
617 self.resolved
618 .lock()
619 .unwrap()
620 .insert(host.to_string(), (Instant::now(), found.clone()));
621 found
622 }
623}
624
625#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
630pub enum LifecycleKind {
631 Start,
632 Stop,
633 Crash,
635 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#[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 pub pid: Option<u32>,
661 pub what: Option<String>,
663 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
703pub(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 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 ("::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 ("::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/", "http://0x7f.0.0.1/", "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 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 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 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 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 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 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 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 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 assert_eq!(
1051 block_on(p.pin("public.example")).unwrap(),
1052 vec![ip("93.184.216.34")]
1053 );
1054 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 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 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(); 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}