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 ends with
94 /// the call unless a host function (`dnsResolve` with DNS on) is still running then.
95 ///
96 /// Evaluation threads are limited process-wide to the number of cores, with a minimum
97 /// of 4. A thread holds its slot until its script ends, including after the call times
98 /// out. When all slots are occupied, a call waits for one within the same budget and
99 /// returns [`Error::PacSaturated`](crate::Error::PacSaturated) without running the
100 /// script if the budget expires. Run untrusted scripts in a process the caller can kill
101 /// (`pac-subprocess`). With `None`, the in-process engine runs on the calling thread
102 /// without a slot, bypassing the thread limit as well as the timeout.
103 /// `SubprocessEvaluator` (`pac-subprocess`) still requires a slot and waits without a
104 /// time limit for a slot and its worker's response.
105 #[must_use]
106 pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
107 self.timeout = timeout;
108 self
109 }
110
111 /// Seconds from UTC treated as "local" for date/time predicates (default 0 = GMT).
112 #[must_use]
113 pub fn with_local_utc_offset(mut self, seconds: i32) -> Self {
114 self.local_utc_offset = seconds;
115 self
116 }
117
118 /// Pin the clock the time-dependent host functions see.
119 ///
120 /// Intended for tests and for reproducing a routing decision after the fact.
121 #[must_use]
122 pub fn with_now(mut self, now: SystemTime) -> Self {
123 self.now = Some(now);
124 self
125 }
126
127 /// Whether name resolution is permitted.
128 #[must_use]
129 pub fn resolve_dns(&self) -> bool {
130 self.resolve_dns
131 }
132
133 /// Whether resolution results in internal address space are kept.
134 #[must_use]
135 pub fn allow_internal_addresses(&self) -> bool {
136 self.allow_internal_addresses
137 }
138
139 /// The address `myIpAddress()` reports, or `127.0.0.1` when unset.
140 #[must_use]
141 pub fn my_ip_address(&self) -> IpAddr {
142 self.my_ip_address
143 .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
144 }
145
146 /// The wall-clock evaluation budget.
147 #[must_use]
148 pub fn timeout(&self) -> Option<Duration> {
149 self.timeout
150 }
151
152 /// The offset from UTC, in seconds, that counts as local time.
153 #[must_use]
154 pub fn local_utc_offset(&self) -> i32 {
155 self.local_utc_offset
156 }
157
158 /// The pinned clock, when [`with_now`](Self::with_now) was used.
159 #[must_use]
160 pub fn now(&self) -> Option<SystemTime> {
161 self.now
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 // Everything else on this type is held where a script can see it: the engine tests set a
170 // flag and read what `dnsResolve`, `myIpAddress` or a range predicate answers. Two
171 // defaults cannot be reached that way, because observing them means either hanging or
172 // reading the real clock, and both of those are what a test sets out to avoid. So they
173 // are held here, as values.
174 //
175 // `timeout` selects the execution mode for `QuickJsEvaluator::evaluate`: `Some` spawns
176 // the script on a thread the call gives up on, `None` runs it on the caller's own
177 // thread with no budget at all. A default of `None` therefore hands an application
178 // built on `PacPolicy::new()` a remote script (under WPAD, one from whoever answered
179 // the discovery query) with nothing to end it. The five seconds are stated in the type
180 // doc and in the constant's own, and changing them changes what every default caller
181 // does when a script does not come back.
182 #[test]
183 fn the_default_budget_is_five_seconds_and_not_the_absence_of_one() {
184 assert_eq!(PacPolicy::new().timeout(), Some(DEFAULT_PAC_TIMEOUT));
185 assert_eq!(DEFAULT_PAC_TIMEOUT, Duration::from_secs(5));
186 }
187
188 // `pac::time` reads `now()` and falls back to `SystemTime::now()`, so with the default
189 // unset the time predicates answer about today. Pinned instead, `dateRange`,
190 // `weekdayRange` and `timeRange` would all answer about that one instant forever, and
191 // every engine test would still pass: they each pin a clock of their own so they do not
192 // depend on this.
193 #[test]
194 fn the_default_clock_is_the_real_one() {
195 assert_eq!(PacPolicy::new().now(), None);
196 }
197
198 // `with_timeout` takes an `Option` rather than a `Duration` so that removing the budget
199 // is something a caller can ask for, and its doc says so. Folded back into the default,
200 // the request is refused in silence: the caller that accepted a blocking evaluation
201 // gets a five-second one instead, and finds out from a `PacTimeout` it was not
202 // expecting. Zero is the other end of the same axis and means a budget of nothing.
203 #[test]
204 fn removing_the_budget_is_a_request_the_builder_honours() {
205 assert_eq!(PacPolicy::new().with_timeout(None).timeout(), None);
206 assert_eq!(
207 PacPolicy::new()
208 .with_timeout(Some(Duration::ZERO))
209 .timeout(),
210 Some(Duration::ZERO)
211 );
212 }
213}