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}