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}