proxy_watch/pac/policy.rs
1//! [`PacPolicy`]: the safety envelope a PAC script is evaluated inside.
2
3use std::net::IpAddr;
4use std::net::Ipv4Addr;
5use std::time::{Duration, SystemTime};
6
7/// The default evaluation timeout, five seconds.
8pub const DEFAULT_PAC_TIMEOUT: Duration = Duration::from_secs(5);
9
10/// The default loop-iteration cap (10M). Hitting it aborts with
11/// [`Error::PacEvaluation`](crate::Error::PacEvaluation). Unlike the two constants below
12/// this one is not `boa_engine`'s default: `RuntimeLimits::default()` leaves
13/// `loop_iteration` at `u64::MAX`, so an unbounded `while` is left to the timeout — which
14/// releases the caller without stopping the script (see [`PacPolicy::with_timeout`]).
15pub const DEFAULT_PAC_LOOP_LIMIT: u64 = 10_000_000;
16
17/// The default function-call recursion depth (512) — `boa_engine`'s own default,
18/// kept so stating it is not a behaviour change. Tighten with
19/// [`PacPolicy::with_recursion_limit`].
20pub const DEFAULT_PAC_RECURSION_LIMIT: usize = 512;
21
22/// The default engine value-stack length (10 240) — also `boa_engine`'s own default.
23/// Entry count, not bytes or OS stack size — in particular not the native stack the
24/// parser recurses on, which [`PacPolicy`] cannot bound at all.
25pub const DEFAULT_PAC_STACK_SIZE_LIMIT: usize = 1024 * 10;
26
27/// Safety envelope for PAC evaluation (`pac-boa`; ignored by `WinHttpPacResolver`).
28///
29/// Defaults: no DNS, drop internal answers, `myIpAddress()` → `127.0.0.1`, 5 s timeout,
30/// boa's own recursion/stack limits, and a loop cap boa does not impose at all.
31/// Heap unbounded — OS-sourced PAC + DNS = high severity.
32///
33/// One default is not a limit: the crate reads no time zone, so
34/// [`local_utc_offset`](Self::local_utc_offset) is 0 and `weekdayRange`, `dateRange` and
35/// `timeRange` answer in GMT whether or not the script passed `"GMT"` — a browser reads the
36/// host's zone there. [`with_local_utc_offset`](Self::with_local_utc_offset) is the only way
37/// to move them, and a fixed offset does not follow DST.
38///
39/// Every limit on this type is a VM limit: it bounds a script that is already running, and
40/// none of them bounds parsing. `boa_parser` 0.21.1 is a recursive-descent parser with no
41/// depth limit of its own, so nesting alone overflows the native stack before evaluation
42/// starts — measured through this type on the thread `BoaEvaluator` spawns for a script
43/// (x86-64 Windows, the stack `std::thread` gives a spawn by default): an optimized build
44/// parses 99 nested `(` and aborts on 100, in a 256-byte script; the same source unoptimized
45/// aborts on 17. A native stack overflow aborts the process rather than panicking, so
46/// neither the dedicated evaluation thread nor the timeout contains it. A caller that
47/// accepts a PAC body it does not control has to isolate the process itself; no setting here
48/// substitutes for that.
49///
50/// Neither figure is a ceiling to design against. `(` is the shape they were taken on, not
51/// the only one that recurses — `[` and `{a:` overflow too, and unoptimized they do it at a
52/// comparable depth — so a screen written for `(` screens `(`. And with
53/// [`with_timeout(None)`](Self::with_timeout) there is no spawned thread to measure at all:
54/// the script parses on the calling thread, under whatever stack that thread was given.
55/// Upstream has the bug open
56/// ([boa#4397](https://github.com/boa-dev/boa/issues/4397)) and closed the parser guard
57/// written for it ([boa#4772](https://github.com/boa-dev/boa/pull/4772)) unmerged over the
58/// Test262 conformance it cost; 0.22.0 ships with none, so raising the dependency is not the
59/// way out either.
60///
61/// The script arrives over the network, which is why the defaults are what they are:
62/// standalone PAC libraries have been broken exactly there — `pac-resolver`
63/// escaped its Node.js `vm` and reached RCE
64/// ([CVE-2021-23406](https://github.com/advisories/GHSA-9j49-mfvp-vmhm)) and `pacparser`
65/// had [CVE-2023-37360](https://github.com/manugarg/pacparser/security/advisories/GHSA-62q6-v997-f7v9).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct PacPolicy {
68 resolve_dns: bool,
69 allow_internal_addresses: bool,
70 my_ip_address: Option<IpAddr>,
71 timeout: Option<Duration>,
72 max_loop_iterations: u64,
73 recursion_limit: usize,
74 stack_size_limit: usize,
75 local_utc_offset: i32,
76 now: Option<SystemTime>,
77}
78
79impl Default for PacPolicy {
80 fn default() -> Self {
81 Self {
82 resolve_dns: false,
83 allow_internal_addresses: false,
84 my_ip_address: None,
85 timeout: Some(DEFAULT_PAC_TIMEOUT),
86 max_loop_iterations: DEFAULT_PAC_LOOP_LIMIT,
87 recursion_limit: DEFAULT_PAC_RECURSION_LIMIT,
88 stack_size_limit: DEFAULT_PAC_STACK_SIZE_LIMIT,
89 local_utc_offset: 0,
90 now: None,
91 }
92 }
93}
94
95impl PacPolicy {
96 /// The default policy: no DNS, no internal addresses, no real local IP, 5 s budget.
97 #[must_use]
98 pub fn new() -> Self {
99 Self::default()
100 }
101
102 /// Allow `dnsResolve`, `isResolvable` and `isInNet` to perform name resolution.
103 ///
104 /// With the default `false`, `dnsResolve` returns `null` and no DNS query is made.
105 #[must_use]
106 pub fn with_dns_resolution(mut self, enabled: bool) -> Self {
107 self.resolve_dns = enabled;
108 self
109 }
110
111 /// Allow answers in internal space (loopback, RFC 1918, link-local, CGNAT, … and the
112 /// IPv4-mapped spelling of those). Default `false` drops them.
113 ///
114 /// Classic `dnsResolve` is IPv4-only, so a native IPv6 answer — a ULA, say — never
115 /// reaches this flag either way. IP literals in the script are never filtered.
116 #[must_use]
117 pub fn with_internal_addresses(mut self, allowed: bool) -> Self {
118 self.allow_internal_addresses = allowed;
119 self
120 }
121
122 /// Set the address `myIpAddress()` reports. Unset → `127.0.0.1` (no auto-discovery).
123 #[must_use]
124 pub fn with_my_ip_address(mut self, address: IpAddr) -> Self {
125 self.my_ip_address = Some(address);
126 self
127 }
128
129 /// Wall-clock budget, or `None` to remove it (blocks the caller if the script hangs).
130 ///
131 /// `Some(Duration::ZERO)` is a budget of nothing, not the absence of one: every
132 /// evaluation answers [`Error::PacTimeout`](crate::Error::PacTimeout). `None` is how a
133 /// caller asks for no limit. `WinHttpPacResolver::with_timeout` answers zero the same
134 /// way — it is named in backticks rather than linked because that type exists only
135 /// under `pac-windows-native` on Windows, and a link to it fails the doc build
136 /// everywhere else.
137 ///
138 /// The budget bounds the call, not the script. On expiry `BoaEvaluator` returns
139 /// [`Error::PacTimeout`](crate::Error::PacTimeout) and abandons the evaluation thread,
140 /// which runs on until one of the VM limits stops it — so that thread, and whatever it
141 /// has allocated by then, outlives the call that asked for it. Nothing here interrupts
142 /// a running script: bounding the work is what the limits below are for.
143 ///
144 /// Read that per call and it sounds like untidiness; the cost is in the aggregate.
145 /// Abandoned threads do not queue behind each other, so an application resolving
146 /// repeatedly against a script that always overruns holds roughly
147 /// `overrun ÷ timeout` of them at once, each spinning a core until its own VM limit
148 /// lands. With the defaults that ratio is not small: a `while (true) {}` reaches
149 /// [`DEFAULT_PAC_LOOP_LIMIT`] in about 93 seconds on an unoptimized build of this
150 /// crate, against a budget of [`DEFAULT_PAC_TIMEOUT`]. Shortening the budget
151 /// widens the ratio rather than narrowing it. What bounds this is a lower
152 /// [`PacPolicy::with_max_loop_iterations`], or not letting the calls stack up.
153 #[must_use]
154 pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
155 self.timeout = timeout;
156 self
157 }
158
159 /// Loop-iteration cap (`u64::MAX` disables). Honoured via `boa_engine` `RuntimeLimits`.
160 ///
161 /// Iterations, not allocations — the same distinction
162 /// [`DEFAULT_PAC_STACK_SIZE_LIMIT`] draws. The limit is checked where the engine
163 /// re-enters a loop body, so whatever a builtin allocates within a call it never
164 /// reaches: a script that spends its memory in one `String.prototype.repeat` rather
165 /// than in a loop has nothing stopping it — the cap never fires, and
166 /// [`PacPolicy::with_timeout`] ends the wait rather than the work. That is the gap the
167 /// type doc's "Heap unbounded" names.
168 #[must_use]
169 pub fn with_max_loop_iterations(mut self, limit: u64) -> Self {
170 self.max_loop_iterations = limit;
171 self
172 }
173
174 /// Recursion depth cap. Default [`DEFAULT_PAC_RECURSION_LIMIT`] is boa's own.
175 #[must_use]
176 pub fn with_recursion_limit(mut self, limit: usize) -> Self {
177 self.recursion_limit = limit;
178 self
179 }
180
181 /// Value-stack entry cap (not bytes / OS stack). Default [`DEFAULT_PAC_STACK_SIZE_LIMIT`].
182 #[must_use]
183 pub fn with_stack_size_limit(mut self, limit: usize) -> Self {
184 self.stack_size_limit = limit;
185 self
186 }
187
188 /// Seconds from UTC treated as "local" for date/time predicates (default 0 = GMT).
189 #[must_use]
190 pub fn with_local_utc_offset(mut self, seconds: i32) -> Self {
191 self.local_utc_offset = seconds;
192 self
193 }
194
195 /// Pin the clock the time-dependent host functions see.
196 ///
197 /// Intended for tests and for reproducing a routing decision after the fact.
198 #[must_use]
199 pub fn with_now(mut self, now: SystemTime) -> Self {
200 self.now = Some(now);
201 self
202 }
203
204 /// Whether name resolution is permitted.
205 #[must_use]
206 pub fn resolve_dns(&self) -> bool {
207 self.resolve_dns
208 }
209
210 /// Whether resolution results in internal address space are kept.
211 #[must_use]
212 pub fn allow_internal_addresses(&self) -> bool {
213 self.allow_internal_addresses
214 }
215
216 /// The address `myIpAddress()` reports, or `127.0.0.1` when unset.
217 #[must_use]
218 pub fn my_ip_address(&self) -> IpAddr {
219 self.my_ip_address
220 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
221 }
222
223 /// The wall-clock evaluation budget.
224 #[must_use]
225 pub fn timeout(&self) -> Option<Duration> {
226 self.timeout
227 }
228
229 /// The loop iteration cap.
230 #[must_use]
231 pub fn max_loop_iterations(&self) -> u64 {
232 self.max_loop_iterations
233 }
234
235 /// The function-call recursion depth cap.
236 #[must_use]
237 pub fn recursion_limit(&self) -> usize {
238 self.recursion_limit
239 }
240
241 /// The cap on the engine's internal value-stack length.
242 #[must_use]
243 pub fn stack_size_limit(&self) -> usize {
244 self.stack_size_limit
245 }
246
247 /// The offset from UTC, in seconds, that counts as local time.
248 #[must_use]
249 pub fn local_utc_offset(&self) -> i32 {
250 self.local_utc_offset
251 }
252
253 /// The pinned clock, when [`with_now`](Self::with_now) was used.
254 #[must_use]
255 pub fn now(&self) -> Option<SystemTime> {
256 self.now
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 // Everything else on this type is held where a script can see it: the engine tests set a
265 // flag and read what `dnsResolve`, `myIpAddress` or a range predicate answers. Two
266 // defaults cannot be reached that way, because observing them means either hanging or
267 // reading the real clock, and both of those are what a test sets out to avoid. So they
268 // are held here, as values.
269 //
270 // `timeout` decides which door `BoaEvaluator::evaluate` opens: `Some` spawns the script
271 // on a thread the call gives up on, `None` runs it on the caller's own thread with no
272 // budget at all. A default of `None` therefore hands an application built on
273 // `PacPolicy::new()` a remote script — under WPAD, one from whoever answered the
274 // discovery query — with nothing to end it. The five seconds are stated in the type doc
275 // and in the constant's own, and changing them changes what every default caller does
276 // when a script does not come back.
277 #[test]
278 fn the_default_budget_is_five_seconds_and_not_the_absence_of_one() {
279 assert_eq!(PacPolicy::new().timeout(), Some(DEFAULT_PAC_TIMEOUT));
280 assert_eq!(DEFAULT_PAC_TIMEOUT, Duration::from_secs(5));
281 }
282
283 // `pac::time` reads `now()` and falls back to `SystemTime::now()`, so an unset default is
284 // what makes the time predicates answer about today. Pinned instead, `dateRange`,
285 // `weekdayRange` and `timeRange` would all answer about that one instant forever, and
286 // every engine test would still pass: they each pin a clock of their own precisely so
287 // they do not depend on this.
288 #[test]
289 fn the_default_clock_is_the_real_one() {
290 assert_eq!(PacPolicy::new().now(), None);
291 }
292
293 // The loop cap is the third limit and the only one whose default this file has to hold.
294 // `recursion_limit` and `stack_size_limit` are reached by scripts that boa tests here
295 // actually run, so lowering either field draws red on its own. Ten million iterations is
296 // not a number a test can spend, which is why the same field is invisible: set to
297 // `u64::MAX` — the value `with_max_loop_iterations` documents as disabling the cap — the
298 // whole suite still passes, because every loop test above passes a cap of its own.
299 //
300 // `boa.rs`'s `the_two_limits_that_claim_to_be_boas_still_are` holds the near half of this,
301 // that the constant is not `u64::MAX`, and argues there about why an OS-supplied script's
302 // `while (true)` must not be left to a timeout that ends the wait and not the work. The
303 // constant staying 10 000 000 while the default stops carrying it is the gap between the
304 // two. They are asserted apart because they fail for different reasons: that one when boa
305 // changes, this one when this crate does.
306 //
307 // The setter is the same shape one level down. Folded into a floor —
308 // `limit.max(DEFAULT_PAC_LOOP_LIMIT)` — it passes as well, because the one test that caps
309 // an endless loop at 10 000 still gets its `PacEvaluation`, ten thousand times later. That
310 // surfaces as half a minute of test time rather than as a failure.
311 #[test]
312 fn the_default_carries_the_loop_cap_and_the_setter_can_tighten_it() {
313 assert_eq!(
314 PacPolicy::new().max_loop_iterations(),
315 DEFAULT_PAC_LOOP_LIMIT
316 );
317 assert_eq!(
318 PacPolicy::new()
319 .with_max_loop_iterations(5)
320 .max_loop_iterations(),
321 5
322 );
323 }
324
325 // `with_timeout` takes an `Option` rather than a `Duration` so that removing the budget
326 // is something a caller can ask for, and its doc says so. Folded back into the default,
327 // the request is refused in silence — the caller that deliberately accepted a blocking
328 // evaluation gets a five-second one instead, and finds out from a `PacTimeout` it was not
329 // expecting. Zero is the other end of the same axis and means a budget of nothing.
330 #[test]
331 fn removing_the_budget_is_a_request_the_builder_honours() {
332 assert_eq!(PacPolicy::new().with_timeout(None).timeout(), None);
333 assert_eq!(
334 PacPolicy::new()
335 .with_timeout(Some(Duration::ZERO))
336 .timeout(),
337 Some(Duration::ZERO)
338 );
339 }
340}