Skip to main content

proxy_watch/pac/
boa.rs

1//! `pac-boa`: PAC on `boa_engine`. Hostfns in [`super::hostfn`]; limits in [`PacPolicy`].
2//!
3//! The PAC host functions only — no fetch/XHR/FS. Loop/recursion/stack caps plus
4//! optional wall-clock timeout on a dedicated thread (overrun thread abandoned).
5//! Untrusted PAC: enable DNS/local-IP only via [`PacPolicy`]; bound heap/work outside this crate.
6//! None of those caps reaches the parse phase — deep nesting overflows the native stack and
7//! aborts the process before `run` gets a chance to enforce anything. [`PacPolicy`]'s own
8//! documentation carries the measurement and what a caller has to do about it.
9
10use std::sync::mpsc::{self, RecvTimeoutError};
11use std::thread;
12use std::time::Duration;
13
14use boa_engine::{Context, JsResult, JsValue, NativeFunction, Source, js_string};
15use url::Url;
16
17use crate::error::Error;
18use crate::resolve::ProxyStep;
19
20use super::hostfn;
21use super::policy::PacPolicy;
22use super::result::parse_find_proxy_result;
23use super::{PacEvaluator, PacScript};
24
25/// [`PacEvaluator`] on `boa_engine`. Prefers `FindProxyForURL`; `FindProxyForURLEx` only if
26/// it is the sole entry point (Ex-only hostfns are not registered).
27///
28/// A deeply nested script aborts the process while parsing, before any [`PacPolicy`] limit
29/// applies; see [`PacPolicy`] for the measurement and the caller-side mitigation.
30///
31/// ```
32/// # #[cfg(feature = "pac-boa")] {
33/// use proxy_watch::pac::{BoaEvaluator, PacEvaluator, PacPolicy, PacScript};
34/// use proxy_watch::Url;
35///
36/// let evaluator = BoaEvaluator::new(PacPolicy::new());
37/// let script = PacScript::new("function FindProxyForURL(url, host) { return 'DIRECT'; }");
38/// let url = Url::parse("http://example.com/").unwrap();
39///
40/// let steps = evaluator.evaluate(&script, &url, "example.com")?;
41/// assert!(steps[0].is_direct());
42/// # }
43/// # Ok::<(), proxy_watch::Error>(())
44/// ```
45#[derive(Debug, Clone)]
46pub struct BoaEvaluator {
47    policy: PacPolicy,
48}
49
50impl BoaEvaluator {
51    /// Build an evaluator that runs scripts under `policy`.
52    #[must_use]
53    pub fn new(policy: PacPolicy) -> Self {
54        Self { policy }
55    }
56
57    /// The policy this evaluator applies.
58    #[must_use]
59    pub fn policy(&self) -> &PacPolicy {
60        &self.policy
61    }
62}
63
64impl PacEvaluator for BoaEvaluator {
65    fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error> {
66        // Idempotent, and repeated here because this impl is reachable without going
67        // through `pac::evaluate_with_host` — see `PacEvaluator::evaluate`'s doc.
68        let url = &crate::pac::sanitize_url(url);
69        match self.policy.timeout() {
70            Some(timeout) => run_with_timeout(
71                script.source().to_owned(),
72                url.as_str().to_owned(),
73                host.to_owned(),
74                self.policy,
75                timeout,
76            ),
77            None => run(script.source(), url.as_str(), host, self.policy),
78        }
79    }
80}
81
82// Run the script on a throw-away thread and give up on it after `timeout`.
83//
84// A zero `timeout` means "no budget at all" here, not "unlimited", and is answered before
85// the thread exists. Letting it through would spawn the script and then `recv_timeout` would
86// return at once, leaving untrusted code running on a thread nothing is waiting on — a budget
87// of nothing has to buy nothing, not an unsupervised run.
88// [`super::winhttp::WinHttpPacResolver`] refuses zero too, for a reason of its own
89// (`WinHttpSetTimeouts` reads it as infinite), and one step earlier: at construction rather
90// than per evaluation.
91//
92// What the timeout does not do is stop the thread. `recv_timeout` gives up on the answer; the
93// script keeps running. In the ordinary case that still ends, because [`run`] applies the loop,
94// recursion and value-stack caps to the context it builds, and an abandoned evaluation walks
95// into one of them. The caps are not a termination proof, though, and two paths leave them
96// behind outright. Parsing is one, and it is worse than unbounded — it aborts the process, as
97// this module's own doc says up top. The other is [`super::hostfn::dns_resolve`], which asks
98// the system resolver through `(host, 0).to_socket_addrs()`; std puts no timeout on that call,
99// so a script naming a host nothing answers for parks the abandoned thread in the resolver for
100// as long as the OS takes, with no cap of ours in the way. That second path is off unless the
101// caller opens it: [`PacPolicy::resolve_dns`] is `false` by default, and `resolve_ipv4` returns
102// `None` on that flag before it reaches the network.
103fn run_with_timeout(
104    source: String,
105    url: String,
106    host: String,
107    policy: PacPolicy,
108    timeout: Duration,
109) -> Result<Vec<ProxyStep>, Error> {
110    if timeout.is_zero() {
111        return Err(Error::PacTimeout { timeout });
112    }
113
114    let (sender, receiver) = mpsc::sync_channel(1);
115    thread::Builder::new()
116        .name("proxy-watch-pac".to_owned())
117        .spawn(move || {
118            // The receiver may already be gone; the send failing is the normal outcome
119            // of a timeout and is not an error here.
120            let _ = sender.send(run(&source, &url, &host, policy));
121        })
122        .map_err(|source| Error::io("spawning the PAC evaluation thread", source))?;
123
124    match receiver.recv_timeout(timeout) {
125        Ok(result) => result,
126        Err(RecvTimeoutError::Timeout) => Err(Error::PacTimeout { timeout }),
127        Err(RecvTimeoutError::Disconnected) => Err(Error::pac_evaluation(
128            "the PAC evaluation thread ended without producing a result",
129        )),
130    }
131}
132
133// Copy the loop, recursion and value-stack limits from `policy` onto `context`.
134fn apply_runtime_limits(context: &mut Context, policy: PacPolicy) {
135    let limits = context.runtime_limits_mut();
136    limits.set_loop_iteration_limit(policy.max_loop_iterations());
137    limits.set_recursion_limit(policy.recursion_limit());
138    limits.set_stack_size_limit(policy.stack_size_limit());
139}
140
141// Build a context, load the script, call `FindProxyForURL` and parse the answer.
142fn run(source: &str, url: &str, host: &str, policy: PacPolicy) -> Result<Vec<ProxyStep>, Error> {
143    let mut context = Context::default();
144    apply_runtime_limits(&mut context, policy);
145
146    register_host_functions(&mut context, policy).map_err(|error| {
147        Error::pac_evaluation(format!("could not install the PAC host functions: {error}"))
148    })?;
149
150    context.eval(Source::from_bytes(source)).map_err(|error| {
151        Error::pac_evaluation(format!("the PAC script failed to load: {error}"))
152    })?;
153
154    // Prefer `FindProxyForURL`; fall back to `FindProxyForURLEx` only when it is the sole
155    // entry point (Ex-only hostfns like `dnsResolveEx` are not registered — Chromium
156    // always calls plain `FindProxyForURL` instead).
157    let global = context.global_object();
158    let mut callee = None;
159    for name in [
160        js_string!("FindProxyForURL"),
161        js_string!("FindProxyForURLEx"),
162    ] {
163        // Reading the property can itself throw: the script is free to install an accessor
164        // (or a `Proxy` trap) under the entry-point name. Propagate that instead of
165        // treating it as absence — a script that throws from the getter has defined the
166        // name, so "defines no FindProxyForURL function" would be a false report, and it
167        // is also not the "sole entry point" case the `Ex` fallback below exists for.
168        let value = global.get(name, &mut context).map_err(|error| {
169            Error::pac_evaluation(format!("reading the PAC entry point failed: {error}"))
170        })?;
171        if let Some(function) = value.as_callable() {
172            callee = Some(function);
173            break;
174        }
175    }
176    let Some(callee) = callee else {
177        return Err(Error::pac_evaluation(
178            "the PAC script defines no FindProxyForURL function",
179        ));
180    };
181
182    let args = [
183        JsValue::from(js_string!(url)),
184        JsValue::from(js_string!(host)),
185    ];
186    let returned = callee
187        .call(&JsValue::undefined(), &args, &mut context)
188        .map_err(|error| Error::pac_evaluation(format!("FindProxyForURL failed: {error}")))?;
189
190    // `to_std_string_escaped` keeps a JS string's content whole even when it holds a
191    // UTF-16 code unit no `char` can represent: an unpaired surrogate becomes a
192    // `\uXXXX`-style literal in the returned `String` rather than a replacement
193    // character. That is the right call for JS text — round-trippable, and boa's own
194    // established convention — but it is not what this crate's WinHTTP-side UTF-16
195    // conversions do (`sys::win::ffi::wide_ptr_to_string` substitutes U+FFFD). The two
196    // never actually compare a script's raw return string against each other, though:
197    // WinHTTP's own native engine parses `FindProxyForURL`'s "PROXY host:port; DIRECT"
198    // grammar in its own code before this crate ever sees a wide string, so only
199    // already-tokenised hostnames — not arbitrary script-authored text — reach that
200    // lossy conversion. A script that returns a string containing an unpaired surrogate
201    // is the one case where "same script, same result" (the claim this module's sibling
202    // documents for the two engines) does not hold: this line keeps the surrogate as
203    // literal escape text.
204    let text = returned
205        .to_string(&mut context)
206        .map_err(|error| {
207            Error::pac_evaluation(format!(
208                "FindProxyForURL returned a value that is not a string: {error}"
209            ))
210        })?
211        .to_std_string_escaped();
212
213    parse_find_proxy_result(&text)
214}
215
216// Read argument `index` as a string, treating a missing argument as `""`.
217fn arg_string(args: &[JsValue], index: usize, context: &mut Context) -> JsResult<String> {
218    match args.get(index) {
219        Some(value) => Ok(value.to_string(context)?.to_std_string_escaped()),
220        None => Ok(String::new()),
221    }
222}
223
224// Read every argument as a string, for the variadic date/time functions.
225fn arg_strings(args: &[JsValue], context: &mut Context) -> JsResult<Vec<String>> {
226    let mut out = Vec::with_capacity(args.len());
227    for value in args {
228        out.push(value.to_string(context)?.to_std_string_escaped());
229    }
230    Ok(out)
231}
232
233// Install the fourteen host functions on the global object.
234//
235// `policy` is [`Copy`], which is what lets every binding be a plain
236// `NativeFunction::from_copy_closure` with no garbage-collected capture and no `unsafe`.
237fn register_host_functions(context: &mut Context, policy: PacPolicy) -> JsResult<()> {
238    context.register_global_callable(
239        js_string!("isPlainHostName"),
240        1,
241        NativeFunction::from_copy_closure(|_this, args, context| {
242            let host = arg_string(args, 0, context)?;
243            Ok(JsValue::from(hostfn::is_plain_host_name(&host)))
244        }),
245    )?;
246
247    context.register_global_callable(
248        js_string!("dnsDomainIs"),
249        2,
250        NativeFunction::from_copy_closure(|_this, args, context| {
251            let host = arg_string(args, 0, context)?;
252            let domain = arg_string(args, 1, context)?;
253            Ok(JsValue::from(hostfn::dns_domain_is(&host, &domain)))
254        }),
255    )?;
256
257    context.register_global_callable(
258        js_string!("localHostOrDomainIs"),
259        2,
260        NativeFunction::from_copy_closure(|_this, args, context| {
261            let host = arg_string(args, 0, context)?;
262            let hostdom = arg_string(args, 1, context)?;
263            Ok(JsValue::from(hostfn::local_host_or_domain_is(
264                &host, &hostdom,
265            )))
266        }),
267    )?;
268
269    context.register_global_callable(
270        js_string!("isResolvable"),
271        1,
272        NativeFunction::from_copy_closure(move |_this, args, context| {
273            let host = arg_string(args, 0, context)?;
274            Ok(JsValue::from(hostfn::is_resolvable(&host, &policy)))
275        }),
276    )?;
277
278    context.register_global_callable(
279        js_string!("isInNet"),
280        3,
281        NativeFunction::from_copy_closure(move |_this, args, context| {
282            let host = arg_string(args, 0, context)?;
283            let pattern = arg_string(args, 1, context)?;
284            let mask = arg_string(args, 2, context)?;
285            Ok(JsValue::from(hostfn::is_in_net(
286                &host, &pattern, &mask, &policy,
287            )))
288        }),
289    )?;
290
291    context.register_global_callable(
292        js_string!("dnsResolve"),
293        1,
294        NativeFunction::from_copy_closure(move |_this, args, context| {
295            let host = arg_string(args, 0, context)?;
296            Ok(match hostfn::dns_resolve(&host, &policy) {
297                Some(address) => JsValue::from(js_string!(address.to_string())),
298                // The PAC contract is `null`, not the empty string: scripts test it
299                // with `if (ip == null)`.
300                None => JsValue::null(),
301            })
302        }),
303    )?;
304
305    context.register_global_callable(
306        js_string!("myIpAddress"),
307        0,
308        NativeFunction::from_copy_closure(move |_this, _args, _context| {
309            Ok(JsValue::from(js_string!(
310                hostfn::my_ip_address(&policy).to_string()
311            )))
312        }),
313    )?;
314
315    context.register_global_callable(
316        js_string!("dnsDomainLevels"),
317        1,
318        NativeFunction::from_copy_closure(|_this, args, context| {
319            let host = arg_string(args, 0, context)?;
320            Ok(JsValue::from(hostfn::dns_domain_levels(&host) as f64))
321        }),
322    )?;
323
324    context.register_global_callable(
325        js_string!("shExpMatch"),
326        2,
327        NativeFunction::from_copy_closure(|_this, args, context| {
328            let text = arg_string(args, 0, context)?;
329            let pattern = arg_string(args, 1, context)?;
330            Ok(JsValue::from(hostfn::sh_exp_match(&text, &pattern)))
331        }),
332    )?;
333
334    context.register_global_callable(
335        js_string!("weekdayRange"),
336        2,
337        NativeFunction::from_copy_closure(move |_this, args, context| {
338            let args = arg_strings(args, context)?;
339            Ok(JsValue::from(hostfn::weekday_range(&args, &policy)))
340        }),
341    )?;
342
343    context.register_global_callable(
344        js_string!("dateRange"),
345        6,
346        NativeFunction::from_copy_closure(move |_this, args, context| {
347            let args = arg_strings(args, context)?;
348            Ok(JsValue::from(hostfn::date_range(&args, &policy)))
349        }),
350    )?;
351
352    context.register_global_callable(
353        js_string!("timeRange"),
354        6,
355        NativeFunction::from_copy_closure(move |_this, args, context| {
356            let args = arg_strings(args, context)?;
357            Ok(JsValue::from(hostfn::time_range(&args, &policy)))
358        }),
359    )?;
360
361    context.register_global_callable(
362        js_string!("alert"),
363        1,
364        NativeFunction::from_copy_closure(|_this, args, context| {
365            let message = arg_string(args, 0, context)?;
366            hostfn::alert(&message);
367            Ok(JsValue::undefined())
368        }),
369    )?;
370
371    context.register_global_callable(
372        js_string!("convert_addr"),
373        1,
374        NativeFunction::from_copy_closure(|_this, args, context| {
375            let address = arg_string(args, 0, context)?;
376            Ok(JsValue::from(f64::from(hostfn::convert_addr(&address))))
377        }),
378    )?;
379
380    Ok(())
381}
382
383#[cfg(test)]
384mod tests {
385    use std::time::{Duration, UNIX_EPOCH};
386
387    use super::*;
388
389    fn steps(source: &str, url: &str, policy: PacPolicy) -> Result<Vec<ProxyStep>, Error> {
390        let url = Url::parse(url).unwrap();
391        let host = url.host_str().unwrap_or_default().to_owned();
392        BoaEvaluator::new(policy).evaluate(&PacScript::new(source), &url, &host)
393    }
394
395    // [`PacPolicy::with_timeout`] says `Some(Duration::ZERO)` is "a budget of nothing, not
396    // the absence of one", and this test is the only thing holding it on this side. Let a
397    // zero fall through to the untimed route — `Some(t) if !t.is_zero()`, so `None` catches
398    // it — and a zero timeout means the opposite of what it says. What a caller loses is the
399    // wall clock on
400    // attacker-supplied JavaScript: the loop and recursion caps still hold, but the one
401    // bound that answers for work those caps consider legal is gone. A timeout computed as
402    // `deadline - now` reaches zero on its own.
403    //
404    // The script is one that plainly succeeds without a budget
405    // (`a_constant_script_returns_direct` runs the same source), so the `Err` here can only
406    // mean the budget was applied.
407    //
408    // What this holds is the answer, not the guard inside `run_with_timeout`. With that
409    // guard disabled, all four of a valid script, an endless one, a syntax error
410    // and a script with no entry point still come back `PacTimeout { timeout: 0ns }`,
411    // because `recv_timeout(ZERO)` gives up before the thread it spawned can reply. The
412    // guard's whole worth is that no thread is spawned to keep running unwatched, and no
413    // return value can see that — so the guard gets no case of its own.
414    #[test]
415    fn a_zero_timeout_is_a_budget_of_nothing_not_the_absence_of_one() {
416        let error = steps(
417            "function FindProxyForURL(url, host) { return 'DIRECT'; }",
418            "http://example.com/",
419            PacPolicy::new().with_timeout(Some(Duration::ZERO)),
420        )
421        .unwrap_err();
422        assert!(
423            matches!(error, Error::PacTimeout { timeout } if timeout.is_zero()),
424            "{error:?}"
425        );
426    }
427
428    #[test]
429    fn a_constant_script_returns_direct() {
430        let result = steps(
431            "function FindProxyForURL(url, host) { return 'DIRECT'; }",
432            "http://example.com/",
433            PacPolicy::new(),
434        )
435        .unwrap();
436        assert_eq!(result, vec![ProxyStep::Direct]);
437    }
438
439    #[test]
440    fn the_url_and_host_arguments_reach_the_script() {
441        let result = steps(
442            "function FindProxyForURL(url, host) {
443                 if (url.indexOf('/secret') != -1 && host == 'example.com') {
444                     return 'PROXY hit:1';
445                 }
446                 return 'PROXY miss:1';
447             }",
448            "http://example.com/secret",
449            PacPolicy::new(),
450        )
451        .unwrap();
452        assert_eq!(result[0].endpoint().unwrap().authority(), "hit:1");
453    }
454
455    // Sanitising is repeated inside this impl because a caller holding a `BoaEvaluator`
456    // reaches it without going through [`crate::pac::evaluate_with_host`], and the script
457    // it hands the URL to is the untrusted party in the room. Nothing else could catch a
458    // dropped call: [`crate::pac::sanitize_url`]'s own tests exercise the function, not
459    // whether an engine still asks for it.
460    #[test]
461    fn the_script_is_handed_a_sanitized_url() {
462        let source = "function FindProxyForURL(url, host) {
463                 if (url === 'http://example.net/a/b?q=1') { return 'DIRECT'; }
464                 if (url.indexOf('hunter2') != -1) { return 'PROXY password:1'; }
465                 if (url.indexOf('alice') != -1) { return 'PROXY username:1'; }
466                 if (url.indexOf('#frag') != -1) { return 'PROXY fragment:1'; }
467                 return 'PROXY unexpected:1';
468             }";
469        let result = steps(
470            source,
471            "http://alice:hunter2@example.net/a/b?q=1#frag",
472            PacPolicy::new(),
473        )
474        .unwrap();
475        assert_eq!(
476            result,
477            vec![ProxyStep::Direct],
478            "the endpoint names what the script was still able to read"
479        );
480    }
481
482    #[test]
483    fn the_plain_entry_point_wins_when_both_exist() {
484        let source = "function FindProxyForURL(url, host) { return 'PROXY plain:1'; }
485                      function FindProxyForURLEx(url, host) { return 'PROXY ex:1'; }";
486        let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
487        assert_eq!(result[0].endpoint().unwrap().authority(), "plain:1");
488    }
489
490    #[test]
491    fn the_ex_entry_point_still_runs_when_it_is_the_only_one() {
492        let source = "function FindProxyForURLEx(url, host) { return 'PROXY ex:1'; }";
493        let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
494        assert_eq!(result[0].endpoint().unwrap().authority(), "ex:1");
495    }
496
497    #[test]
498    fn a_realistic_corporate_script() {
499        // Full routing semantics live in tests/pac.rs; here we only need one chain parse.
500        let source = "function FindProxyForURL(url, host) {
501                 return 'PROXY edge:8080; PROXY backup:8080; DIRECT';
502             }";
503        let chain = steps(source, "https://example.net/", PacPolicy::new()).unwrap();
504        assert_eq!(chain.len(), 3);
505        assert_eq!(chain[0].endpoint().unwrap().authority(), "edge:8080");
506        assert_eq!(chain[1].endpoint().unwrap().authority(), "backup:8080");
507        assert!(chain[2].is_direct());
508    }
509
510    #[test]
511    fn every_host_function_is_bound() {
512        // Calling every one of them in one script: a missing binding is a ReferenceError.
513        let source = "function FindProxyForURL(url, host) {
514                 alert('hello');
515                 var used = [
516                     isPlainHostName(host),
517                     dnsDomainIs(host, '.example'),
518                     localHostOrDomainIs(host, 'www.example'),
519                     isResolvable(host),
520                     isInNet(host, '10.0.0.0', '255.0.0.0'),
521                     dnsResolve(host),
522                     myIpAddress(),
523                     dnsDomainLevels(host),
524                     shExpMatch(url, 'http:*'),
525                     weekdayRange('MON', 'FRI'),
526                     dateRange('JAN', 'DEC'),
527                     timeRange(0, 23),
528                     convert_addr('127.0.0.1')
529                 ];
530                 return 'PROXY ok:' + used.length;
531             }";
532        let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
533        assert_eq!(result[0].endpoint().unwrap().port, 13);
534    }
535
536    // What a call that leaves an argument out comes back with. `arg_string` reads the gap as
537    // `""`, and this test is the only thing holding that against `"undefined"` — the value
538    // JS actually puts there.
539    //
540    // The substitution is not neutral. `isPlainHostName` is handed a name carrying no dot
541    // and `dnsDomainIs` a suffix every host ends with, so each answers `true`, and `true`
542    // in the usual `if (…) return "PROXY internal:8080"` shape is a rule that matches every
543    // destination. `hostfn`'s known-differences list now carries the divergence; this is
544    // the reading it was written from, and what keeps the answers from moving again unseen.
545    //
546    // Rendered together and compared once, so a failure names every answer that moved. The
547    // `map(String)` is load-bearing: `join` renders `dnsResolve`'s `null` as nothing, which
548    // would leave that row asserting an empty gap and unable to tell `null` from `""`.
549    #[test]
550    fn an_argument_the_script_leaves_out_is_read_as_the_empty_string() {
551        let source = "function FindProxyForURL(url, host) {
552                 return 'PROXY ' + [
553                     isPlainHostName(),
554                     dnsDomainIs(host),
555                     localHostOrDomainIs(host),
556                     shExpMatch(url),
557                     isInNet(host, '10.0.0.0'),
558                     dnsResolve(),
559                     dnsDomainLevels(),
560                     weekdayRange(),
561                     dateRange(),
562                     timeRange()
563                 ].map(String).join('-') + ':1';
564             }";
565        let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
566        assert_eq!(
567            result[0].endpoint().unwrap().authority(),
568            "true-true-false-false-false-null-0-false-false-false:1"
569        );
570    }
571
572    // Which argument is which, for the four bindings whose two arguments are not
573    // interchangeable.
574    //
575    // [`hostfn`] is tested exhaustively, but it is tested through Rust calls. The wiring in
576    // [`register_host_functions`] — which `arg_string` index reaches which parameter — is
577    // written once per binding, and this test is the only thing holding it. Swap `isInNet`'s
578    // `pattern` and `mask`, or `localHostOrDomainIs`'s `host` and `hostdom`, and nothing else
579    // in the tree objects, `--include-ignored` and every integration suite included. The
580    // other two swaps are caught only in passing, by tests aimed elsewhere — `dnsDomainIs`
581    // by the missing-argument test above (whose `dnsDomainIs(host)` happens to be
582    // asymmetric) and `shExpMatch` by a script inside `tests/pac.rs`. Neither would survive
583    // being rewritten for its own reasons.
584    //
585    // What a swap costs is a script that reads correctly and routes wrongly. `isInNet(host,
586    // "10.0.0.0", "255.0.0.0")` is how a corporate script says "internal traffic goes
587    // direct"; with the last two arguments exchanged it answers about a different network
588    // and the internal/external decision inverts, silently, for every destination.
589    //
590    // Each row is chosen so that exchanging that call's arguments moves the answer.
591    // `isInNet` gets two rows: the `/16` and the `/24` differ only in the mask, so the
592    // `false` is the mask being read as a mask rather than the call being broken — and it
593    // is the row with teeth, because a `pattern`/`mask` exchange turns exactly that one
594    // true. The `host`/`pattern` pair is left out on purpose: `is_in_net` compares
595    // `address & mask == pattern & mask`, which is symmetric in those two, so no input
596    // could tell them apart.
597    #[test]
598    fn each_host_function_receives_its_arguments_in_the_documented_order() {
599        let source = "function FindProxyForURL(url, host) {
600                 return 'PROXY ' + [
601                     dnsDomainIs('www.corp.example', '.corp.example'),
602                     localHostOrDomainIs('www', 'www.corp.example'),
603                     isInNet('10.0.1.5', '10.0.0.0', '255.255.0.0'),
604                     isInNet('10.0.1.5', '10.0.0.0', '255.255.255.0'),
605                     shExpMatch('http://www.corp.example/x', 'http://*.corp.example/*')
606                 ].map(String).join('-') + ':1';
607             }";
608        let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
609        assert_eq!(
610            result[0].endpoint().unwrap().authority(),
611            "true-true-true-false-true:1"
612        );
613    }
614
615    #[test]
616    fn the_default_policy_makes_dns_resolve_null() {
617        let source = "function FindProxyForURL(url, host) {
618                 return dnsResolve(host) == null ? 'DIRECT' : 'PROXY leaked:1';
619             }";
620        assert!(steps(source, "http://example.com/", PacPolicy::new()).unwrap()[0].is_direct());
621    }
622
623    #[test]
624    fn my_ip_address_is_loopback_unless_configured() {
625        let source =
626            "function FindProxyForURL(url, host) { return 'PROXY ' + myIpAddress() + ':1'; }";
627        let result = steps(source, "http://example.com/", PacPolicy::new()).unwrap();
628        assert_eq!(result[0].endpoint().unwrap().authority(), "127.0.0.1:1");
629
630        let policy = PacPolicy::new().with_my_ip_address("192.0.2.7".parse().unwrap());
631        let result = steps(source, "http://example.com/", policy).unwrap();
632        assert_eq!(result[0].endpoint().unwrap().authority(), "192.0.2.7:1");
633    }
634
635    #[test]
636    fn the_time_functions_see_the_pinned_clock() {
637        // 2024-02-29T13:45:07Z, a Thursday.
638        let policy = PacPolicy::new().with_now(UNIX_EPOCH + Duration::from_secs(1_709_214_307));
639        let source = "function FindProxyForURL(url, host) {
640                 if (weekdayRange('MON', 'FRI') && timeRange(9, 17) && dateRange('FEB')) {
641                     return 'PROXY office:8080';
642                 }
643                 return 'DIRECT';
644             }";
645        let result = steps(source, "http://example.com/", policy).unwrap();
646        assert_eq!(result[0].endpoint().unwrap().authority(), "office:8080");
647    }
648
649    #[test]
650    fn a_throwing_script_is_an_error() {
651        let error = steps(
652            "function FindProxyForURL(url, host) { throw new Error('nope'); }",
653            "http://a/",
654            PacPolicy::new(),
655        )
656        .unwrap_err();
657        assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
658    }
659
660    // A hostile script controls what it `throw`s, and `boa_engine` quotes that value
661    // verbatim in the `JsError` it returns — which is exactly what `run()` embeds in
662    // `Error::PacEvaluation.reason` via `Error::pac_evaluation`. This mimics a script
663    // that throws a string shaped like credentials plus a forged log line, and checks
664    // that neither survives `Display`/`Debug` on the resulting error, end to end
665    // through the real evaluator rather than through `Error::pac_evaluation` directly.
666    #[test]
667    fn a_thrown_string_is_masked_and_sanitized_end_to_end() {
668        let error = steps(
669            "function FindProxyForURL(url, host) { \
670                 throw 'leaked http://alice:hunter2@proxy.corp/x.pac\\nWARN forged line'; \
671             }",
672            "http://a/",
673            PacPolicy::new(),
674        )
675        .unwrap_err();
676
677        let display = error.to_string();
678        let debug = format!("{error:?}");
679        assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
680        assert!(!display.contains("hunter2"), "{display}");
681        assert!(!debug.contains("hunter2"), "{debug}");
682        assert!(!display.contains('\n'), "{display}");
683        assert!(!debug.contains('\n'), "{debug}");
684        // Not vacuous: the rest of the thrown text is still there.
685        assert!(display.contains("proxy.corp"), "{display}");
686    }
687
688    #[test]
689    fn a_script_without_the_entry_point_is_an_error() {
690        let error = steps("var x = 1;", "http://a/", PacPolicy::new()).unwrap_err();
691        assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
692    }
693
694    // The entry point exists but is an accessor that throws. Both before and after this
695    // is an `Err`, so only the message distinguishes them: it must not claim the script
696    // defines no entry point, because it does.
697    #[test]
698    fn a_throwing_entry_point_getter_is_not_reported_as_a_missing_entry_point() {
699        let error = steps(
700            "Object.defineProperty(globalThis, 'FindProxyForURL', {
701                 get: function () { throw new Error('tripwire'); }
702             });",
703            "http://a/",
704            PacPolicy::new(),
705        )
706        .unwrap_err();
707        let display = error.to_string();
708        assert!(
709            !display.contains("defines no FindProxyForURL"),
710            "the getter threw, so the name is defined: {display}"
711        );
712        assert!(display.contains("tripwire"), "{display}");
713    }
714
715    #[test]
716    fn a_nonsense_return_value_is_an_error() {
717        let error = steps(
718            "function FindProxyForURL(url, host) { return 'GOPHER g:70'; }",
719            "http://a/",
720            PacPolicy::new(),
721        )
722        .unwrap_err();
723        assert!(matches!(error, Error::PacInvalidResult { .. }), "{error:?}");
724    }
725
726    #[test]
727    fn an_infinite_loop_hits_the_wall_clock_timeout() {
728        // A cap far too high to fire inside the timeout, so it is the wall clock that
729        // stops this.
730        //
731        // "Far too high" is measured rather than assumed. Reaching this cap with the
732        // timeout switched off takes about 93 seconds on an unoptimized build of this
733        // crate, so the 250 ms below buys on the order of 54_000 iterations against a cap
734        // of 20_000_000. For the cap to win instead, an optimized build would have to
735        // outrun the measured rate by a factor in the hundreds.
736        //
737        // The same measurement says what becomes of the thread this abandons. The worker
738        // keeps running, and the cap it is heading for is those same 93 seconds away; this
739        // lib test binary has been measured in the tens of seconds, run to run, and the
740        // argument needs only that it stay under 93 — do not replace this with whatever one
741        // run says. So the worker is reaped by process exit, not by the cap, and the margin
742        // above is paid for with a core spinning for the whole rest of the run.
743        // There is no setting that buys both: [`run_with_timeout`] hands the evaluation to
744        // a thread it can stop waiting for but cannot cancel, so a cap low enough to
745        // unwind promptly is a cap an optimized build might reach first.
746        // The finite cap is still the right thing to have — it is what bounds the same
747        // script in a consumer process, which unlike a test binary does not exit.
748        let policy = PacPolicy::new()
749            .with_max_loop_iterations(20_000_000)
750            .with_timeout(Some(Duration::from_millis(250)));
751        let start = std::time::Instant::now();
752        let error = steps(
753            "function FindProxyForURL(url, host) { while (true) {} }",
754            "http://a/",
755            policy,
756        )
757        .unwrap_err();
758        assert!(matches!(error, Error::PacTimeout { .. }), "{error:?}");
759        assert!(start.elapsed() < Duration::from_secs(5));
760    }
761
762    #[test]
763    fn an_infinite_loop_hits_the_iteration_cap() {
764        // No timeout, so only the engine's loop cap can stop this.
765        let policy = PacPolicy::new()
766            .with_max_loop_iterations(10_000)
767            .with_timeout(None);
768        let error = steps(
769            "function FindProxyForURL(url, host) { while (true) {} }",
770            "http://a/",
771            policy,
772        )
773        .unwrap_err();
774        assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
775    }
776
777    // The other half of that cap's contract, which
778    // [`PacPolicy::with_max_loop_iterations`] states and nothing pinned: the limit is
779    // charged where the engine re-enters a loop body, so iteration a builtin does inside
780    // one call escapes it entirely. Same cap and same absent timeout as the test above —
781    // the only thing that moves is where the iteration happens, and 1 000 000 characters
782    // is a hundred times the cap that stops the `while`.
783    #[test]
784    fn iteration_inside_a_builtin_is_not_charged_to_the_loop_cap() {
785        let policy = PacPolicy::new()
786            .with_max_loop_iterations(10_000)
787            .with_timeout(None);
788        let result = steps(
789            "function FindProxyForURL(url, host) {
790                 var filler = 'a'.repeat(1000000);
791                 return filler.length === 1000000 ? 'DIRECT' : 'PROXY p:1';
792             }",
793            "http://a/",
794            policy,
795        )
796        .unwrap();
797        assert_eq!(result, vec![ProxyStep::Direct]);
798    }
799
800    #[test]
801    fn unbounded_recursion_does_not_blow_the_stack() {
802        let error = steps(
803            "function boom(n) { return boom(n + 1); }
804             function FindProxyForURL(url, host) { return boom(0); }",
805            "http://a/",
806            PacPolicy::new(),
807        )
808        .unwrap_err();
809        assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
810    }
811
812    // A recursion depth tighter than `boa_engine`'s own default trips before the
813    // script's own (finite, otherwise unremarkable) recursion completes.
814    #[test]
815    fn a_tight_recursion_limit_rejects_deep_recursion() {
816        let policy = PacPolicy::new().with_recursion_limit(10);
817        let error = steps(
818            "function depth(n) { return n <= 0 ? 0 : 1 + depth(n - 1); }
819             function FindProxyForURL(url, host) { return 'PROXY d:' + depth(100); }",
820            "http://a/",
821            policy,
822        )
823        .unwrap_err();
824        assert!(matches!(error, Error::PacEvaluation { .. }), "{error:?}");
825    }
826
827    // No-regression check: a recursion depth well inside `boa_engine`'s own default
828    // (512), run under the crate's default [`PacPolicy`], still completes. Guards
829    // against the default value drifting away from `boa_engine`'s own default and
830    // silently breaking a PAC script that happens to recurse.
831    #[test]
832    fn the_default_recursion_limit_still_runs_an_ordinary_recursive_script() {
833        let result = steps(
834            "function depth(n) { return n <= 0 ? 0 : 1 + depth(n - 1); }
835             function FindProxyForURL(url, host) { return 'PROXY d:' + depth(100); }",
836            "http://a/",
837            PacPolicy::new(),
838        )
839        .unwrap();
840        assert_eq!(result[0].endpoint().unwrap().port, 100);
841    }
842
843    // `recursion_limit` and `stack_size_limit` are honoured through `apply_runtime_limits`,
844    // the exact code `run()` calls.
845    #[test]
846    fn recursion_and_stack_size_limits_reach_the_engine() {
847        let policy = PacPolicy::new()
848            .with_recursion_limit(7)
849            .with_stack_size_limit(42);
850        let mut context = Context::default();
851        apply_runtime_limits(&mut context, policy);
852        assert_eq!(context.runtime_limits().recursion_limit(), 7);
853        assert_eq!(context.runtime_limits().stack_size_limit(), 42);
854    }
855
856    // Two of the three limits in [`PacPolicy`]'s default are documented as being boa's own
857    // "kept so stating it is not a behaviour change", and the third is documented as one
858    // boa does not impose. Those are claims about a dependency, not about this crate, and
859    // an upgrade can falsify them without anyone editing this file: `RuntimeLimits`
860    // carries them as plain literals too. Asserting against `Context::default()` instead
861    // of against 512 and 10 240 is the whole point — a literal would only restate the
862    // constant, and 0.21.1 → next is exactly when the answer changes.
863    #[test]
864    fn the_two_limits_that_claim_to_be_boas_still_are() {
865        let boa = Context::default().runtime_limits();
866        assert_eq!(
867            boa.recursion_limit(),
868            crate::pac::DEFAULT_PAC_RECURSION_LIMIT
869        );
870        assert_eq!(
871            boa.stack_size_limit(),
872            crate::pac::DEFAULT_PAC_STACK_SIZE_LIMIT
873        );
874        // The third: boa leaves loops unbounded, which is the reason this crate caps them
875        // at all. If boa ever starts capping them, the doc on `DEFAULT_PAC_LOOP_LIMIT`
876        // stops being true even though the number in it is unchanged.
877        assert_eq!(boa.loop_iteration_limit(), u64::MAX);
878        // And that the default is a cap at all: every `while (true)` test above passes an
879        // explicit one, so `u64::MAX` here — the value
880        // [`PacPolicy::with_max_loop_iterations`] documents as "disables" — would leave an
881        // OS-supplied script's infinite loop with nothing but the timeout, which ends the
882        // wait and not the work. Asserted rather than run: the comment on
883        // [`an_infinite_loop_hits_the_wall_clock_timeout`] explains why nothing here
884        // evaluates a loop against the real cap.
885        assert_ne!(crate::pac::DEFAULT_PAC_LOOP_LIMIT, u64::MAX);
886    }
887
888    #[test]
889    fn the_runtime_offers_no_way_out() {
890        // None of these exist in the PAC runtime; every one must be a ReferenceError
891        // rather than a working escape hatch.
892        for escape in [
893            "fetch('http://evil/')",
894            "require('fs')",
895            "XMLHttpRequest",
896            "process.exit(1)",
897            "globalThis.WebAssembly.compile",
898        ] {
899            let source =
900                format!("function FindProxyForURL(url, host) {{ {escape}; return 'DIRECT'; }}");
901            let error = steps(&source, "http://a/", PacPolicy::new()).unwrap_err();
902            assert!(
903                matches!(error, Error::PacEvaluation { .. }),
904                "{escape} gave {error:?}"
905            );
906        }
907    }
908}