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}