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}