Skip to main content

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}