use futures::future::BoxFuture;
use std::collections::HashMap;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum ActionKind {
Navigate,
Reload,
History,
Click,
DoubleClick,
Hover,
Drag,
Wheel,
Press,
TypeText,
Fill,
Select,
Upload,
Eval,
}
#[derive(Clone, Debug)]
pub struct AdmissionRequest {
pub session_id: String,
pub page_id: String,
pub action: ActionKind,
pub url: Option<String>,
pub target: Option<String>,
pub detail: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Decision {
Allow,
Deny(String),
}
pub type AdmissionHook =
Arc<dyn Fn(AdmissionRequest) -> BoxFuture<'static, Decision> + Send + Sync>;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum AddressClass {
Public,
Loopback,
LinkLocal,
Private,
Reserved,
Unspecified,
Multicast,
}
pub fn classify_ip(ip: IpAddr) -> AddressClass {
match ip {
IpAddr::V4(v4) => classify_v4(v4),
IpAddr::V6(v6) => classify_v6(v6),
}
}
fn classify_v4(ip: Ipv4Addr) -> AddressClass {
let [a, b, c, _] = ip.octets();
if a == 127 {
AddressClass::Loopback
} else if a == 0 {
AddressClass::Unspecified
} else if a == 169 && b == 254 {
AddressClass::LinkLocal
} else if a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 100 && (64..=127).contains(&b))
{
AddressClass::Private
} else if (224..=239).contains(&a) {
AddressClass::Multicast
} else if a >= 240
|| (a == 192 && b == 0 && c == 0)
|| (a == 192 && b == 0 && c == 2)
|| (a == 198 && (b == 18 || b == 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113)
{
AddressClass::Reserved
} else {
AddressClass::Public
}
}
fn v4_from_segments(hi: u16, lo: u16) -> Ipv4Addr {
Ipv4Addr::new((hi >> 8) as u8, hi as u8, (lo >> 8) as u8, lo as u8)
}
fn classify_v6(v6: Ipv6Addr) -> AddressClass {
let s = v6.segments();
if v6.is_loopback() {
return AddressClass::Loopback;
}
if v6.is_unspecified() {
return AddressClass::Unspecified;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return classify_v4(v4);
}
if s[..6] == [0, 0, 0, 0, 0, 0] {
return classify_v4(v4_from_segments(s[6], s[7]));
}
if s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
return classify_v4(v4_from_segments(s[6], s[7]));
}
if s[0] == 0x2002 {
return classify_v4(v4_from_segments(s[1], s[2]));
}
if v6.is_multicast() {
AddressClass::Multicast
} else if s[0] & 0xffc0 == 0xfe80 {
AddressClass::LinkLocal
} else if s[0] & 0xfe00 == 0xfc00 || s[0] & 0xffc0 == 0xfec0 {
AddressClass::Private
} else if (s[0] == 0x2001 && s[1] == 0x0db8) || (s[0] == 0x2001 && s[1] == 0) {
AddressClass::Reserved
} else {
AddressClass::Public
}
}
fn is_metadata_host(host: &str) -> bool {
matches!(
host,
"metadata" | "metadata.google.internal" | "metadata.goog" | "instance-data"
) || host.ends_with(".metadata.google.internal")
}
fn normalize_host(host: &str) -> String {
let h = host.trim_matches(['[', ']']).to_ascii_lowercase();
h.strip_suffix('.').map(str::to_string).unwrap_or(h)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BlockReason {
InvalidUrl(String),
Scheme(String),
NoHost,
Unresolved {
host: String,
},
Address {
host: String,
ip: IpAddr,
class: AddressClass,
},
MetadataHost {
host: String,
},
Callback(String),
}
impl BlockReason {
pub fn is_hard(&self, hard_block_link_local: bool) -> bool {
hard_block_link_local
&& matches!(
self,
BlockReason::MetadataHost { .. }
| BlockReason::Address {
class: AddressClass::LinkLocal,
..
}
)
}
}
impl fmt::Display for BlockReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BlockReason::InvalidUrl(e) => write!(f, "unparseable url: {e}"),
BlockReason::Scheme(s) => write!(f, "scheme '{s}' is blocked"),
BlockReason::NoHost => write!(f, "request has no host"),
BlockReason::Unresolved { host } => write!(f, "host '{host}' did not resolve"),
BlockReason::Address { host, ip, class } => {
if host == &ip.to_string() {
write!(f, "address {ip} is {class:?}")
} else {
write!(f, "host '{host}' resolves to {class:?} address {ip}")
}
}
BlockReason::MetadataHost { host } => {
write!(f, "host '{host}' is a cloud metadata endpoint")
}
BlockReason::Callback(r) => write!(f, "denied by policy callback: {r}"),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PolicyDenial {
pub url: String,
pub resource_type: String,
pub reason: BlockReason,
}
impl fmt::Display for PolicyDenial {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({}): {}", self.url, self.resource_type, self.reason)
}
}
#[derive(Clone, Debug)]
pub struct BrowserRequest {
pub url: String,
pub scheme: String,
pub host: String,
pub port: Option<u16>,
pub class: Option<AddressClass>,
pub addresses: Vec<IpAddr>,
pub resource_type: String,
pub default_block: Option<BlockReason>,
}
pub type NetworkRequest = BrowserRequest;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PolicyDecision {
Allow,
Deny(String),
Default,
}
impl From<bool> for PolicyDecision {
fn from(b: bool) -> Self {
if b {
PolicyDecision::Allow
} else {
PolicyDecision::Default
}
}
}
pub type AllowCallback = Arc<dyn Fn(&BrowserRequest) -> PolicyDecision + Send + Sync>;
pub type Resolver = Arc<dyn Fn(&str) -> Vec<IpAddr> + Send + Sync>;
type ResolvedCache = Arc<Mutex<HashMap<String, (Instant, Vec<IpAddr>)>>>;
#[derive(Clone)]
pub struct NetworkPolicy {
pub allow_hosts: Vec<String>,
pub allow_classes: Vec<AddressClass>,
pub allowed_schemes: Vec<String>,
pub allow_request: Option<AllowCallback>,
pub hard_block_link_local: bool,
pub unrestricted: bool,
pub resolver: Option<Resolver>,
resolved: ResolvedCache,
approved: Arc<Mutex<HashMap<String, Vec<IpAddr>>>>,
}
impl Default for NetworkPolicy {
fn default() -> Self {
Self {
allow_hosts: Vec::new(),
allow_classes: Vec::new(),
allowed_schemes: vec!["http".into(), "https".into()],
allow_request: None,
hard_block_link_local: true,
unrestricted: false,
resolver: None,
resolved: ResolvedCache::default(),
approved: Arc::default(),
}
}
}
impl fmt::Debug for NetworkPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NetworkPolicy")
.field("allow_hosts", &self.allow_hosts)
.field("allow_classes", &self.allow_classes)
.field("allowed_schemes", &self.allowed_schemes)
.field("allow_request", &self.allow_request.is_some())
.field("hard_block_link_local", &self.hard_block_link_local)
.field("unrestricted", &self.unrestricted)
.finish()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BlockedRequest {
pub url: String,
pub reason: String,
}
impl NetworkPolicy {
pub fn unrestricted() -> Self {
Self {
unrestricted: true,
..Self::default()
}
}
pub fn allow_host(mut self, host: impl Into<String>) -> Self {
self.allow_hosts.push(normalize_host(&host.into()));
self
}
pub fn allow_class(mut self, class: AddressClass) -> Self {
self.allow_classes.push(class);
self
}
pub fn allow_scheme(mut self, scheme: impl Into<String>) -> Self {
self.allowed_schemes
.push(scheme.into().to_ascii_lowercase());
self
}
pub fn hard_block_link_local(mut self, on: bool) -> Self {
self.hard_block_link_local = on;
self
}
pub fn with_resolver(
mut self,
f: impl Fn(&str) -> Vec<IpAddr> + Send + Sync + 'static,
) -> Self {
self.resolver = Some(Arc::new(f));
self
}
pub fn allow_with<D: Into<PolicyDecision>>(
mut self,
f: impl Fn(&BrowserRequest) -> D + Send + Sync + 'static,
) -> Self {
self.allow_request = Some(Arc::new(move |r| f(r).into()));
self
}
fn scheme_allowed(&self, scheme: &str) -> bool {
self.allowed_schemes.iter().any(|s| s == scheme)
}
fn address_allowed(&self, host: &str, class: AddressClass) -> bool {
class == AddressClass::Public
|| self.allow_classes.contains(&class)
|| self.allow_hosts.iter().any(|h| h == host)
}
pub async fn check(&self, url: &str, resource_type: &str) -> Result<(), BlockReason> {
if self.unrestricted {
return Ok(());
}
let parsed = url::Url::parse(url).map_err(|e| BlockReason::InvalidUrl(e.to_string()))?;
let scheme = parsed.scheme().to_ascii_lowercase();
let is_document = matches!(resource_type, "Navigation" | "Document");
if scheme == "about" && parsed.path() == "blank" {
return Ok(());
}
if matches!(scheme.as_str(), "data" | "blob") && !is_document {
return Ok(()); }
let networked = matches!(scheme.as_str(), "http" | "https" | "ws" | "wss");
let mut default_block =
(!self.scheme_allowed(&scheme)).then(|| BlockReason::Scheme(scheme.clone()));
let mut host = String::new();
let mut class = None;
let mut addresses = Vec::new();
if networked {
host = normalize_host(parsed.host_str().unwrap_or(""));
if host.is_empty() {
return Err(BlockReason::NoHost);
}
if is_metadata_host(&host) {
let r = BlockReason::MetadataHost { host: host.clone() };
if r.is_hard(self.hard_block_link_local) {
return Err(r);
}
default_block.get_or_insert(r);
}
let (c, worst, ips) = self.host_class(&host).await;
class = Some(c);
addresses = ips;
if addresses.is_empty() {
return Err(BlockReason::Unresolved { host });
}
if !self.address_allowed(&host, c)
|| (c == AddressClass::LinkLocal && self.hard_block_link_local)
{
let r = BlockReason::Address {
host: host.clone(),
ip: worst,
class: c,
};
if r.is_hard(self.hard_block_link_local) {
return Err(r);
}
default_block.get_or_insert(r);
}
}
let decision = match &self.allow_request {
Some(cb) => cb(&BrowserRequest {
url: url.to_string(),
scheme: scheme.clone(),
host: host.clone(),
port: parsed.port_or_known_default(),
class,
addresses: addresses.clone(),
resource_type: resource_type.to_string(),
default_block: default_block.clone(),
}),
None => PolicyDecision::Default,
};
let allowed = match decision {
PolicyDecision::Allow => true,
PolicyDecision::Deny(r) => return Err(BlockReason::Callback(r)),
PolicyDecision::Default => match default_block {
Some(r) => return Err(r),
None => true,
},
};
if allowed && networked && class != Some(AddressClass::Public) {
self.approved
.lock()
.unwrap()
.entry(host)
.or_default()
.extend(addresses);
}
Ok(())
}
pub(crate) async fn pin(&self, host: &str) -> Result<Vec<IpAddr>, BlockReason> {
let host = normalize_host(host);
if self.hard_block_link_local && is_metadata_host(&host) {
return Err(BlockReason::MetadataHost { host });
}
let (_, _, ips) = self.host_class(&host).await;
if ips.is_empty() {
return Err(BlockReason::Unresolved { host });
}
let approved = self
.approved
.lock()
.unwrap()
.get(&host)
.cloned()
.unwrap_or_default();
for ip in &ips {
let class = classify_ip(*ip);
let hard = self.hard_block_link_local && class == AddressClass::LinkLocal;
let ok = !hard && (self.address_allowed(&host, class) || approved.contains(ip));
if !ok {
return Err(BlockReason::Address {
host,
ip: *ip,
class,
});
}
}
Ok(ips)
}
async fn host_class(&self, host: &str) -> (AddressClass, IpAddr, Vec<IpAddr>) {
let unspecified = IpAddr::V4(Ipv4Addr::UNSPECIFIED);
if let Ok(ip) = host.parse::<IpAddr>() {
return (classify_ip(ip), ip, vec![ip]);
}
if host == "localhost" || host.ends_with(".localhost") {
let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
return (AddressClass::Loopback, ip, vec![ip]);
}
let addrs = self.resolve(host).await;
let worst = addrs
.iter()
.copied()
.find(|ip| classify_ip(*ip) != AddressClass::Public);
match worst {
Some(ip) => (classify_ip(ip), ip, addrs),
None => (
AddressClass::Public,
addrs.first().copied().unwrap_or(unspecified),
addrs,
),
}
}
async fn resolve(&self, host: &str) -> Vec<IpAddr> {
const TTL: Duration = Duration::from_secs(30);
if let Some((at, v)) = self.resolved.lock().unwrap().get(host) {
if at.elapsed() < TTL {
return v.clone();
}
}
let found: Vec<IpAddr> = if let Some(r) = &self.resolver {
r(host)
} else {
match tokio::time::timeout(Duration::from_secs(3), tokio::net::lookup_host((host, 0)))
.await
{
Ok(Ok(it)) => it.map(|a| a.ip()).collect(),
_ => Vec::new(), }
};
self.resolved
.lock()
.unwrap()
.insert(host.to_string(), (Instant::now(), found.clone()));
found
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum LifecycleKind {
Start,
Stop,
Crash,
Denied,
}
impl LifecycleKind {
pub fn label(self) -> &'static str {
match self {
LifecycleKind::Start => "start",
LifecycleKind::Stop => "stop",
LifecycleKind::Crash => "crash",
LifecycleKind::Denied => "denied",
}
}
}
#[derive(Clone, Debug)]
pub struct BrowserEvent {
pub kind: LifecycleKind,
pub session_id: String,
pub reason: String,
pub at: SystemTime,
pub page_id: Option<String>,
pub pid: Option<u32>,
pub what: Option<String>,
pub blocked: Option<BlockReason>,
}
impl BrowserEvent {
pub(crate) fn new(kind: LifecycleKind, session_id: &str, reason: impl Into<String>) -> Self {
Self {
kind,
session_id: session_id.to_string(),
reason: reason.into(),
at: SystemTime::now(),
page_id: None,
pid: None,
what: None,
blocked: None,
}
}
pub fn timestamp_ms(&self) -> u64 {
self.at
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"event": self.kind.label(),
"session_id": self.session_id,
"reason": self.reason,
"ts_ms": self.timestamp_ms(),
"page_id": self.page_id,
"pid": self.pid,
"what": self.what,
})
}
}
pub type EventSink = Arc<dyn Fn(&BrowserEvent) + Send + Sync>;
pub(crate) struct Guard {
pub session_id: String,
pub admission: Option<AdmissionHook>,
pub network: NetworkPolicy,
pub events: Option<EventSink>,
}
impl Guard {
pub fn emit(&self, e: BrowserEvent) {
if let Some(s) = &self.events {
s(&e);
}
}
pub fn emit_kind(
&self,
kind: LifecycleKind,
reason: impl Into<String>,
f: impl FnOnce(&mut BrowserEvent),
) {
let mut e = BrowserEvent::new(kind, &self.session_id, reason);
f(&mut e);
self.emit(e);
}
pub fn denied_network(&self, page_id: Option<&str>, what: &str, reason: &BlockReason) {
self.emit_kind(LifecycleKind::Denied, reason.to_string(), |e| {
e.page_id = page_id.map(str::to_string);
e.what = Some(what.to_string());
e.blocked = Some(reason.clone());
});
}
pub async fn admit(&self, req: AdmissionRequest) -> crate::error::Result<()> {
let what = format!("{:?}", req.action);
let page_id = req.page_id.clone();
if let Some(hook) = &self.admission {
if let Decision::Deny(reason) = hook(req).await {
self.emit_kind(LifecycleKind::Denied, reason.clone(), |e| {
e.page_id = Some(page_id);
e.what = Some(what);
});
return Err(crate::error::BrowserError::Denied(reason));
}
}
Ok(())
}
pub async fn check_url(
&self,
page_id: Option<&str>,
url: &str,
kind: &str,
) -> crate::error::Result<()> {
match self.network.check(url, kind).await {
Ok(()) => Ok(()),
Err(reason) => {
self.denied_network(page_id, kind, &reason);
Err(crate::error::BrowserError::Blocked(PolicyDenial {
url: url.to_string(),
resource_type: kind.to_string(),
reason,
}))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn block_on<F: std::future::Future>(f: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(f)
}
fn offline() -> NetworkPolicy {
NetworkPolicy::default().with_resolver(|h| match h {
"public.example" => vec![ip("93.184.216.34")],
"rebind.example" => vec![ip("93.184.216.34"), ip("10.0.0.7")],
"meta.example" => vec![ip("169.254.169.254")],
"mapped.example" => vec![ip("::ffff:192.168.1.1")],
"dev.example" => vec![ip("127.0.0.1")],
_ => vec![],
})
}
fn check(p: &NetworkPolicy, url: &str) -> Result<(), BlockReason> {
block_on(p.check(url, "Navigation"))
}
#[test]
fn classifies_every_blocked_v4_range() {
let cases = [
("127.0.0.1", AddressClass::Loopback),
("127.255.255.254", AddressClass::Loopback),
("169.254.169.254", AddressClass::LinkLocal),
("169.254.0.1", AddressClass::LinkLocal),
("10.0.0.1", AddressClass::Private),
("10.255.255.255", AddressClass::Private),
("172.16.0.1", AddressClass::Private),
("172.31.255.255", AddressClass::Private),
("192.168.0.1", AddressClass::Private),
("100.64.0.1", AddressClass::Private),
("100.127.255.255", AddressClass::Private),
("0.0.0.0", AddressClass::Unspecified),
("0.1.2.3", AddressClass::Unspecified),
("224.0.0.1", AddressClass::Multicast),
("255.255.255.255", AddressClass::Reserved),
("240.0.0.1", AddressClass::Reserved),
("192.0.2.1", AddressClass::Reserved),
("198.18.0.1", AddressClass::Reserved),
("198.51.100.1", AddressClass::Reserved),
("203.0.113.1", AddressClass::Reserved),
];
for (s, want) in cases {
assert_eq!(classify_ip(ip(s)), want, "{s}");
}
for s in [
"8.8.8.8",
"93.184.216.34",
"172.32.0.1",
"172.15.255.255",
"100.63.255.255",
"100.128.0.1",
"169.253.1.1",
] {
assert_eq!(classify_ip(ip(s)), AddressClass::Public, "{s}");
}
}
#[test]
fn classifies_every_blocked_v6_range_and_embedded_v4() {
let cases = [
("::1", AddressClass::Loopback),
("::", AddressClass::Unspecified),
("fe80::1", AddressClass::LinkLocal),
("febf::1", AddressClass::LinkLocal),
("fc00::1", AddressClass::Private),
("fd00:ec2::254", AddressClass::Private),
("fec0::1", AddressClass::Private),
("ff02::1", AddressClass::Multicast),
("2001:db8::1", AddressClass::Reserved),
("::ffff:127.0.0.1", AddressClass::Loopback),
("::ffff:169.254.169.254", AddressClass::LinkLocal),
("::ffff:10.1.2.3", AddressClass::Private),
("::ffff:192.168.1.1", AddressClass::Private),
("::ffff:0.0.0.0", AddressClass::Unspecified),
("::127.0.0.1", AddressClass::Loopback),
("64:ff9b::a9fe:a9fe", AddressClass::LinkLocal),
("64:ff9b::7f00:1", AddressClass::Loopback),
("2002:c0a8:0101::1", AddressClass::Private),
("2002:7f00:0001::1", AddressClass::Loopback),
];
for (s, want) in cases {
assert_eq!(classify_ip(ip(s)), want, "{s}");
}
for s in [
"2606:4700:4700::1111",
"::ffff:8.8.8.8",
"64:ff9b::808:808",
"2002:0808:0808::1",
] {
assert_eq!(classify_ip(ip(s)), AddressClass::Public, "{s}");
}
}
#[test]
fn default_policy_blocks_literals_localhost_and_metadata() {
let p = offline();
for url in [
"http://127.0.0.1/",
"http://127.1.2.3:8080/x",
"http://localhost/",
"http://LOCALHOST./",
"http://app.localhost/",
"http://[::1]/",
"http://169.254.169.254/latest/meta-data/",
"http://[fe80::1]/",
"http://10.0.0.5/",
"http://172.20.1.1/",
"http://192.168.1.1/",
"http://100.64.1.1/",
"http://[fc00::1]/",
"http://0.0.0.0/",
"http://[::]/",
"http://[::ffff:127.0.0.1]/",
"http://[::ffff:169.254.169.254]/",
"http://[::ffff:10.0.0.1]/",
"http://2130706433/", "http://0x7f.0.0.1/", "http://metadata.google.internal/computeMetadata/v1/",
] {
let r = check(&p, url);
assert!(r.is_err(), "{url} was allowed");
}
assert_eq!(check(&p, "https://public.example/"), Ok(()));
assert_eq!(check(&p, "http://8.8.8.8/"), Ok(()));
}
#[test]
fn every_resolved_address_is_checked_and_mapped_v6_resolution_blocks() {
let p = offline();
match check(&p, "https://rebind.example/") {
Err(BlockReason::Address { ip: a, class, .. }) => {
assert_eq!(a, ip("10.0.0.7"));
assert_eq!(class, AddressClass::Private);
}
other => panic!("{other:?}"),
}
assert!(matches!(
check(&p, "https://mapped.example/"),
Err(BlockReason::Address {
class: AddressClass::Private,
..
})
));
assert!(matches!(
check(&p, "https://meta.example/"),
Err(BlockReason::Address {
class: AddressClass::LinkLocal,
..
})
));
assert_eq!(
check(&p, "https://nowhere.example/"),
Err(BlockReason::Unresolved {
host: "nowhere.example".into()
})
);
}
#[test]
fn schemes_other_than_http_are_blocked_unless_allowed() {
let p = offline();
for url in [
"file:///etc/passwd",
"chrome://settings",
"ftp://public.example/",
"ws://public.example/",
"wss://public.example/",
"javascript:alert(1)",
"about:config",
] {
assert!(
matches!(check(&p, url), Err(BlockReason::Scheme(_))),
"{url}: {:?}",
check(&p, url)
);
}
assert_eq!(check(&p, "about:blank"), Ok(()));
assert_eq!(
block_on(p.check("data:image/png;base64,AA==", "Image")),
Ok(())
);
assert!(matches!(
block_on(p.check("data:text/html,<h1>x</h1>", "Document")),
Err(BlockReason::Scheme(_))
));
let p = offline().allow_scheme("file").allow_scheme("wss");
assert_eq!(check(&p, "file:///tmp/x.html"), Ok(()));
assert_eq!(check(&p, "wss://public.example/socket"), Ok(()));
assert!(check(&p, "wss://127.0.0.1/").is_err());
}
#[test]
fn allow_callback_overrides_both_ways_but_not_hard_blocks() {
let seen: Arc<Mutex<Vec<BrowserRequest>>> = Arc::default();
let s2 = seen.clone();
let p = offline().allow_with(move |r: &BrowserRequest| {
s2.lock().unwrap().push(r.clone());
match r.host.as_str() {
"127.0.0.1" if r.port == Some(3000) => PolicyDecision::Allow,
"public.example" => PolicyDecision::Deny("not on the list".into()),
"169.254.169.254" | "meta.example" => PolicyDecision::Allow,
_ => PolicyDecision::Default,
}
});
assert_eq!(check(&p, "http://127.0.0.1:3000/"), Ok(()));
assert!(check(&p, "http://127.0.0.1:3001/").is_err());
assert_eq!(
check(&p, "https://public.example/"),
Err(BlockReason::Callback("not on the list".into()))
);
let before = seen.lock().unwrap().len();
assert!(check(&p, "http://169.254.169.254/").is_err());
assert!(check(&p, "https://meta.example/").is_err());
assert_eq!(seen.lock().unwrap().len(), before);
let s = seen.lock().unwrap();
let r = s.iter().find(|r| r.port == Some(3000)).unwrap();
assert_eq!(r.class, Some(AddressClass::Loopback));
assert!(matches!(r.default_block, Some(BlockReason::Address { .. })));
let r = s.iter().find(|r| r.host == "public.example").unwrap();
assert_eq!(r.default_block, None);
drop(s);
let p = offline().allow_with(|r: &BrowserRequest| r.host == "10.0.0.5");
assert_eq!(check(&p, "http://10.0.0.5/"), Ok(()));
assert!(check(&p, "http://10.0.0.6/").is_err());
assert_eq!(check(&p, "https://public.example/"), Ok(()));
let p = offline()
.hard_block_link_local(false)
.allow_host("169.254.169.254");
assert_eq!(check(&p, "http://169.254.169.254/"), Ok(()));
}
#[test]
fn class_and_host_allowances() {
let p = offline().allow_class(AddressClass::Loopback);
assert_eq!(check(&p, "http://localhost:5173/"), Ok(()));
assert_eq!(check(&p, "https://dev.example/"), Ok(()));
assert!(check(&p, "http://10.0.0.1/").is_err());
assert!(check(&p, "http://169.254.169.254/").is_err());
let p = offline().allow_host("Rebind.Example.");
assert_eq!(check(&p, "https://rebind.example/"), Ok(()));
let p = offline()
.allow_host("meta.example")
.allow_class(AddressClass::LinkLocal);
assert!(check(&p, "https://meta.example/").is_err());
}
#[test]
fn pin_only_connects_to_vetted_addresses() {
let p = offline();
assert_eq!(
block_on(p.pin("public.example")).unwrap(),
vec![ip("93.184.216.34")]
);
assert!(block_on(p.pin("rebind.example")).is_err());
assert!(block_on(p.pin("dev.example")).is_err());
assert!(block_on(p.pin("metadata.google.internal")).is_err());
assert!(block_on(p.pin("nowhere.example")).is_err());
let p = offline().allow_with(|r: &BrowserRequest| r.host == "dev.example");
assert_eq!(check(&p, "https://dev.example/"), Ok(()));
assert_eq!(
block_on(p.pin("dev.example")).unwrap(),
vec![ip("127.0.0.1")]
);
}
#[test]
fn rebinding_after_check_is_refused_at_connect() {
let n = Arc::new(Mutex::new(0u32));
let n2 = n.clone();
let p = NetworkPolicy::default().with_resolver(move |_| {
let mut g = n2.lock().unwrap();
*g += 1;
if *g == 1 {
vec![ip("93.184.216.34")]
} else {
vec![ip("10.9.9.9")]
}
});
assert_eq!(check(&p, "https://flip.example/"), Ok(()));
p.resolved.lock().unwrap().clear(); assert!(matches!(
block_on(p.pin("flip.example")),
Err(BlockReason::Address {
class: AddressClass::Private,
..
})
));
}
#[test]
fn unrestricted_allows_everything() {
let p = NetworkPolicy::unrestricted();
assert_eq!(check(&p, "file:///etc/hosts"), Ok(()));
assert_eq!(check(&p, "http://169.254.169.254/"), Ok(()));
}
#[test]
fn denials_emit_typed_events_with_session_reason_and_timestamp() {
let events: Arc<Mutex<Vec<BrowserEvent>>> = Arc::default();
let e2 = events.clone();
let g = Guard {
session_id: "bs-test".into(),
admission: Some(Arc::new(|_req| {
Box::pin(async { Decision::Deny("needs approval".into()) })
})),
network: offline(),
events: Some(Arc::new(move |e: &BrowserEvent| {
e2.lock().unwrap().push(e.clone())
})),
};
let before = SystemTime::now();
let err = block_on(g.check_url(Some("p1"), "http://10.0.0.1/", "Navigation")).unwrap_err();
match err {
crate::BrowserError::Blocked(d) => {
assert_eq!(d.resource_type, "Navigation");
assert!(matches!(
d.reason,
BlockReason::Address {
class: AddressClass::Private,
..
}
));
}
other => panic!("{other:?}"),
}
let err = block_on(g.admit(AdmissionRequest {
session_id: "bs-test".into(),
page_id: "p1".into(),
action: ActionKind::Click,
url: None,
target: None,
detail: None,
}))
.unwrap_err();
assert!(matches!(err, crate::BrowserError::Denied(ref r) if r == "needs approval"));
let ev = events.lock().unwrap();
assert_eq!(ev.len(), 2);
for e in ev.iter() {
assert_eq!(e.kind, LifecycleKind::Denied);
assert_eq!(e.session_id, "bs-test");
assert_eq!(e.page_id.as_deref(), Some("p1"));
assert!(!e.reason.is_empty());
assert!(e.at >= before);
assert!(e.timestamp_ms() > 0);
let j = e.to_json();
assert_eq!(j["event"], "denied");
assert_eq!(j["session_id"], "bs-test");
}
assert!(ev[0].blocked.is_some());
assert_eq!(ev[0].what.as_deref(), Some("Navigation"));
assert_eq!(ev[1].what.as_deref(), Some("Click"));
assert!(ev[1].blocked.is_none());
}
}