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// The most steps a PAC result keeps. The script's heap limit bounds only the string the
9// chain is read from, not the chain.
10pub(crate) const MAX_CHAIN: usize = 64;
11
12// The chain every PAC engine's answer goes into: this parse, `winhttp.rs` and
13// `sys/proxy_info.rs`. Repeats collapse against the whole chain, since a proxy that failed
14// is no better the second time; Chromium keeps them. WinHTTP drops everything past the
15// first `DIRECT` before its answer gets here.
16pub(crate) struct Chain {
17    steps: Vec<ProxyStep>,
18    seen: HashSet<ProxyStep>,
19}
20
21impl Chain {
22    pub(crate) fn new() -> Self {
23        Self {
24            steps: Vec::new(),
25            seen: HashSet::new(),
26        }
27    }
28
29    pub(crate) fn push(&mut self, step: ProxyStep) {
30        if !self.is_full() && self.seen.insert(step.clone()) {
31            self.steps.push(step);
32        }
33    }
34
35    pub(crate) fn is_full(&self) -> bool {
36        self.steps.len() == MAX_CHAIN
37    }
38
39    // `None` when nothing usable was pushed; each caller builds its own error, and an empty
40    // chain never becomes `DIRECT`.
41    pub(crate) fn finish(self) -> Option<Vec<ProxyStep>> {
42        (!self.steps.is_empty()).then_some(self.steps)
43    }
44}
45
46/// Parse a `FindProxyForURL` return string into an ordered fallback chain.
47///
48/// Tokens are separated by `;` or newlines; newline separation is specific to this crate
49/// and differs from Chromium. Keywords are case-insensitive: `DIRECT`;
50/// `PROXY`/`HTTP`→[`ProxyStep::Http`]; `HTTPS`→HTTPS; `SOCKS`/`SOCKS4`→SOCKS4;
51/// `SOCKS5`→[`ProxyScheme::Socks5h`] (remote DNS). An address without a port uses
52/// [`ProxyScheme::default_port`] for the keyword's scheme. Addresses must be bare
53/// authorities: `/`, `?` or `#`, including in `scheme://`, invalidates the whole candidate.
54/// Bad tokens are skipped, repeats collapse, and embedded credentials are dropped. An empty
55/// usable chain returns [`Error::PacInvalidResult`]. Words are split at ASCII whitespace
56/// only, so a no-break space or a vertical tab stays inside a word and spoils it. An IPv6
57/// address without a port can go without brackets (`PROXY ::1`). The chain keeps its first
58/// 64 steps and drops the rest, an explicit `DIRECT` among them.
59///
60/// ```
61/// # use proxy_watch::pac::parse_find_proxy_result;
62/// let steps = parse_find_proxy_result("PROXY a:1; SOCKS5 b:2; DIRECT")?;
63/// assert_eq!(steps.len(), 3);
64/// assert_eq!(steps[0].endpoint().unwrap().authority(), "a:1");
65/// assert_eq!(steps[1].scheme(), Some("socks5"));
66/// assert_eq!(steps[1].to_url().unwrap().as_str(), "socks5h://b:2");
67/// assert!(steps[2].is_direct());
68/// # Ok::<(), proxy_watch::Error>(())
69/// ```
70///
71/// # Errors
72///
73/// [`Error::PacInvalidResult`] when no candidate in the string could be understood,
74/// including the empty string.
75pub fn parse_find_proxy_result(result: &str) -> Result<Vec<ProxyStep>, Error> {
76    let mut chain = Chain::new();
77    // A newline separates candidates here and not in Chromium, whose
78    // `ProxyList::SetFromPacString` splits on `;` alone.
79    // Without it a script that separates its candidates by line loses the whole chain:
80    // `PROXY a:1\nDIRECT` would be one candidate of three words. This accepts what Chromium
81    // rejects, never the reverse.
82    for candidate in result.split([';', '\n', '\r']) {
83        if let Some(step) = parse_candidate(candidate) {
84            chain.push(step);
85            if chain.is_full() {
86                break;
87            }
88        }
89    }
90    // An error, not `DIRECT` as in Chromium's `PacResult::ToProxyList`: a script that said
91    // something unreadable has not said "go direct". The caller can still choose `DIRECT`.
92    chain
93        .finish()
94        .ok_or_else(|| Error::pac_invalid_result(result))
95}
96
97// One `;`-separated candidate, or `None` when it is not usable.
98fn parse_candidate(candidate: &str) -> Option<ProxyStep> {
99    // ASCII whitespace only: a Unicode space is a character of the word it sits in.
100    let mut words = candidate.split_ascii_whitespace();
101    let keyword = words.next()?;
102    let address = words.next();
103    // `PROXY a:1 b:2` is not a thing; refuse rather than guess.
104    if words.next().is_some() {
105        return None;
106    }
107
108    let scheme = match keyword.to_ascii_uppercase().as_str() {
109        "DIRECT" => return address.is_none().then_some(ProxyStep::Direct),
110        "PROXY" | "HTTP" => ProxyScheme::Http,
111        "HTTPS" => ProxyScheme::Https,
112        "SOCKS" | "SOCKS4" => ProxyScheme::Socks4,
113        // Chromium's `ProxyServer::SCHEME_SOCKS5` resolves names on the proxy side, which
114        // curl spells with the `h` suffix (https://curl.se/docs/url-syntax.html).
115        "SOCKS5" => ProxyScheme::Socks5h,
116        _ => return None,
117    };
118
119    let address = address?;
120    // A PAC candidate is a bare authority, so a `/`, `?` or `#` in it (the `/`s of a
121    // `scheme://` included) makes the candidate malformed rather than something to trim, as
122    // Chromium reads it. `ProxyEndpoint::parse` does trim them, and is right to: the values
123    // operating systems *store* are written as URLs. A script's return value is not, and the
124    // same trim here would answer with an address the script did not write. For a scheme it
125    // would also override the keyword: `PROXY https://p:8443` read as plain HTTP is
126    // cleartext to a port that expects TLS. The tests that hold this are
127    // `junk_candidates_are_skipped_but_do_not_poison_the_chain` and
128    // `a_candidate_that_is_not_a_bare_authority_is_skipped_whole`.
129    if address.contains(['/', '?', '#']) {
130        return None;
131    }
132    // A Unicode space left at either end of the address is part of the word, and
133    // `ProxyEndpoint::parse` would trim it off: refuse it here, as one inside the word is.
134    if address.contains(char::is_whitespace) {
135        return None;
136    }
137    let mut endpoint = ProxyEndpoint::parse(address, scheme.default_port())
138        .ok()?
139        .with_scheme_hint(scheme);
140    // `p:0` names no port anything listens on. The native engines answer it with the
141    // keyword's default port (`winhttp.rs` and `cfnetwork.rs` say why), and the same script
142    // gets the same port whichever engine ran it.
143    if endpoint.port == 0 {
144        endpoint.port = scheme.default_port();
145    }
146    // A PAC script is remote code (under WPAD, from whoever answered discovery), so
147    // credentials in its result are attacker-chosen, and `ProxyStep::to_url` would write
148    // them into the URL a caller feeds its HTTP client. Drop them but keep the candidate, as
149    // Firefox does (Chromium drops the candidate), and warn: a silent drop looks like a
150    // working authenticated proxy until the 407.
151    if endpoint.auth.take().is_some() {
152        crate::trace::warning!(
153            keyword,
154            "dropping the credentials embedded in a PAC result candidate"
155        );
156    }
157    // The hint comes from `scheme`, so use `ProxyStep::from_endpoint`'s exhaustive table.
158    // Do not duplicate it with a SOCKS5 `_` arm for currently unreachable `ProxyScheme`
159    // variants: adding `SOCKS4A` to the keyword list would then produce a SOCKS5 step.
160    Some(ProxyStep::from_endpoint(endpoint))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn authorities(result: &str) -> Vec<String> {
168        parse_find_proxy_result(result)
169            .unwrap()
170            .iter()
171            .map(|step| match step.endpoint() {
172                Some(endpoint) => format!("{}/{}", step.scheme().unwrap(), endpoint.authority()),
173                None => "direct".to_owned(),
174            })
175            .collect()
176    }
177
178    #[test]
179    fn every_keyword_maps_onto_a_step() {
180        assert_eq!(authorities("DIRECT"), ["direct"]);
181        assert_eq!(authorities("PROXY p:8080"), ["http/p:8080"]);
182        assert_eq!(authorities("HTTP p:8080"), ["http/p:8080"]);
183        assert_eq!(authorities("HTTPS p:8443"), ["https/p:8443"]);
184        assert_eq!(authorities("SOCKS p:1080"), ["socks4/p:1080"]);
185        assert_eq!(authorities("SOCKS4 p:1080"), ["socks4/p:1080"]);
186        assert_eq!(authorities("SOCKS5 p:1080"), ["socks5/p:1080"]);
187    }
188
189    #[test]
190    fn socks5_uses_remote_dns_in_to_url() {
191        let step = &parse_find_proxy_result("SOCKS5 proxy.corp:1080").unwrap()[0];
192        assert_eq!(step.scheme(), Some("socks5"));
193        assert_eq!(
194            step.endpoint().unwrap().scheme_hint,
195            Some(ProxyScheme::Socks5h)
196        );
197        assert_eq!(step.to_url().unwrap().as_str(), "socks5h://proxy.corp:1080");
198    }
199
200    // `scheme()` says `socks4` for SOCKS4a too, so only `to_url` tells the two apart: a plain
201    // SOCKS4 proxy may not speak 4a, and the name is resolved on this side.
202    #[test]
203    fn socks_and_socks4_resolve_locally_in_to_url() {
204        for keyword in ["SOCKS", "SOCKS4"] {
205            let step = &parse_find_proxy_result(&format!("{keyword} proxy.corp")).unwrap()[0];
206            assert_eq!(
207                step.to_url().unwrap().as_str(),
208                "socks4://proxy.corp:1080",
209                "{keyword}"
210            );
211        }
212    }
213
214    #[test]
215    fn keywords_are_case_insensitive_and_ports_default() {
216        assert_eq!(authorities("proxy p"), ["http/p:80"]);
217        assert_eq!(authorities("Https p"), ["https/p:443"]);
218        assert_eq!(authorities("socks5 p"), ["socks5/p:1080"]);
219        assert_eq!(authorities("direct"), ["direct"]);
220        // Port 0 is the keyword's default too, as the WinHTTP and CFNetwork engines read it.
221        assert_eq!(authorities("PROXY p:0"), ["http/p:80"]);
222        assert_eq!(authorities("SOCKS5 p:0"), ["socks5/p:1080"]);
223    }
224
225    #[test]
226    fn a_chain_keeps_its_order() {
227        assert_eq!(
228            authorities("PROXY a:1; SOCKS5 b:2; DIRECT"),
229            ["http/a:1", "socks5/b:2", "direct"]
230        );
231    }
232
233    #[test]
234    fn whitespace_around_a_candidate_is_ignored() {
235        assert_eq!(
236            authorities("  PROXY   a:1  ;\n\tPROXY b:2 ;;  "),
237            ["http/a:1", "http/b:2"]
238        );
239    }
240
241    // Every `\n` in the row above follows a `;`, so the word split would absorb it anyway.
242    // These rows have no `;`, the only shape that tells the two apart.
243    #[test]
244    fn a_newline_separates_candidates_where_no_semicolon_does() {
245        assert_eq!(
246            authorities("PROXY a:1\nPROXY b:2\r\nDIRECT"),
247            ["http/a:1", "http/b:2", "direct"]
248        );
249
250        // A lone `\r` is a line ending too; above, it sits before a `\n` that already cut.
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        assert_eq!(authorities("PROXY ::1"), ["http/[::1]:80"]);
258    }
259
260    #[test]
261    fn duplicates_collapse() {
262        assert_eq!(
263            authorities("PROXY a:1; PROXY a:1; DIRECT; DIRECT"),
264            ["http/a:1", "direct"]
265        );
266        // Not just adjacent ones: the check is against the whole chain, and the first
267        // occurrence is the one that keeps its position.
268        assert_eq!(
269            authorities("PROXY a:1; PROXY b:2; PROXY a:1"),
270            ["http/a:1", "http/b:2"]
271        );
272        // A repeat is the same protocol to the same address. One address under another
273        // protocol is another thing to try.
274        assert_eq!(
275            authorities("PROXY a:1; HTTPS a:1; SOCKS5 a:1; HTTP a:1"),
276            ["http/a:1", "https/a:1", "socks5/a:1"]
277        );
278    }
279
280    #[test]
281    fn junk_candidates_are_skipped_but_do_not_poison_the_chain() {
282        assert_eq!(
283            authorities("GOPHER g:70; PROXY a:1; PROXY ; DIRECT x; SOCKS9 b:2"),
284            ["http/a:1"]
285        );
286        assert_eq!(authorities("PROXY a:99999; PROXY a:1"), ["http/a:1"]);
287        assert_eq!(authorities("PROXY http://a:1; PROXY a:1"), ["http/a:1"]);
288        // That row cannot show the scheme is what is being *refused*: the one the address
289        // carries agrees with the keyword, so accepting it would build the step the next
290        // candidate already contributes and the collapse above hides the difference. Where
291        // they disagree the keyword wins: `with_scheme_hint` overwrites what the address
292        // said, and reading `https://` as plain HTTP is cleartext to a port that expects
293        // TLS. So the candidate goes, and the chain moves on to what the script named next.
294        assert_eq!(authorities("PROXY https://p:8443; DIRECT"), ["direct"]);
295        assert_eq!(authorities("PROXY a:1 b:2; DIRECT"), ["direct"]);
296    }
297
298    /// A candidate carrying a path, query or fragment is malformed, not an address with
299    /// something on the end to cut off. `ProxyEndpoint::parse` cuts it off: that is right
300    /// for the values operating systems store, which are written as URLs, and running the
301    /// same cut here answered with `corp:8080` for a string no script wrote, and put it
302    /// ahead of the `DIRECT` the reference falls back to.
303    ///
304    /// Chromium's `ProxySchemeHostAndPortToProxyServer` gives `url::ParseAuthority` the
305    /// whole string, so the port component of `corp:8080/path` is `8080/path` and
306    /// `url::ParsePort` refuses it; `corp/path` has no colon, so the path lands in the host
307    /// and `CanonicalizeHost` refuses that. `InvalidProxyUriToProxyServer` lists the three
308    /// spellings under the comment "Paths disallowed."
309    /// (`net/base/proxy_string_util_unittest.cc`), though the three rows there are missing
310    /// their commas and so concatenate into one literal, which is why they are separate
311    /// rows here.
312    #[test]
313    fn a_candidate_that_is_not_a_bare_authority_is_skipped_whole() {
314        for spec in [
315            "PROXY corp:8080/path",
316            "PROXY corp:8080/",
317            "PROXY corp/path",
318            "PROXY corp:8080?q=1",
319            "PROXY corp:8080#f",
320            "SOCKS5 corp:1080/path",
321        ] {
322            assert_eq!(
323                authorities(&format!("{spec}; DIRECT")),
324                ["direct"],
325                "{spec}"
326            );
327        }
328
329        // The candidate goes, and only it: the chain moves on to what the script named next
330        // rather than reporting the address a cut would have left.
331        assert_eq!(
332            authorities("PROXY corp:8080/path; PROXY other:3128"),
333            ["http/other:3128"]
334        );
335
336        // Nothing else in the chain, and there is no answer to give: the same refusal the
337        // parse makes for any string it cannot read.
338        assert!(matches!(
339            parse_find_proxy_result("PROXY corp:8080/path").unwrap_err(),
340            Error::PacInvalidResult { .. }
341        ));
342    }
343
344    #[test]
345    fn credentials_in_a_candidate_are_dropped_but_the_proxy_survives() {
346        let steps = parse_find_proxy_result("PROXY alice:hunter2@p:8080").unwrap();
347        let endpoint = steps[0].endpoint().unwrap();
348        assert_eq!(endpoint.authority(), "p:8080");
349        assert_eq!(endpoint.auth, None);
350        // Dropped rather than rejected because `to_url` is what a caller hands its HTTP
351        // client, and it must not carry userinfo a remote script chose.
352        assert_eq!(steps[0].to_url().unwrap().as_str(), "http://p:8080/");
353        // A bare user name has no `:` to split on and must go the same way.
354        assert_eq!(authorities("SOCKS5 bob@s:1080"), ["socks5/s:1080"]);
355    }
356
357    #[test]
358    fn a_result_with_nothing_usable_is_an_error() {
359        for junk in [
360            "",
361            "   ",
362            ";;;",
363            "null",
364            "undefined",
365            "GOPHER g:70",
366            "PROXY",
367        ] {
368            let error = parse_find_proxy_result(junk).unwrap_err();
369            assert!(
370                matches!(error, Error::PacInvalidResult { .. }),
371                "{junk:?} gave {error:?}"
372            );
373        }
374    }
375
376    // A remote script's string has no length cap, and this parse runs after the PAC timeout
377    // has stopped waiting. Repeats and junk never fill the chain, so every candidate is read.
378    #[test]
379    fn a_long_result_of_repeats_and_junk_parses_in_linear_time() {
380        let result = (0..200_000)
381            .map(|i| match i % 3 {
382                0 => format!("PROXY h0.example.com:{}", 8080 + i % 2),
383                1 => format!("PROXY h{i}.example.com/"),
384                _ => format!("JUNK{i}"),
385            })
386            .collect::<Vec<_>>()
387            .join(";");
388        let start = std::time::Instant::now();
389        let chain = authorities(&result);
390        let elapsed = start.elapsed();
391        assert_eq!(
392            chain,
393            ["http/h0.example.com:8080", "http/h0.example.com:8081"]
394        );
395        assert!(
396            elapsed < std::time::Duration::from_secs(20),
397            "parsing 200,000 candidates took {elapsed:?}"
398        );
399    }
400
401    #[test]
402    fn the_chain_keeps_its_first_steps_and_drops_the_rest() {
403        // The doc of `parse_find_proxy_result` names the number.
404        assert_eq!(MAX_CHAIN, 64);
405        // Junk and repeats are not steps and do not count; a `DIRECT` past the limit is
406        // dropped with the rest, so the chain never widens to direct by being long.
407        let mut result = String::from("junk; PROXY h0:1; PROXY h0:1;");
408        for i in 1..=MAX_CHAIN {
409            result.push_str(&format!(" PROXY h{i}:1;"));
410        }
411        result.push_str(" DIRECT");
412        let chain = authorities(&result);
413        assert_eq!(chain.len(), MAX_CHAIN);
414        assert_eq!(chain[0], "http/h0:1");
415        assert_eq!(chain[MAX_CHAIN - 1], format!("http/h{}:1", MAX_CHAIN - 1));
416        assert!(!chain.contains(&"direct".to_owned()));
417    }
418
419    #[test]
420    fn words_are_split_at_ascii_whitespace_only() {
421        assert_eq!(authorities("PROXY\ta:1"), ["http/a:1"]);
422        // A no-break space, an ideographic space and a vertical tab are part of the word
423        // they sit in, at either end of a candidate as well as inside it.
424        for space in ['\u{a0}', '\u{3000}', '\u{b}'] {
425            for result in [
426                format!("PROXY{space}a:1; DIRECT"),
427                format!("{space}PROXY a:1; DIRECT"),
428                format!("PROXY a:1{space}; DIRECT"),
429            ] {
430                assert_eq!(authorities(&result), ["direct"], "{result:?}");
431            }
432            let result = format!("DIRECT{space}; PROXY a:1");
433            assert_eq!(authorities(&result), ["http/a:1"], "{result:?}");
434        }
435    }
436}