Skip to main content

proxy_watch/pac/
mod.rs

1//! PAC evaluation (`pac` feature, off by default): script → `Vec<ProxyStep>` like
2//! [`resolve`](crate::resolve()).
3//!
4//! No in-crate fetch — pass body to [`evaluate`]. Untrusted code: [`PacPolicy`] defaults
5//! block DNS, fake local IP, 5 s budget. `pac-boa` to run. Bypass lists do not apply.
6
7#[cfg_attr(not(feature = "pac-boa"), allow(dead_code))]
8mod hostfn;
9mod policy;
10mod result;
11#[cfg_attr(not(feature = "pac-boa"), allow(dead_code))]
12mod time;
13
14#[cfg(feature = "pac-boa")]
15mod boa;
16
17// WinHTTP path: target+feature gated so the flag stays additive/portable.
18#[cfg(all(windows, feature = "pac-windows-native"))]
19mod winhttp;
20
21use std::fmt;
22
23use url::Url;
24
25use crate::error::Error;
26use crate::mode::ProxyMode;
27use crate::resolve::ProxyStep;
28
29pub use self::policy::{
30    DEFAULT_PAC_LOOP_LIMIT, DEFAULT_PAC_RECURSION_LIMIT, DEFAULT_PAC_STACK_SIZE_LIMIT,
31    DEFAULT_PAC_TIMEOUT, PacPolicy,
32};
33pub use self::result::parse_find_proxy_result;
34
35#[cfg(feature = "pac-boa")]
36pub use self::boa::BoaEvaluator;
37
38#[cfg(all(windows, feature = "pac-windows-native"))]
39pub use self::winhttp::{DEFAULT_WINHTTP_PAC_TIMEOUT, WinHttpPacResolver, WinHttpPacSource};
40
41/// PAC script body (newtype so "this is JS that will run" stays visible in signatures).
42#[derive(Clone, PartialEq, Eq)]
43pub struct PacScript {
44    source: String,
45}
46
47// Same masking as [`ProxyMode::PacInline`]: length + FNV-1a, never the source.
48impl fmt::Debug for PacScript {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.debug_struct("PacScript")
51            .field("len", &self.source.len())
52            .field(
53                "fnv1a",
54                &format_args!("{:016x}", crate::util::fnv1a(self.source.as_bytes())),
55            )
56            .finish()
57    }
58}
59
60impl PacScript {
61    /// Wrap a script body.
62    #[must_use]
63    pub fn new(source: impl Into<String>) -> Self {
64        Self {
65            source: source.into(),
66        }
67    }
68
69    /// The JavaScript source.
70    #[must_use]
71    pub fn source(&self) -> &str {
72        &self.source
73    }
74
75    /// [`ProxyMode::PacInline`]'s body, or `None` (including for [`ProxyMode::Pac`]).
76    #[must_use]
77    pub fn from_mode(mode: &ProxyMode) -> Option<Self> {
78        match mode {
79            ProxyMode::PacInline { script, .. } => Some(Self::new(script.clone())),
80            _ => None,
81        }
82    }
83}
84
85impl From<String> for PacScript {
86    fn from(source: String) -> Self {
87        Self::new(source)
88    }
89}
90
91impl From<&str> for PacScript {
92    fn from(source: &str) -> Self {
93        Self::new(source)
94    }
95}
96
97/// What [`requirement`] says a [`ProxyMode`] needs before routing.
98#[derive(Clone, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum PacRequirement<'a> {
101    /// No auto-config; [`resolve`](crate::resolve()) already answers.
102    NotNeeded,
103    /// Body already in the snapshot ([`ProxyMode::PacInline`]).
104    Inline(&'a str),
105    /// Fetch this URL and pass the body ([`ProxyMode::Pac`]).
106    Fetch(&'a Url),
107    /// WPAD on; no DHCP 252 / DNS `wpad.` discovery (collision risk). Windows:
108    /// `pac-windows-native`. Else: inline or fetched script.
109    Discover,
110}
111
112// Mirror [`ProxyMode`]'s Debug: `Inline` → len+digest, `Fetch` → redacted URL.
113impl fmt::Debug for PacRequirement<'_> {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::NotNeeded => f.write_str("NotNeeded"),
117            Self::Discover => f.write_str("Discover"),
118            Self::Inline(script) => f
119                .debug_struct("Inline")
120                .field("len", &script.len())
121                .field(
122                    "fnv1a",
123                    &format_args!("{:016x}", crate::util::fnv1a(script.as_bytes())),
124                )
125                .finish(),
126            Self::Fetch(url) => f
127                .debug_tuple("Fetch")
128                .field(&format_args!(
129                    "{}",
130                    crate::util::redact_userinfo(url.as_str())
131                ))
132                .finish(),
133        }
134    }
135}
136
137/// What `mode` needs before [`evaluate`] can be called for it.
138///
139/// ```
140/// # use proxy_watch::pac::{requirement, PacRequirement};
141/// # use proxy_watch::ProxyMode;
142/// assert_eq!(requirement(&ProxyMode::Direct), PacRequirement::NotNeeded);
143/// let inline = ProxyMode::pac_inline("…".to_owned());
144/// assert_eq!(requirement(&inline), PacRequirement::Inline("…"));
145/// ```
146#[must_use]
147pub fn requirement(mode: &ProxyMode) -> PacRequirement<'_> {
148    match mode {
149        ProxyMode::Pac { url, .. } => PacRequirement::Fetch(url),
150        ProxyMode::PacInline { script, .. } => PacRequirement::Inline(script),
151        ProxyMode::WpadAutoDetect => PacRequirement::Discover,
152        _ => PacRequirement::NotNeeded,
153    }
154}
155
156// Replaceable JS engine: `pac-boa` ships [`BoaEvaluator`]; WinHTTP uses `WinHttpPacResolver`.
157/// macOS may wrap `CFNetworkCopyProxiesForAutoConfigurationScript`. [`PacPolicy`] on the impl.
158pub trait PacEvaluator {
159    /// Run `FindProxyForURL(url, host)` and parse the result.
160    ///
161    /// Call [`sanitize_url`] if you bypass [`evaluate_with_host`].
162    ///
163    /// # Errors
164    ///
165    /// [`Error::PacEvaluation`], [`Error::PacTimeout`], [`Error::PacInvalidResult`], and
166    /// [`Error::Io`] for an OS resource the evaluator needs but cannot get — with a
167    /// [`PacPolicy::timeout`] set, `BoaEvaluator` spawns the thread it enforces the
168    /// budget on, and that spawn can fail.
169    fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error>;
170}
171
172/// Chromium `SanitizeUrl`: strip userinfo+fragment; path+query only for `https`/`wss`.
173/// Not a [`PacPolicy`] knob. Custom [`PacEvaluator`] impls should still sanitize.
174///
175/// ```
176/// use proxy_watch::Url;
177/// use proxy_watch::pac::sanitize_url;
178///
179/// let url = Url::parse("http://user:secret@example.net/a/b?q=1#frag").unwrap();
180/// assert_eq!(sanitize_url(&url).as_str(), "http://example.net/a/b?q=1");
181///
182/// let url = Url::parse("https://user:secret@example.net/a/b?q=1#frag").unwrap();
183/// assert_eq!(sanitize_url(&url).as_str(), "https://example.net/");
184/// ```
185#[must_use]
186pub fn sanitize_url(url: &Url) -> Url {
187    let mut sanitized = url.clone();
188    // Both setters refuse a URL `has_host()` calls hostless. A cannot-be-a-base URL reaches
189    // that refusal with no userinfo to lose — but a non-special scheme whose host was emptied
190    // is hostless *and* still holds `user:pass@`, because `set_host(Some(""))` accepts on
191    // `socks5:` what it rejects on `http:` with `EmptyHost`. Lend such a URL a host so the
192    // setters engage, then put the empty host back. All or nothing: a half-applied round trip
193    // would move the host instead of the credentials. The authority is what separates the two
194    // refusals — `Url::password` is not, because it panics on exactly these URLs
195    // (`trace::render::MaskedUrl` has the same note).
196    let refused = sanitized.set_username("").is_err();
197    let _ = sanitized.set_password(None);
198    if refused && sanitized.has_authority() {
199        let mut lent = sanitized.clone();
200        if lent.set_host(Some("x")).is_ok()
201            && lent.set_username("").is_ok()
202            && lent.set_password(None).is_ok()
203            && lent.set_host(Some("")).is_ok()
204        {
205            sanitized = lent;
206        }
207    }
208    sanitized.set_fragment(None);
209    if matches!(sanitized.scheme(), "https" | "wss") {
210        sanitized.set_path("/");
211        sanitized.set_query(None);
212    }
213    sanitized
214}
215
216/// Evaluate `script` for `url` under `policy` (`sanitize_url` first).
217///
218/// `pac-boa` is the only engine this reaches, whatever else is enabled — turning on
219/// `pac-windows-native` as well adds `WinHttpPacResolver` for the caller to drive, not a
220/// second engine for this function to choose between.
221///
222/// A URL with no host (`data:`, `mailto:`) is still evaluated, with `host` empty —
223/// `resolve_with_pac` and `WinHttpPacResolver::resolve_config` answer Direct for those
224/// without running anything. This is the engine door, not a routing entry point.
225///
226/// # Errors
227///
228/// [`Error::PacEngineUnavailable`] with no engine, else [`PacEvaluator::evaluate`].
229pub fn evaluate(
230    script: &PacScript,
231    url: &Url,
232    policy: &PacPolicy,
233) -> Result<Vec<ProxyStep>, Error> {
234    // `host_str` keeps the brackets on an IPv6 literal, so the script sees `[::1]`. The two
235    // references disagree here — Gecko passes `nsIURI::GetAsciiHost`, whose IPv6 segment is
236    // bracketed, while Chromium passes `GURL::HostNoBrackets()` — so neither spelling can be
237    // called the right one. Following Gecko keeps the string the URL itself carries; the host
238    // functions accept both spellings (`unbracket_ipv6` in `dns_resolve` / `is_resolvable`,
239    // the colon test in `is_plain_host_name`), and `evaluate_with_host` is the way out for a
240    // caller who wants Chromium's.
241    evaluate_with_host(script, url, url.host_str().unwrap_or_default(), policy)
242}
243
244/// Like [`evaluate`], but the caller chooses `host` (`url` is still sanitized).
245///
246/// # Errors
247///
248/// Same as [`evaluate`].
249pub fn evaluate_with_host(
250    script: &PacScript,
251    url: &Url,
252    host: &str,
253    policy: &PacPolicy,
254) -> Result<Vec<ProxyStep>, Error> {
255    let url = &sanitize_url(url);
256    // `pac-boa` is the only engine this function can reach, whatever else is enabled:
257    // `pac-windows-native` exports [`WinHttpPacResolver`] for the caller to drive itself
258    // rather than entering a selection here. There is no engine ordering to describe.
259    #[cfg(feature = "pac-boa")]
260    {
261        BoaEvaluator::new(*policy).evaluate(script, url, host)
262    }
263    #[cfg(not(feature = "pac-boa"))]
264    {
265        let _ = (script, url, host, policy);
266        Err(Error::PacEngineUnavailable)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use std::collections::HashMap;
273
274    use super::*;
275    use crate::BypassRules;
276
277    // The answer this type carries is the whole point of asking for it, and the arms with no
278    // field print as a bare word from a hand-written impl, so this test is the only thing
279    // comparing the word to the variant. `Discover` rendered as `NotNeeded` reads as its own
280    // opposite — "WPAD is on and the discovery cannot be run here" against
281    // "routing is already answered". Deciding whether a machine needs a PAC engine is read
282    // off exactly this line.
283    //
284    // `PacScript` shares the digest rendering with `Inline`, so it is held here too. Its
285    // `len` label is in `debug_masking`'s registry, which asks the rendering to *contain*
286    // the word; the exact string is what also refuses `length`.
287    #[test]
288    fn every_requirement_debug_names_itself_and_keeps_its_fields() {
289        const SCRIPT: &str = "function FindProxyForURL(){}";
290        let url = Url::parse("https://wpad.corp/proxy.pac").unwrap();
291        let digest = format!(
292            "len: {}, fnv1a: {:016x}",
293            SCRIPT.len(),
294            crate::util::fnv1a(SCRIPT.as_bytes())
295        );
296        for (rendered, expected) in [
297            (
298                format!("{:?}", PacRequirement::NotNeeded),
299                "NotNeeded".to_owned(),
300            ),
301            (
302                format!("{:?}", PacRequirement::Discover),
303                "Discover".to_owned(),
304            ),
305            (
306                format!("{:?}", PacRequirement::Inline(SCRIPT)),
307                format!("Inline {{ {digest} }}"),
308            ),
309            (
310                // No credentials, so the row pins the framing and not the masking, which
311                // `debug_masking`'s registry owns for this arm.
312                format!("{:?}", PacRequirement::Fetch(&url)),
313                "Fetch(https://wpad.corp/proxy.pac)".to_owned(),
314            ),
315            (
316                format!("{:?}", PacScript::new(SCRIPT)),
317                format!("PacScript {{ {digest} }}"),
318            ),
319        ] {
320            assert_eq!(rendered, expected);
321        }
322    }
323
324    // Every example `net/docs/proxy.md` gives for `FindProxyForURL`'s first argument.
325    #[test]
326    fn the_documented_chromium_examples_come_out_the_same_way() {
327        for (actual, expected) in [
328            ("https://www.google.com/Foo", "https://www.google.com/"),
329            ("https://[dead::beef]/foo?bar", "https://[dead::beef]/"),
330            (
331                "https://www.example.com:8080#search",
332                "https://www.example.com:8080/",
333            ),
334            (
335                "https://username:password@www.example.com",
336                "https://www.example.com/",
337            ),
338        ] {
339            let url = Url::parse(actual).expect("valid url");
340            assert_eq!(sanitize_url(&url).as_str(), expected, "for {actual}");
341        }
342    }
343
344    // The asymmetry is the part most likely to be "tidied up" by a later reader, so it
345    // is pinned: plain HTTP keeps its path and query on purpose.
346    #[test]
347    fn only_cryptographic_schemes_lose_their_path_and_query() {
348        let http = Url::parse("http://user:pw@example.net/deep/path?q=1#frag").unwrap();
349        assert_eq!(
350            sanitize_url(&http).as_str(),
351            "http://example.net/deep/path?q=1"
352        );
353
354        let wss = Url::parse("wss://user:pw@example.net/socket?q=1#frag").unwrap();
355        assert_eq!(sanitize_url(&wss).as_str(), "wss://example.net/");
356
357        let ws = Url::parse("ws://user:pw@example.net/socket?q=1#frag").unwrap();
358        assert_eq!(sanitize_url(&ws).as_str(), "ws://example.net/socket?q=1");
359    }
360
361    #[test]
362    fn sanitizing_is_idempotent_and_leaves_the_host_alone() {
363        for raw in [
364            "https://user:pw@example.net:8443/a?b#c",
365            "http://example.net/",
366            "ftp://user@files.corp/pub",
367        ] {
368            let url = Url::parse(raw).unwrap();
369            let once = sanitize_url(&url);
370            assert_eq!(sanitize_url(&once), once, "not idempotent for {raw}");
371            assert_eq!(once.host_str(), url.host_str(), "host changed for {raw}");
372            assert_eq!(once.port(), url.port(), "port changed for {raw}");
373        }
374    }
375
376    // A URL with no authority has no userinfo to strip; the setters refuse, and that
377    // must not be mistaken for a failure to sanitize.
378    #[test]
379    fn cannot_be_a_base_urls_survive_untouched_except_for_the_fragment() {
380        let url = Url::parse("mailto:someone@example.net?subject=hi#frag").unwrap();
381        assert_eq!(
382            sanitize_url(&url).as_str(),
383            "mailto:someone@example.net?subject=hi"
384        );
385    }
386
387    // ...and the refusal above is not the only one. `set_host(Some(""))` is accepted on a
388    // non-special scheme and rejected on a special one, so this is the one shape that reaches
389    // the same refusal with credentials still attached. `Url::parse` cannot build it; a caller
390    // holding a `Url` can, and both engines hand `as_str()` straight to the script.
391    #[test]
392    fn an_emptied_host_does_not_carry_the_credentials_through() {
393        for (input, expected) in [
394            (
395                "socks5://user:secret@example.net/p?q=1#frag",
396                "socks5:///p?q=1",
397            ),
398            (
399                "socks5://user:secret@example.net:8080/p",
400                "socks5://:8080/p",
401            ),
402            ("socks5://:secret@example.net/p", "socks5:///p"),
403            // Nothing to strip, and the shape `Url::password` cannot be asked about.
404            ("socks5://example.net:8080/p", "socks5://:8080/p"),
405        ] {
406            let mut url = Url::parse(input).unwrap();
407            url.set_host(Some(""))
408                .expect("a non-special scheme accepts an empty host");
409            let sanitized = sanitize_url(&url);
410            assert_eq!(sanitized.as_str(), expected, "for {input}");
411            assert_eq!(
412                sanitize_url(&sanitized),
413                sanitized,
414                "not idempotent for {input}"
415            );
416        }
417    }
418
419    // The other half of the guard on the lend-a-host branch, and the half `mailto:` above
420    // cannot show. That one is refused twice over — no authority *and* cannot-be-a-base, so
421    // `set_host` refuses too and the round trip collapses on its own. A non-special scheme
422    // with a rootless path is refused only once: `unix:/run/foo.socket` has no authority to
423    // hold userinfo, but it *can* be a base, so every setter in the chain succeeds and the
424    // URL comes back with an empty authority it never had.
425    //
426    // This test is the only thing holding `has_authority()` in the guard. Without it,
427    // `unix:/run/foo.socket` comes back as `unix:///run/foo.socket`, and nothing else in the
428    // tree objects. Both engines hand `as_str()` to the script, so that is a
429    // different string in `FindProxyForURL`'s first argument.
430    #[test]
431    fn a_path_only_url_is_not_lent_an_authority_it_never_had() {
432        for (input, expected) in [
433            ("unix:/run/foo.socket", "unix:/run/foo.socket"),
434            // The fragment still goes, so this is not "the function declined to run".
435            ("git:/a/b?q=1#frag", "git:/a/b?q=1"),
436        ] {
437            let url = Url::parse(input).unwrap();
438            assert!(!url.has_authority(), "premise for {input}: {url:?}");
439            assert!(!url.cannot_be_a_base(), "premise for {input}: {url:?}");
440            assert_eq!(sanitize_url(&url).as_str(), expected, "for {input}");
441        }
442    }
443
444    #[cfg(feature = "pac-boa")]
445    #[test]
446    fn the_script_is_handed_the_sanitized_url_end_to_end() {
447        let script = PacScript::new(
448            "function FindProxyForURL(url, host) { return 'PROXY ' + url.replace(/[^a-zA-Z0-9.]/g, '-') + ':8080'; }",
449        );
450        let url = Url::parse("https://user:secret@example.net/private/doc?token=abc#x").unwrap();
451        let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
452        let seen = steps[0].endpoint().expect("a proxy step").authority();
453        assert_eq!(
454            seen, "https---example.net-:8080",
455            "the script must not see the credentials, path, query or fragment"
456        );
457    }
458
459    // `evaluate` is the engine door, not a routing entry point: unlike `resolve_with_pac`
460    // and `WinHttpPacResolver::resolve_config` it does not answer Direct for a hostless
461    // URL, it runs the script with the host empty. Adopting the routing convention here
462    // would change what a caller who reached the engine directly gets back, and no other
463    // test looks at a URL without a host.
464    #[cfg(feature = "pac-boa")]
465    #[test]
466    fn a_hostless_url_reaches_the_script_with_an_empty_host() {
467        let script = PacScript::new(
468            "function FindProxyForURL(url, host) {
469                 return host === '' ? 'PROXY empty.example:1' : 'PROXY host.example:2';
470             }",
471        );
472        let url = Url::parse("mailto:someone@example.net").unwrap();
473
474        let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
475        assert_eq!(
476            steps[0].endpoint().expect("a proxy step").authority(),
477            "empty.example:1",
478            "a hostless URL must reach the engine, with the host empty"
479        );
480    }
481
482    // The escape hatch `evaluate`'s comment points at. `evaluate` passes `host_str`, so a
483    // script sees Gecko's bracketed IPv6 spelling; `evaluate_with_host` exists so a caller
484    // who wants Chromium's `HostNoBrackets()` can have it. Nothing inside this crate ever
485    // passes a host other than the URL's own, so without this the override could stop
486    // reaching the engine and no other test would notice.
487    #[cfg(feature = "pac-boa")]
488    #[test]
489    fn the_host_override_is_what_the_script_sees() {
490        let script = PacScript::new(
491            "function FindProxyForURL(url, host) {
492                 if (host === '[dead::beef]') { return 'PROXY bracketed.example:1'; }
493                 if (host === 'dead::beef') { return 'PROXY unbracketed.example:2'; }
494                 return 'PROXY neither.example:3';
495             }",
496        );
497        let url = Url::parse("https://[dead::beef]/foo").unwrap();
498
499        let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
500        assert_eq!(
501            steps[0].endpoint().expect("a proxy step").authority(),
502            "bracketed.example:1",
503            "`evaluate` must pass the spelling the URL itself carries"
504        );
505
506        let steps =
507            evaluate_with_host(&script, &url, "dead::beef", &PacPolicy::new()).expect("evaluated");
508        assert_eq!(
509            steps[0].endpoint().expect("a proxy step").authority(),
510            "unbracketed.example:2",
511            "the chosen host must reach the script instead of the URL's own"
512        );
513    }
514
515    #[test]
516    fn scripts_come_from_inline_modes_only() {
517        let inline = ProxyMode::pac_inline("body".to_owned());
518        assert_eq!(PacScript::from_mode(&inline), Some(PacScript::new("body")));
519        assert_eq!(PacScript::from_mode(&ProxyMode::Direct), None);
520        // `Manual` for the same reason it appears in `requirements_cover_every_mode`: it is
521        // the fifth variant, and `Direct` alone leaves the `_` arm free to grow a case for it.
522        assert_eq!(
523            PacScript::from_mode(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
524            None
525        );
526
527        let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
528        assert_eq!(PacScript::from_mode(&ProxyMode::pac(url)), None);
529    }
530
531    #[test]
532    fn requirements_cover_every_mode() {
533        let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
534        assert_eq!(
535            requirement(&ProxyMode::pac(url.clone())),
536            PacRequirement::Fetch(&url)
537        );
538        assert_eq!(
539            requirement(&ProxyMode::pac_inline("b".to_owned())),
540            PacRequirement::Inline("b")
541        );
542        assert_eq!(
543            requirement(&ProxyMode::WpadAutoDetect),
544            PacRequirement::Discover
545        );
546        assert_eq!(requirement(&ProxyMode::Direct), PacRequirement::NotNeeded);
547        // The name says *every* mode, and `Direct` alone does not make that true: `Manual`
548        // is the other variant the `_` arm answers for, and it is the one with something to
549        // lose. A case growing in front of that arm sends a machine with static proxies off
550        // to do WPAD discovery, and with only `Direct` here nothing goes red.
551        assert_eq!(
552            requirement(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
553            PacRequirement::NotNeeded
554        );
555    }
556
557    #[test]
558    #[cfg(not(feature = "pac-boa"))]
559    fn without_an_engine_evaluation_reports_why() {
560        let script = PacScript::new("function FindProxyForURL(u, h) { return 'DIRECT'; }");
561        let url = Url::parse("http://example.com/").unwrap();
562        let error = evaluate(&script, &url, &PacPolicy::new()).unwrap_err();
563        assert!(matches!(error, Error::PacEngineUnavailable), "{error:?}");
564    }
565}