Skip to main content

blitz_traits/
resource_policy.rs

1//! Browser-issued resource capabilities. Never derive these from page headers.
2
3use crate::net::Url;
4use crate::platform::OriginKey;
5use std::net::IpAddr;
6use std::sync::{Arc, Mutex};
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum Credentials {
10    Omit,
11    #[default]
12    SameOrigin,
13    Include,
14}
15
16/// Application parameters, captured when a document is created.
17#[derive(Clone, Debug)]
18pub struct ResourcePolicy {
19    pub max_requests: u64,
20    pub warn_requests: u64,
21    pub max_bytes: u64,
22    pub warn_bytes: u64,
23    /// Explicit browser permission, never a page's credentials option.
24    pub cross_origin_credentials: bool,
25}
26
27impl Default for ResourcePolicy {
28    fn default() -> Self {
29        Self {
30            max_requests: 4096,
31            warn_requests: 3072,
32            max_bytes: 256 * 1024 * 1024,
33            warn_bytes: 192 * 1024 * 1024,
34            cross_origin_credentials: false,
35        }
36    }
37}
38
39#[derive(Debug, Default)]
40struct Usage {
41    requests: u64,
42    bytes: u64,
43}
44
45/// One instance of one document. Opaque file origins are never recomputed.
46#[derive(Debug)]
47pub struct ResourceScope {
48    url: Url,
49    origin: OriginKey,
50    policy: Arc<ResourcePolicy>,
51    usage: Arc<Mutex<Usage>>,
52    file_access: bool,
53    private_network: bool,
54}
55
56/// Only native browser code can issue these; they are not deserializable.
57#[derive(Clone, Debug, Default)]
58pub enum Initiator {
59    #[default]
60    Unknown,
61    Browser(Arc<ResourceScope>),
62    Document(Arc<ResourceScope>),
63}
64
65impl ResourcePolicy {
66    /// Call only for a user navigation or a browser-owned load.
67    pub fn browser(self: &Arc<Self>, url: Url) -> Initiator {
68        Initiator::Browser(Arc::new(ResourceScope {
69            origin: OriginKey::for_document(&url),
70            file_access: url.scheme() == "file",
71            private_network: true,
72            url,
73            policy: Arc::clone(self),
74            usage: Arc::new(Mutex::new(Usage::default())),
75        }))
76    }
77
78    /// Commit the final URL of a navigation. The navigation and its page share
79    /// a budget. A web navigation cannot mint a local-file capability.
80    pub fn document(self: &Arc<Self>, url: Url, navigation: &Initiator) -> Initiator {
81        let scope = navigation.scope();
82        let file_access = matches!(navigation, Initiator::Browser(_))
83            && scope.is_some_and(|scope| scope.file_access && scope.url == url);
84        let private_network = local_host(&url) && scope.is_some_and(|scope| scope.private_network);
85        Initiator::Document(Arc::new(ResourceScope {
86            origin: OriginKey::for_document(&url),
87            url,
88            policy: Arc::clone(self),
89            usage: scope.map_or_else(
90                || Arc::new(Mutex::new(Usage::default())),
91                |scope| Arc::clone(&scope.usage),
92            ),
93            file_access,
94            private_network,
95        }))
96    }
97}
98
99impl Initiator {
100    pub fn scope(&self) -> Option<&Arc<ResourceScope>> {
101        match self {
102            Self::Unknown => None,
103            Self::Browser(scope) | Self::Document(scope) => Some(scope),
104        }
105    }
106
107    pub fn document_url(&self) -> Option<&Url> {
108        self.scope().map(|scope| &scope.url)
109    }
110
111    pub fn origin(&self) -> Option<&OriginKey> {
112        self.scope().map(|scope| &scope.origin)
113    }
114
115    /// A child document receives a new identity, but cannot escape its parent's
116    /// budget or gain private-network permission by naming a private URL.
117    pub fn child(&self, url: Url) -> Self {
118        match self.scope() {
119            Some(scope) => scope.policy.document(url, self),
120            None => Self::Unknown,
121        }
122    }
123
124    pub fn check_url(&self, url: &Url) -> Result<(), String> {
125        let scope = self.scope().ok_or("resource-initiator-unknown")?;
126        if url.scheme() == "file" && !scope.file_access {
127            return Err("resource-file-origin".into());
128        }
129        if matches!(url.scheme(), "http" | "https") && local_host(url) && !scope.private_network {
130            return Err("resource-private-address".into());
131        }
132        Ok(())
133    }
134
135    /// Apply to every resolved candidate before connecting to that exact list.
136    pub fn check_address(&self, address: IpAddr) -> Result<(), String> {
137        let scope = self.scope().ok_or("resource-initiator-unknown")?;
138        if private_address(address) && !scope.private_network {
139            return Err("resource-private-address".into());
140        }
141        Ok(())
142    }
143
144    pub fn restrict_network(&self) -> bool {
145        self.scope().is_none_or(|scope| !scope.private_network)
146    }
147
148    pub fn allows_credentials(&self, url: &Url, mode: Credentials) -> bool {
149        let Some(scope) = self.scope() else {
150            return false;
151        };
152        mode != Credentials::Omit
153            && (scope.origin.is_persistable() && scope.origin == OriginKey::for_document(url)
154                || mode == Credentials::Include && scope.policy.cross_origin_credentials)
155    }
156
157    pub fn begin(&self, url: &Url) -> Result<(), String> {
158        let scope = self.scope().ok_or("resource-initiator-unknown")?;
159        let mut usage = scope
160            .usage
161            .lock()
162            .unwrap_or_else(|error| error.into_inner());
163        if usage.requests >= scope.policy.max_requests {
164            return Err("resource-request-cap".into());
165        }
166        usage.requests += 1;
167        if usage.requests == scope.policy.warn_requests {
168            eprintln!(
169                "resource-request-warning: {} ({})",
170                usage.requests, scope.url
171            );
172        }
173        drop(usage);
174        self.check_url(url)
175    }
176
177    /// Monotonic accounting includes redirects, failures and bytes from cache.
178    /// Workers and child documents share this counter with their owner.
179    pub fn charge(&self, bytes: usize) -> Result<(), String> {
180        let scope = self.scope().ok_or("resource-initiator-unknown")?;
181        let mut usage = scope
182            .usage
183            .lock()
184            .unwrap_or_else(|error| error.into_inner());
185        let bytes = bytes as u64;
186        if bytes > scope.policy.max_bytes.saturating_sub(usage.bytes) {
187            return Err("resource-byte-cap".into());
188        }
189        let previous = usage.bytes;
190        usage.bytes += bytes;
191        if previous < scope.policy.warn_bytes && usage.bytes >= scope.policy.warn_bytes {
192            eprintln!("resource-byte-warning: {} ({})", usage.bytes, scope.url);
193        }
194        Ok(())
195    }
196
197    pub fn remaining_bytes(&self) -> usize {
198        self.scope().map_or(0, |scope| {
199            let usage = scope
200                .usage
201                .lock()
202                .unwrap_or_else(|error| error.into_inner());
203            usize::try_from(scope.policy.max_bytes.saturating_sub(usage.bytes))
204                .unwrap_or(usize::MAX)
205        })
206    }
207}
208
209/// Conservative local origin recognition. DNS names other than localhost and
210/// .local remain public principals even when the user navigates to them.
211fn local_host(url: &Url) -> bool {
212    match url.host() {
213        Some(url::Host::Ipv4(ip)) => private_address(ip.into()),
214        Some(url::Host::Ipv6(ip)) => private_address(ip.into()),
215        Some(url::Host::Domain(host)) => {
216            let host = host.trim_end_matches('.').to_ascii_lowercase();
217            host == "localhost"
218                || host.ends_with(".localhost")
219                || host == "local"
220                || host.ends_with(".local")
221        }
222        None => false,
223    }
224}
225
226pub fn private_address(address: IpAddr) -> bool {
227    match address {
228        IpAddr::V4(ip) => {
229            let [a, b, _, _] = ip.octets();
230            a == 0
231                || a == 127
232                || a == 10
233                || a == 172 && (16..=31).contains(&b)
234                || a == 192 && b == 168
235                || a == 169 && b == 254
236                || a == 100 && (64..=127).contains(&b)
237                || a >= 224
238        }
239        IpAddr::V6(ip) => {
240            if let Some(v4) = ip.to_ipv4_mapped() {
241                return private_address(v4.into());
242            }
243            let octets = ip.octets();
244            ip.is_unspecified() || ip.is_loopback() || ip.is_multicast()
245                || octets[0] & 0xfe == 0xfc
246                || octets[0] == 0xfe && octets[1] & 0xc0 == 0x80
247                // Embedded IPv4 transition addresses cannot bypass the v4 rule.
248                || ip.segments()[0] == 0x2002
249                || ip.segments()[0] == 0x2001 && ip.segments()[1] == 0
250                || ip.segments()[0] == 0x64 && ip.segments()[1] == 0xff9b
251        }
252    }
253}