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