Skip to main content

proxy_watch/pac/
result.rs

1//! Parsing what `FindProxyForURL` returns.
2
3use crate::endpoint::{ProxyEndpoint, ProxyScheme};
4use crate::error::Error;
5use crate::resolve::ProxyStep;
6use std::collections::HashSet;
7
8/// Parse a `FindProxyForURL` return string into an ordered fallback chain.
9///
10/// Tokens are separated by `;` or newlines; newline separation is specific to this crate
11/// and differs from Chromium. Keywords are case-insensitive: `DIRECT`;
12/// `PROXY`/`HTTP`→[`ProxyStep::Http`]; `HTTPS`→HTTPS; `SOCKS`/`SOCKS4`→SOCKS4;
13/// `SOCKS5`→[`ProxyScheme::Socks5h`] (remote DNS). An address without a port uses
14/// [`ProxyScheme::default_port`] for the keyword's scheme. Addresses must be bare
15/// authorities: `/`, `?` or `#`, including in `scheme://`, invalidates the whole candidate.
16/// Bad tokens are skipped, repeats collapse, and embedded credentials are dropped. An empty
17/// usable chain returns [`Error::PacInvalidResult`].
18///
19/// ```
20/// # use proxy_watch::pac::parse_find_proxy_result;
21/// let steps = parse_find_proxy_result("PROXY a:1; SOCKS5 b:2; DIRECT")?;
22/// assert_eq!(steps.len(), 3);
23/// assert_eq!(steps[0].endpoint().unwrap().authority(), "a:1");
24/// assert_eq!(steps[1].scheme(), Some("socks5"));
25/// assert_eq!(steps[1].to_url().unwrap().as_str(), "socks5h://b:2");
26/// assert!(steps[2].is_direct());
27/// # Ok::<(), proxy_watch::Error>(())
28/// ```
29///
30/// # Errors
31///
32/// [`Error::PacInvalidResult`] when no candidate in the string could be understood,
33/// including the empty string.
34pub fn parse_find_proxy_result(result: &str) -> Result<Vec<ProxyStep>, Error> {
35    let mut steps = Vec::new();
36    let mut seen = HashSet::new();
37    // A newline is a separator here, and in Chromium it is not:
38    // `ProxyList::SetFromPacString` builds a `base::StringTokenizer(pac_string, ";")`. A
39    // script that separated its candidates with newlines instead therefore loses all of
40    // them at once: with `;` alone, `PROXY a:1\nDIRECT` is a single candidate of three
41    // words, one more than the grammar allows, so the chain comes back empty. Refusing an
42    // entire chain over punctuation is the same silent-`DIRECT` outcome the block below
43    // declines to inherit, caused here by the separator choice. The divergence runs one way
44    // only: this accepts what the reference rejects, never the other way round.
45    for candidate in result.split([';', '\n', '\r']) {
46        // Chromium's `ProxyList::SetFromPacString` keeps repeats: it `emplace_back`s
47        // every valid element with no membership check. A chain is a list of things to
48        // try in order, and a proxy that just failed is no more alive the second time
49        // the script names it. The test is against the whole chain rather than the
50        // previous element, so `a; b; a` collapses as well as `a; a`: by the time the
51        // second `a` is reached, `a` has already been tried and failed within this same
52        // chain. Dropping them is visible in `len()`, hence the doc line above.
53        //
54        // Chromium keeps repeats and needs no deduplication index. This crate checks every
55        // candidate, so do not use `steps.contains`: scanning a vector that grows with the
56        // remote script's output makes parsing quadratic on a thread the timeout has
57        // stopped waiting for
58        // (`a_long_chain_of_distinct_candidates_does_not_stall_the_parse`).
59        if let Some(step) = parse_candidate(candidate)
60            && seen.insert(step.clone())
61        {
62            steps.push(step);
63        }
64    }
65    if steps.is_empty() {
66        // Chromium's `PacResult::ToProxyList` pushes `DIRECT` here instead, under the
67        // comment "this basically means an error in the PAC script". Same diagnosis,
68        // opposite handling: a script that said something unreadable has not said "go
69        // direct", and silently going direct is how traffic leaves a network the script
70        // was written to keep it inside. The caller gets the error and can choose
71        // `DIRECT` itself.
72        return Err(Error::pac_invalid_result(result));
73    }
74    Ok(steps)
75}
76
77// One `;`-separated candidate, or `None` when it is not usable.
78fn parse_candidate(candidate: &str) -> Option<ProxyStep> {
79    let mut words = candidate.split_whitespace();
80    let keyword = words.next()?;
81    let address = words.next();
82    // `PROXY a:1 b:2` is not a thing; refuse rather than guess.
83    if words.next().is_some() {
84        return None;
85    }
86
87    let scheme = match keyword.to_ascii_uppercase().as_str() {
88        "DIRECT" => return address.is_none().then_some(ProxyStep::Direct),
89        "PROXY" | "HTTP" => ProxyScheme::Http,
90        "HTTPS" => ProxyScheme::Https,
91        "SOCKS" | "SOCKS4" => ProxyScheme::Socks4,
92        // Remote DNS: Chromium's `ProxyServer::SCHEME_SOCKS5` always resolves names on
93        // the proxy side; curl's URI vocabulary uses the `h` suffix for that
94        // (https://curl.se/docs/url-syntax.html).
95        "SOCKS5" => ProxyScheme::Socks5h,
96        _ => return None,
97    };
98
99    let address = address?;
100    // A PAC candidate is a bare authority, so a `/`, `?` or `#` in it (the `/`s of a
101    // `scheme://` included) makes the candidate malformed rather than something to trim.
102    // `ProxyEndpoint::parse` does trim them, and is right to: the values operating systems
103    // *store* are written as URLs, where `http://proxy:8080/` is an ordinary spelling of an
104    // endpoint. A script's return value is not written that way, and letting the same trim
105    // run here would answer with an address the script did not write, and, for a scheme,
106    // silently override the keyword, where reading `https://p:8443` as plain HTTP is
107    // cleartext to a port that expects TLS.
108    //
109    // Chromium refuses the candidate: `ProxySchemeHostAndPortToProxyServer` hands the whole
110    // string to `url::ParseAuthority`, so `p:8080/x` yields a port component of `8080/x`
111    // that `url::ParsePort` rejects, and `p/x` reaches `CanonicalizeHost` carrying a
112    // forbidden host code point ("Paths disallowed.",
113    // `net/base/proxy_string_util_unittest.cc`). libproxy keeps the path whole, building
114    // `http://<server>` and handing `g_uri_to_string` back unshortened
115    // (`px_manager_run_pac`). Neither answers with an address the script did not write, and
116    // trimming is the one reading that would; between the two, Chromium's is what this
117    // module already follows for the rest of the grammar. Firefox is a third reading again:
118    // `ProcessPACString` tries `NS_NewURI` on the address and only prepends `http://`
119    // when that yields no host, which is why a `scheme://` is accepted there and then
120    // ignored, and it is the one measured for the scheme only, not for a path.
121    if address.contains(['/', '?', '#']) {
122        return None;
123    }
124    let mut endpoint = ProxyEndpoint::parse(address, scheme.default_port())
125        .ok()?
126        .with_scheme_hint(scheme);
127    // `p:0` names no port anything listens on. The native engines answer it with the
128    // keyword's default port (`winhttp.rs` and `cfnetwork.rs` say why), and the same script
129    // gets the same chain whichever engine ran it.
130    if endpoint.port == 0 {
131        endpoint.port = scheme.default_port();
132    }
133    // A PAC script is remote code (under WPAD, code from whoever answered the discovery
134    // query), so credentials it hands back are attacker-chosen, and `ProxyStep::to_url`
135    // would write them straight into the URL a caller feeds its HTTP client. Neither
136    // reference propagates them: Chromium rejects the candidate outright (`ParseAuthority`
137    // then `if (username_component.is_valid() || password_component.is_valid()) return
138    // ProxyServer()`), Firefox keeps only `GetAsciiHost` and drops them silently. Take
139    // Firefox's outcome: a proxy the script named is still kept as a candidate to try, so
140    // this does not fail closed on a chain, but say so, because a silent drop looks like a
141    // working authenticated proxy right up until the 407.
142    if endpoint.auth.take().is_some() {
143        crate::trace::warning!(
144            keyword,
145            "dropping the credentials embedded in a PAC result candidate"
146        );
147    }
148    // The hint comes from `scheme`, so use `ProxyStep::from_endpoint`'s exhaustive table.
149    // Do not duplicate it with a SOCKS5 `_` arm for currently unreachable `ProxyScheme`
150    // variants: adding `SOCKS4A` to the keyword list would then produce a SOCKS5 step.
151    Some(ProxyStep::from_endpoint(endpoint))
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn authorities(result: &str) -> Vec<String> {
159        parse_find_proxy_result(result)
160            .unwrap()
161            .iter()
162            .map(|step| match step.endpoint() {
163                Some(endpoint) => format!("{}/{}", step.scheme().unwrap(), endpoint.authority()),
164                None => "direct".to_owned(),
165            })
166            .collect()
167    }
168
169    #[test]
170    fn every_keyword_maps_onto_a_step() {
171        assert_eq!(authorities("DIRECT"), ["direct"]);
172        assert_eq!(authorities("PROXY p:8080"), ["http/p:8080"]);
173        assert_eq!(authorities("HTTP p:8080"), ["http/p:8080"]);
174        assert_eq!(authorities("HTTPS p:8443"), ["https/p:8443"]);
175        assert_eq!(authorities("SOCKS p:1080"), ["socks4/p:1080"]);
176        assert_eq!(authorities("SOCKS4 p:1080"), ["socks4/p:1080"]);
177        assert_eq!(authorities("SOCKS5 p:1080"), ["socks5/p:1080"]);
178    }
179
180    #[test]
181    fn socks5_uses_remote_dns_in_to_url() {
182        let step = &parse_find_proxy_result("SOCKS5 proxy.corp:1080").unwrap()[0];
183        assert_eq!(step.scheme(), Some("socks5"));
184        assert_eq!(
185            step.endpoint().unwrap().scheme_hint,
186            Some(ProxyScheme::Socks5h)
187        );
188        assert_eq!(step.to_url().unwrap().as_str(), "socks5h://proxy.corp:1080");
189    }
190
191    // `scheme()` says `socks4` for SOCKS4a too, so only `to_url` tells the two apart: a plain
192    // SOCKS4 proxy may not speak 4a, and the name is resolved on this side.
193    #[test]
194    fn socks_and_socks4_resolve_locally_in_to_url() {
195        for keyword in ["SOCKS", "SOCKS4"] {
196            let step = &parse_find_proxy_result(&format!("{keyword} proxy.corp")).unwrap()[0];
197            assert_eq!(
198                step.to_url().unwrap().as_str(),
199                "socks4://proxy.corp:1080",
200                "{keyword}"
201            );
202        }
203    }
204
205    #[test]
206    fn keywords_are_case_insensitive_and_ports_default() {
207        assert_eq!(authorities("proxy p"), ["http/p:80"]);
208        assert_eq!(authorities("Https p"), ["https/p:443"]);
209        assert_eq!(authorities("socks5 p"), ["socks5/p:1080"]);
210        assert_eq!(authorities("direct"), ["direct"]);
211        // Port 0 is the keyword's default too, as the WinHTTP and CFNetwork engines read it.
212        assert_eq!(authorities("PROXY p:0"), ["http/p:80"]);
213        assert_eq!(authorities("SOCKS5 p:0"), ["socks5/p:1080"]);
214    }
215
216    #[test]
217    fn a_chain_keeps_its_order() {
218        assert_eq!(
219            authorities("PROXY a:1; SOCKS5 b:2; DIRECT"),
220            ["http/a:1", "socks5/b:2", "direct"]
221        );
222    }
223
224    #[test]
225    fn whitespace_around_a_candidate_is_ignored() {
226        assert_eq!(
227            authorities("  PROXY   a:1  ;\n\tPROXY b:2 ;;  "),
228            ["http/a:1", "http/b:2"]
229        );
230    }
231
232    // A newline is a separator here and not in Chromium, which splits on `;` alone: see the
233    // note on the split.
234    // The row above cannot show it: every `\n` there follows a `;` that has already cut the
235    // string, so `split_whitespace` inside the candidate would absorb it either way. These
236    // rows have no `;` at all, which is the only shape that tells the two apart: without the
237    // newline in the separator set the whole string is one candidate, `PROXY a:1 PROXY b:2`
238    // is three words where two are allowed, and the chain comes back empty.
239    #[test]
240    fn a_newline_separates_candidates_where_no_semicolon_does() {
241        assert_eq!(
242            authorities("PROXY a:1\nPROXY b:2\r\nDIRECT"),
243            ["http/a:1", "http/b:2", "direct"]
244        );
245
246        // The carriage return is needed in that set independently of a newline, and this is
247        // the only shape that shows it: in the row above it sits in front of a `\n` that
248        // has already cut the string, and inside a candidate `split_whitespace` would
249        // absorb it. Alone it is a line ending a script may still be written with, and
250        // without it here the whole return value is again one candidate of too many words.
251        assert_eq!(authorities("PROXY a:1\rDIRECT"), ["http/a:1", "direct"]);
252    }
253
254    #[test]
255    fn ipv6_literals_survive() {
256        assert_eq!(authorities("PROXY [::1]:8080"), ["http/[::1]:8080"]);
257    }
258
259    #[test]
260    fn duplicates_collapse() {
261        assert_eq!(
262            authorities("PROXY a:1; PROXY a:1; DIRECT; DIRECT"),
263            ["http/a:1", "direct"]
264        );
265        // Not just adjacent ones: the check is against the whole chain, and the first
266        // occurrence is the one that keeps its position.
267        assert_eq!(
268            authorities("PROXY a:1; PROXY b:2; PROXY a:1"),
269            ["http/a:1", "http/b:2"]
270        );
271        // A repeat is the same protocol to the same address. One address under another
272        // protocol is another thing to try.
273        assert_eq!(
274            authorities("PROXY a:1; HTTPS a:1; SOCKS5 a:1; HTTP a:1"),
275            ["http/a:1", "https/a:1", "socks5/a:1"]
276        );
277    }
278
279    #[test]
280    fn junk_candidates_are_skipped_but_do_not_poison_the_chain() {
281        assert_eq!(
282            authorities("GOPHER g:70; PROXY a:1; PROXY ; DIRECT x; SOCKS9 b:2"),
283            ["http/a:1"]
284        );
285        assert_eq!(authorities("PROXY a:99999; PROXY a:1"), ["http/a:1"]);
286        assert_eq!(authorities("PROXY http://a:1; PROXY a:1"), ["http/a:1"]);
287        // That row cannot show the scheme is what is being *refused*: the one the address
288        // carries agrees with the keyword, so accepting it would build the step the next
289        // candidate already contributes and the collapse above hides the difference. Where
290        // they disagree the keyword wins: `with_scheme_hint` overwrites what the address
291        // said, and reading `https://` as plain HTTP is cleartext to a port that expects
292        // TLS. So the candidate goes, and the chain moves on to what the script named next.
293        assert_eq!(authorities("PROXY https://p:8443; DIRECT"), ["direct"]);
294        assert_eq!(authorities("PROXY a:1 b:2; DIRECT"), ["direct"]);
295    }
296
297    /// A candidate carrying a path, query or fragment is malformed, not an address with
298    /// something on the end to cut off. `ProxyEndpoint::parse` cuts it off: that is right
299    /// for the values operating systems store, which are written as URLs, and running the
300    /// same cut here answered with `corp:8080` for a string no script wrote, and put it
301    /// ahead of the `DIRECT` the reference falls back to.
302    ///
303    /// Chromium's `ProxySchemeHostAndPortToProxyServer` gives `url::ParseAuthority` the
304    /// whole string, so the port component of `corp:8080/path` is `8080/path` and
305    /// `url::ParsePort` refuses it; `corp/path` has no colon, so the path lands in the host
306    /// and `CanonicalizeHost` refuses that. `InvalidProxyUriToProxyServer` lists the three
307    /// spellings under the comment "Paths disallowed."
308    /// (`net/base/proxy_string_util_unittest.cc`), though the three rows there are missing
309    /// their commas and so concatenate into one literal, which is why they are separate
310    /// rows here.
311    #[test]
312    fn a_candidate_that_is_not_a_bare_authority_is_skipped_whole() {
313        for spec in [
314            "PROXY corp:8080/path",
315            "PROXY corp:8080/",
316            "PROXY corp/path",
317            "PROXY corp:8080?q=1",
318            "PROXY corp:8080#f",
319            "SOCKS5 corp:1080/path",
320        ] {
321            assert_eq!(
322                authorities(&format!("{spec}; DIRECT")),
323                ["direct"],
324                "{spec}"
325            );
326        }
327
328        // The candidate goes, and only it: the chain moves on to what the script named next
329        // rather than reporting the address a cut would have left.
330        assert_eq!(
331            authorities("PROXY corp:8080/path; PROXY other:3128"),
332            ["http/other:3128"]
333        );
334
335        // Nothing else in the chain, and there is no answer to give: the same refusal the
336        // parse makes for any string it cannot read.
337        assert!(matches!(
338            parse_find_proxy_result("PROXY corp:8080/path").unwrap_err(),
339            Error::PacInvalidResult { .. }
340        ));
341    }
342
343    #[test]
344    fn credentials_in_a_candidate_are_dropped_but_the_proxy_survives() {
345        let steps = parse_find_proxy_result("PROXY alice:hunter2@p:8080").unwrap();
346        let endpoint = steps[0].endpoint().unwrap();
347        assert_eq!(endpoint.authority(), "p:8080");
348        assert_eq!(endpoint.auth, None);
349        // Dropped rather than rejected because `to_url` is what a caller hands its HTTP
350        // client, and it must not carry userinfo a remote script chose.
351        assert_eq!(steps[0].to_url().unwrap().as_str(), "http://p:8080/");
352        // A bare user name has no `:` to split on and must go the same way.
353        assert_eq!(authorities("SOCKS5 bob@s:1080"), ["socks5/s:1080"]);
354    }
355
356    #[test]
357    fn a_result_with_nothing_usable_is_an_error() {
358        for junk in [
359            "",
360            "   ",
361            ";;;",
362            "null",
363            "undefined",
364            "GOPHER g:70",
365            "PROXY",
366        ] {
367            let error = parse_find_proxy_result(junk).unwrap_err();
368            assert!(
369                matches!(error, Error::PacInvalidResult { .. }),
370                "{junk:?} gave {error:?}"
371            );
372        }
373    }
374
375    // The string parsed here is whatever a remote script returned, and nothing caps its
376    // length: `quickjs::run` hands `FindProxyForURL`'s string straight over. Worse, this
377    // parse runs *after* `FindProxyForURL` returns, so `run_with_timeout` has already given
378    // its caller `PacTimeout` and walked away: the thread still finishing this loop is one
379    // nobody is waiting for and nothing will stop. Collapsing repeats with `Vec::contains`
380    // makes that quadratic: hence the `HashSet`. The bound sits between the two costs,
381    // near enough to the quadratic one to still fail on it and far enough from the linear
382    // one that a loaded machine does not.
383    #[test]
384    fn a_long_chain_of_distinct_candidates_does_not_stall_the_parse() {
385        let result = (0..16_000)
386            .map(|i| format!("PROXY h{i}.example.com:8080"))
387            .collect::<Vec<_>>()
388            .join(";");
389        let start = std::time::Instant::now();
390        let steps = parse_find_proxy_result(&result).expect("every candidate is usable");
391        let elapsed = start.elapsed();
392        assert_eq!(
393            steps.len(),
394            16_000,
395            "the chain came back a different length"
396        );
397        assert!(
398            elapsed < std::time::Duration::from_secs(20),
399            "parsing {} candidates took {elapsed:?}",
400            steps.len()
401        );
402    }
403}