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/// Safety envelope for PAC evaluation (`pac-quickjs` and `pac-subprocess`; ignored by the
11/// native resolvers, which hand the script to the OS).
12///
13/// Defaults: no DNS, drop internal answers, `myIpAddress()` → `127.0.0.1`, 5 s timeout.
14/// `QuickJsEvaluator` interrupts the script at the deadline, and bounds the heap and the
15/// native stack, the parser's included, with fixed limits of its own.
16///
17/// One default is not a limit: the crate reads no time zone, so
18/// [`local_utc_offset`](Self::local_utc_offset) is 0 and `weekdayRange`, `dateRange` and
19/// `timeRange` answer in GMT whether or not the script passed `"GMT"`; a browser reads the
20/// host's zone there. [`with_local_utc_offset`](Self::with_local_utc_offset) is the only
21/// way to move them, and a fixed offset does not follow DST.
22///
23/// The defaults restrict evaluation because scripts arrive over the network and standalone
24/// PAC libraries have had security vulnerabilities: `pac-resolver` escaped its Node.js `vm`
25/// and reached RCE ([CVE-2021-23406](https://github.com/advisories/GHSA-9j49-mfvp-vmhm)),
26/// and `pacparser` had
27/// [CVE-2023-37360](https://github.com/manugarg/pacparser/security/advisories/GHSA-62q6-v997-f7v9).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct PacPolicy {
30    resolve_dns: bool,
31    allow_internal_addresses: bool,
32    my_ip_address: Option<IpAddr>,
33    timeout: Option<Duration>,
34    local_utc_offset: i32,
35    now: Option<SystemTime>,
36}
37
38impl Default for PacPolicy {
39    fn default() -> Self {
40        Self {
41            resolve_dns: false,
42            allow_internal_addresses: false,
43            my_ip_address: None,
44            timeout: Some(DEFAULT_PAC_TIMEOUT),
45            local_utc_offset: 0,
46            now: None,
47        }
48    }
49}
50
51impl PacPolicy {
52    /// The default policy: no DNS, no internal addresses, no real local IP, 5 s budget.
53    #[must_use]
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Allow `dnsResolve`, `isResolvable` and `isInNet` to perform name resolution.
59    ///
60    /// With the default `false`, `dnsResolve` returns `null` and no DNS query is made.
61    #[must_use]
62    pub fn with_dns_resolution(mut self, enabled: bool) -> Self {
63        self.resolve_dns = enabled;
64        self
65    }
66
67    /// Allow answers in internal space (loopback, RFC 1918, link-local, CGNAT, … and the
68    /// IPv4-mapped spelling of those). Default `false` drops them.
69    ///
70    /// Classic `dnsResolve` is IPv4-only, so a native IPv6 answer (a ULA, say) never
71    /// reaches this flag either way. IP literals in the script are never filtered.
72    #[must_use]
73    pub fn with_internal_addresses(mut self, allowed: bool) -> Self {
74        self.allow_internal_addresses = allowed;
75        self
76    }
77
78    /// Set the address `myIpAddress()` reports. Unset → `127.0.0.1` (no auto-discovery).
79    #[must_use]
80    pub fn with_my_ip_address(mut self, address: IpAddr) -> Self {
81        self.my_ip_address = Some(address);
82        self
83    }
84
85    /// Wall-clock budget, or `None` to remove it (blocks the caller if the script hangs).
86    ///
87    /// `Some(Duration::ZERO)` is a zero budget, not an unlimited one: every evaluation
88    /// returns [`Error::PacTimeout`](crate::Error::PacTimeout). `None` removes the limit.
89    /// `WinHttpPacResolver::with_timeout` treats zero the same way. Its name is left
90    /// unlinked because the type exists only under `pac-windows-native` on Windows; linking
91    /// it breaks doc builds elsewhere.
92    ///
93    /// `QuickJsEvaluator` interrupts the script at the deadline, so its thread is free for
94    /// the next evaluation when the call returns, unless a host function (`dnsResolve` with
95    /// DNS on) is still running then.
96    ///
97    /// Evaluation threads are limited process-wide to the number of cores, with a minimum
98    /// of 4. A thread holds its slot until its script ends, including after the call times
99    /// out. When all slots are occupied, a call waits for one within the same budget and
100    /// returns [`Error::PacSaturated`](crate::Error::PacSaturated) without running the
101    /// script if the budget expires. Run untrusted scripts in a process the caller can kill
102    /// (`pac-subprocess`). With `None`, the in-process engine runs on the calling thread
103    /// without a slot, bypassing the thread limit as well as the timeout.
104    /// `SubprocessEvaluator` (`pac-subprocess`) still requires a slot and waits without a
105    /// time limit for a slot and its worker's response.
106    #[must_use]
107    pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
108        self.timeout = timeout;
109        self
110    }
111
112    /// Seconds from UTC treated as "local" for date/time predicates (default 0 = GMT).
113    #[must_use]
114    pub fn with_local_utc_offset(mut self, seconds: i32) -> Self {
115        self.local_utc_offset = seconds;
116        self
117    }
118
119    /// Pin the clock the time-dependent host functions see.
120    ///
121    /// Intended for tests and for reproducing a routing decision after the fact.
122    #[must_use]
123    pub fn with_now(mut self, now: SystemTime) -> Self {
124        self.now = Some(now);
125        self
126    }
127
128    /// Whether name resolution is permitted.
129    #[must_use]
130    pub fn resolve_dns(&self) -> bool {
131        self.resolve_dns
132    }
133
134    /// Whether resolution results in internal address space are kept.
135    #[must_use]
136    pub fn allow_internal_addresses(&self) -> bool {
137        self.allow_internal_addresses
138    }
139
140    /// The address `myIpAddress()` reports, or `127.0.0.1` when unset.
141    #[must_use]
142    pub fn my_ip_address(&self) -> IpAddr {
143        self.my_ip_address
144            .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
145    }
146
147    /// The wall-clock evaluation budget.
148    #[must_use]
149    pub fn timeout(&self) -> Option<Duration> {
150        self.timeout
151    }
152
153    /// The offset from UTC, in seconds, that counts as local time.
154    #[must_use]
155    pub fn local_utc_offset(&self) -> i32 {
156        self.local_utc_offset
157    }
158
159    /// The pinned clock, when [`with_now`](Self::with_now) was used.
160    #[must_use]
161    pub fn now(&self) -> Option<SystemTime> {
162        self.now
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    // Everything else on this type is held where a script can see it: the engine tests set a
171    // flag and read what `dnsResolve`, `myIpAddress` or a range predicate answers. Two
172    // defaults cannot be reached that way, because observing them means either hanging or
173    // reading the real clock, and both of those are what a test sets out to avoid. So they
174    // are held here, as values.
175    //
176    // `timeout` selects the execution mode for `QuickJsEvaluator::evaluate`: `Some` spawns
177    // the script on a thread the call gives up on, `None` runs it on the caller's own
178    // thread with no budget at all. A default of `None` therefore hands an application
179    // built on `PacPolicy::new()` a remote script (under WPAD, one from whoever answered
180    // the discovery query) with nothing to end it. The five seconds are stated in the type
181    // doc and in the constant's own, and changing them changes what every default caller
182    // does when a script does not come back.
183    #[test]
184    fn the_default_budget_is_five_seconds_and_not_the_absence_of_one() {
185        assert_eq!(PacPolicy::new().timeout(), Some(DEFAULT_PAC_TIMEOUT));
186        assert_eq!(DEFAULT_PAC_TIMEOUT, Duration::from_secs(5));
187    }
188
189    // `pac::time` reads `now()` and falls back to `SystemTime::now()`, so with the default
190    // unset the time predicates answer about today. Pinned instead, `dateRange`,
191    // `weekdayRange` and `timeRange` would all answer about that one instant forever, and
192    // every engine test would still pass: they each pin a clock of their own so they do not
193    // depend on this.
194    #[test]
195    fn the_default_clock_is_the_real_one() {
196        assert_eq!(PacPolicy::new().now(), None);
197    }
198
199    // `with_timeout` takes an `Option` rather than a `Duration` so that removing the budget
200    // is something a caller can ask for, and its doc says so. Folded back into the default,
201    // the request is refused in silence: the caller that accepted a blocking evaluation
202    // gets a five-second one instead, and finds out from a `PacTimeout` it was not
203    // expecting. Zero is the other end of the same axis and means a budget of nothing.
204    #[test]
205    fn removing_the_budget_is_a_request_the_builder_honours() {
206        assert_eq!(PacPolicy::new().with_timeout(None).timeout(), None);
207        assert_eq!(
208            PacPolicy::new()
209                .with_timeout(Some(Duration::ZERO))
210                .timeout(),
211            Some(Duration::ZERO)
212        );
213    }
214}