proxy_watch/endpoint.rs
1//! Proxy endpoints: which host/port to talk to, for which request scheme.
2
3use std::fmt;
4use std::net::Ipv6Addr;
5use std::str::FromStr;
6
7use url::{Host, Url};
8
9use crate::auth::ProxyAuth;
10use crate::error::Error;
11use crate::util::{percent_decode, quote_if_not_credential_shaped, split_host_port};
12
13/// Request scheme a proxy setting applies to ([`ProxyMode::Manual`](crate::ProxyMode::Manual)
14/// `per_scheme` key). Not [`ProxyScheme`] (how to talk to the proxy).
15///
16/// Concrete schemes always beat [`Scheme::All`]. On Windows/`socks=`, GNOME (only SOCKS
17/// configured → All), and macOS (`SOCKSEnable` fallback), readers may already have copied
18/// SOCKS into other slots before a `ProxyMode` exists.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
20#[non_exhaustive]
21pub enum Scheme {
22 /// `http://` requests.
23 Http,
24 /// `https://` requests.
25 Https,
26 /// `ftp://` requests.
27 Ftp,
28 /// SOCKS / catch-all on some platforms.
29 Socks,
30 /// Bare `ProxyServer` / `all_proxy` catch-all.
31 All,
32}
33
34impl Scheme {
35 /// All schemes this crate models, in a stable order.
36 pub const ALL: [Scheme; 5] = [
37 Scheme::Http,
38 Scheme::Https,
39 Scheme::Ftp,
40 Scheme::Socks,
41 Scheme::All,
42 ];
43
44 /// The canonical lowercase name, as used by Windows `ProxyServer` keys.
45 #[must_use]
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Scheme::Http => "http",
49 Scheme::Https => "https",
50 Scheme::Ftp => "ftp",
51 Scheme::Socks => "socks",
52 Scheme::All => "all",
53 }
54 }
55
56 /// Parse a scheme key such as `http` in `http=proxy:8080`.
57 ///
58 /// `None` for unmodelled keys (`gopher`, …). `all` is accepted (see
59 /// [`parse::proxy_server`](crate::parse::proxy_server)).
60 #[must_use]
61 pub fn from_name(name: &str) -> Option<Scheme> {
62 match name.trim().to_ascii_lowercase().as_str() {
63 "http" => Some(Scheme::Http),
64 "https" => Some(Scheme::Https),
65 "ftp" => Some(Scheme::Ftp),
66 "socks" => Some(Scheme::Socks),
67 "all" => Some(Scheme::All),
68 _ => None,
69 }
70 }
71}
72
73impl fmt::Display for Scheme {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.write_str(self.as_str())
76 }
77}
78
79/// The wire protocol used to reach the proxy itself.
80///
81/// Only ever a *hint*: most operating system sources (the Windows registry in
82/// particular) store a bare `host:port` and leave the protocol implicit.
83///
84/// Where a source names SOCKS without a version (GNOME's `socks` child, KDE's `socksProxy`
85/// and macOS's `SOCKSProxy`) this crate reports [`ProxyScheme::Socks5`]. That is a
86/// compatibility choice and not something read out of the setting: Chromium makes the same
87/// one and calls it a "policy decision" where it makes it, in
88/// `proxy_config_service_linux.cc`. GIO chose otherwise, treating such a setting as
89/// standing for SOCKS5, SOCKS4a and SOCKS4 alike, so a proxy that speaks only SOCKS4 is
90/// reachable from a GIO application and not from a caller that takes this hint literally.
91///
92/// A version written into the value is kept: `socks4://proxy.example.com` in GNOME's `host`
93/// child or KDE's `socksProxy` reads as [`Socks4`](ProxyScheme::Socks4), because the
94/// readers apply their default only when the value specifies no version. Chromium reads it
95/// the same way: "we default to socks 5, but if the user specifically set it to
96/// `socks4://`, then use that", in `FixupProxyHostScheme`. The native stacks do not:
97/// glib-networking formats `socks://%s:%u` from the host key verbatim, and KF5-era KIO
98/// replaces whatever scheme it finds with a bare `socks://`. This spelling is an escape
99/// hatch from this crate's default; the platform's own resolver still sees SOCKS5 or an
100/// invalid value.
101///
102/// The same word can mean different versions in different sources: a `socks://` URI is SOCKS5,
103/// while the Windows registry's `socks=host:port` is read as SOCKS4. Microsoft gives that token
104/// no version of its own, but WinINet owns the key, and the feature table in
105/// [WinINet vs. WinHTTP](https://learn.microsoft.com/en-us/windows/win32/wininet/wininet-vs-winhttp)
106/// grants it SOCKS4 alone: "**SOCKS4 (SOCKS version 4) support**. Doesn't include v4a" is `yes`
107/// for WinINet, and "**SOCKS5 (SOCKS version 5) support**" is `no`.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
109#[non_exhaustive]
110pub enum ProxyScheme {
111 /// Plain HTTP proxy (`CONNECT` for TLS). Default port 80.
112 Http,
113 /// TLS-wrapped HTTP proxy. Default port 443.
114 Https,
115 /// SOCKS4, names resolved locally. Default port 1080.
116 Socks4,
117 /// SOCKS4a, names resolved by the proxy. Default port 1080.
118 Socks4a,
119 /// SOCKS5, names resolved locally. Default port 1080.
120 Socks5,
121 /// SOCKS5, names resolved by the proxy. Default port 1080.
122 Socks5h,
123}
124
125impl ProxyScheme {
126 /// The canonical lowercase URL scheme.
127 #[must_use]
128 pub fn as_str(self) -> &'static str {
129 match self {
130 ProxyScheme::Http => "http",
131 ProxyScheme::Https => "https",
132 ProxyScheme::Socks4 => "socks4",
133 ProxyScheme::Socks4a => "socks4a",
134 ProxyScheme::Socks5 => "socks5",
135 ProxyScheme::Socks5h => "socks5h",
136 }
137 }
138
139 /// The port assumed when the source omitted one.
140 #[must_use]
141 pub fn default_port(self) -> u16 {
142 match self {
143 ProxyScheme::Http => 80,
144 ProxyScheme::Https => 443,
145 ProxyScheme::Socks4
146 | ProxyScheme::Socks4a
147 | ProxyScheme::Socks5
148 | ProxyScheme::Socks5h => 1080,
149 }
150 }
151}
152
153impl fmt::Display for ProxyScheme {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 f.write_str(self.as_str())
156 }
157}
158
159impl FromStr for ProxyScheme {
160 type Err = Error;
161
162 fn from_str(s: &str) -> Result<Self, Self::Err> {
163 match s.trim().to_ascii_lowercase().as_str() {
164 "http" => Ok(ProxyScheme::Http),
165 "https" => Ok(ProxyScheme::Https),
166 "socks4" => Ok(ProxyScheme::Socks4),
167 // A `socks://` URI scheme is SOCKS5, and only here. The same word means
168 // SOCKS4 as the Windows `socks=` bucket key and as the PAC `SOCKS` keyword, which
169 // is why those two set their hint themselves instead of asking this function --
170 // the reference draws the same line, and says so out loud.
171 "socks" | "socks5" => Ok(ProxyScheme::Socks5),
172 "socks4a" => Ok(ProxyScheme::Socks4a),
173 "socks5h" => Ok(ProxyScheme::Socks5h),
174 // Redact: `ProxyEndpoint::parse` can hand `alice:pass@http` here, or
175 // `bob:pw`, from `bob:pw://host`, where no `@` marks the userinfo.
176 other => Err(Error::UnsupportedProxyScheme(
177 crate::util::redact_offending_token(other),
178 )),
179 }
180 }
181}
182
183/// Concrete proxy address. IPv6 brackets resolved at parse; use [`authority`](Self::authority).
184/// `#[non_exhaustive]`: construct via [`new`](Self::new) / [`parse`](Self::parse).
185#[derive(Debug, Clone, PartialEq, Eq, Hash)]
186#[non_exhaustive]
187pub struct ProxyEndpoint {
188 /// Wire protocol hint when the source named one.
189 pub scheme_hint: Option<ProxyScheme>,
190 /// Proxy host.
191 pub host: Host,
192 /// Proxy port.
193 pub port: u16,
194 /// Embedded credentials, if any.
195 pub auth: Option<ProxyAuth>,
196}
197
198impl ProxyEndpoint {
199 /// Build an endpoint from an already-parsed host and port.
200 #[must_use]
201 pub fn new(host: Host, port: u16) -> Self {
202 Self {
203 scheme_hint: None,
204 host,
205 port,
206 auth: None,
207 }
208 }
209
210 /// Set the scheme hint (builder style).
211 #[must_use]
212 pub fn with_scheme_hint(mut self, scheme: ProxyScheme) -> Self {
213 self.scheme_hint = Some(scheme);
214 self
215 }
216
217 /// Set the credentials (builder style).
218 #[must_use]
219 pub fn with_auth(mut self, auth: ProxyAuth) -> Self {
220 self.auth = Some(auth);
221 self
222 }
223
224 /// Parse a proxy address in any of the shapes operating systems store.
225 ///
226 /// `host`, `host:port`, `[::1]:8080`, `user:pass@host[:port]`, or any of those behind
227 /// a `scheme://`. Credentials do not need the scheme: the userinfo split happens after
228 /// the optional prefix, so a bare `user:pass@host` carries a secret just as a
229 /// `scheme://` one does. The *last* `@` is the delimiter, as in WHATWG's authority
230 /// state, so `user@corp.example:pw@proxy:8080` keeps an email address as the user name.
231 /// Port: explicit, else scheme default, else `default_port`.
232 /// A path, query or fragment is dropped, so `http://proxy:8080/` and
233 /// `http://proxy:8080` are the same endpoint; nothing after the authority survives.
234 /// One holding an `@` is refused with [`Error::InvalidProxyServer`]: the host in front
235 /// of it is then the start of a password that held a `/`, `?` or `#`.
236 ///
237 /// ```
238 /// # use proxy_watch::{ProxyEndpoint, ProxyScheme};
239 /// let ep = ProxyEndpoint::parse("socks5://[::1]", 80).unwrap();
240 /// assert_eq!(ep.scheme_hint, Some(ProxyScheme::Socks5));
241 /// assert_eq!(ep.port, 1080);
242 /// assert_eq!(ep.authority(), "[::1]:1080");
243 /// ```
244 ///
245 /// # Errors
246 ///
247 /// [`Error::InvalidProxyServer`] if the input is not a usable address, or
248 /// [`Error::UnsupportedProxyScheme`] for a `scheme://` prefix this crate does not
249 /// model. Never [`Error::InvalidProxyUrl`]: that one belongs to the callers that
250 /// parse an `AutoConfigURL`-style value, not to an address.
251 ///
252 /// U+FFFD anywhere in the authority (credentials included) is one of the
253 /// unusable addresses. It is what a lossy byte-to-text conversion leaves behind, and
254 /// this crate's platform readers convert that way so that a value they could not decode
255 /// is refused here instead of reading as unset. A path or query is dropped before the
256 /// check, so the character is only refused where the answer is built from it.
257 pub fn parse(input: &str, default_port: u16) -> Result<Self, Error> {
258 let trimmed = input.trim();
259 if trimmed.is_empty() {
260 return Err(Error::proxy_server(input, "empty address"));
261 }
262
263 let (scheme_hint, rest) = match trimmed.split_once("://") {
264 Some((scheme, rest)) => (Some(scheme.parse::<ProxyScheme>()?), rest),
265 None => (None, trimmed),
266 };
267 // Cut the path, query and fragment, and do it *before* the userinfo split below,
268 // not after. `@` is an ordinary path character, so
269 // `http://bob:pw/x@proxy.corp:8080` would otherwise `rsplit_once` into userinfo
270 // `bob:pw/x` and a host taken from the path: a destination the writer never named,
271 // reached because a `/` came first.
272 // `trace::tests::safe_error_does_not_leak_credentials_from_real_parsers` is what
273 // fails if these two lines change places.
274 //
275 // An `@` in what the cut drops is refused rather than dropped with it. The writer
276 // who puts one there almost always meant a password holding the cut character, and
277 // the authority left in front of it is then the user name and the start of the
278 // password: `http://bob:123/x@proxy.corp:8080` is host `bob`, port 123, a machine
279 // not configured as the proxy. A password whose start is not a port fails
280 // anyway; one that is all digits, or no password at all
281 // (`http://bob/x@proxy.corp`), parsed.
282 let (rest, dropped) = match rest.find(['/', '?', '#']) {
283 Some(cut) => rest.split_at(cut),
284 None => (rest, ""),
285 };
286 if dropped.contains('@') {
287 return Err(Error::proxy_server(
288 input,
289 "an '@' follows a '/', '?' or '#', so the host before it is the start of a \
290 user name or password rather than the proxy (percent-encode the character \
291 in the credentials: %2F, %3F, %23)",
292 ));
293 }
294
295 // Readers of proxy values convert bytes lossily: `env::readable_var`,
296 // `kioslaverc`'s `ProxyType = 4` lookup and `sys::win::ffi::string_value` use this
297 // because `into_string().ok()` cannot distinguish unset values from non-text bytes.
298 // The mangled value is retained and refused here. Checking only `parse_host` is
299 // insufficient: it refuses U+FFFD, but `parse_userinfo` cannot fail. A `0xFF` in a
300 // password would otherwise produce a secret the administrator did not set, causing
301 // authentication failure without a `rejected` entry identifying the changed value,
302 // and `ProxyAuth`'s `Debug` masks it out of the snapshot that could show it.
303 // `util::percent_decode` refuses to manufacture the same character for the same
304 // reason.
305 //
306 // On the authority, not on `input`: a path, query or fragment is dropped whole, so
307 // mangling there costs nothing the answer is built from.
308 if rest.contains(char::REPLACEMENT_CHARACTER) {
309 return Err(Error::proxy_server(input, "value is not valid text"));
310 }
311
312 let (auth, host_port) = match rest.rsplit_once('@') {
313 Some((userinfo, host_port)) => (Some(parse_userinfo(userinfo)), host_port),
314 None => (None, rest),
315 };
316
317 if host_port.is_empty() {
318 return Err(Error::proxy_server(input, "missing host"));
319 }
320 let (host_text, port) =
321 split_host_port(host_port).map_err(|reason| Error::proxy_server(input, reason))?;
322 let host = parse_host(host_text).map_err(|reason| Error::proxy_server(input, reason))?;
323
324 // `host:` is a *written* port that is empty, which is not the same thing as a source
325 // that omitted the port: the writer meant to name one and did not. Chromium draws the
326 // same line: `ProxyUriToProxyServer` in `net/base/proxy_string_util.cc` splits the
327 // authority and answers with an invalid `ProxyServer()` when the port component
328 // `is_valid() && is_empty()`, while `url::ParsePort` -- the canonicaliser the same
329 // build uses for ordinary URLs -- reads that empty component as `PORT_UNSPECIFIED`,
330 // i.e. as no port at all. Only the proxy side refuses it.
331 // Length, not `ends_with(':')`, is what separates the two: `h:` and `[::1]:` leave a
332 // host shorter than what was split, while a bare host and an unbracketed `2001:db8::`
333 // -- which `split_host_port` hands back whole, colons and all -- do not.
334 if port.is_none() && host_port.len() > host_text.len() {
335 return Err(Error::proxy_server(input, "empty port after ':'"));
336 }
337
338 let port = port
339 .or_else(|| scheme_hint.map(ProxyScheme::default_port))
340 .unwrap_or(default_port);
341
342 Ok(Self {
343 scheme_hint,
344 host,
345 port,
346 auth,
347 })
348 }
349
350 /// The `host:port` form, with IPv6 hosts re-bracketed.
351 #[must_use]
352 pub fn authority(&self) -> String {
353 match &self.host {
354 Host::Ipv6(ip) => format!("[{ip}]:{}", self.port),
355 other => format!("{other}:{}", self.port),
356 }
357 }
358}
359
360impl fmt::Display for ProxyEndpoint {
361 // Renders `host:port`, with `scheme://` only when a hint was parsed out: most sources
362 // store a bare authority. Never the credentials: a `Display` ends up in logs and error
363 // messages.
364 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
365 if let Some(scheme) = self.scheme_hint {
366 write!(f, "{scheme}://")?;
367 }
368 f.write_str(&self.authority())
369 }
370}
371
372/// Whether a given [`Scheme`] uses a proxy at all.
373///
374/// `Disabled` distinguishes a scheme explicitly configured without a proxy from an
375/// unconfigured scheme: it suppresses the [`Scheme::All`] fallback, while an unconfigured
376/// scheme allows it. [`Unusable`](Self::Unusable) records a configured scheme whose proxy
377/// could not be read. Absence means the platform has no setting for the scheme.
378#[derive(Debug, Clone, PartialEq, Eq)]
379#[non_exhaustive]
380pub enum ProxyEntry {
381 /// Route this scheme through the given proxy.
382 Use(ProxyEndpoint),
383 /// The platform named this scheme and gave it no proxy: an off switch, or a slot
384 /// left blank.
385 Disabled,
386 /// The platform named this scheme and gave it a proxy that could not be read.
387 ///
388 /// Routing requires an endpoint, which is unavailable here. Returning
389 /// [`Disabled`](Self::Disabled) would incorrectly treat an unreadable proxy setting as
390 /// a request for a direct connection. Under the `resolve` feature this becomes
391 /// [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable).
392 ///
393 /// Every lookup uses it only when no other entry covers the scheme; see
394 /// [`ProxyMode::entry_for`](crate::ProxyMode::entry_for). A usable [`Scheme::All`]
395 /// takes precedence because it supplies a usable configured fallback.
396 Unusable(crate::diagnostic::RejectedValue),
397}
398
399impl ProxyEntry {
400 /// The endpoint, or `None` for [`Disabled`](Self::Disabled) and
401 /// [`Unusable`](Self::Unusable) alike; neither has one, which is why a caller that
402 /// needs to tell "go direct" from "the setting was lost" asks
403 /// [`rejected`](Self::rejected) rather than this.
404 #[must_use]
405 pub fn endpoint(&self) -> Option<&ProxyEndpoint> {
406 match self {
407 ProxyEntry::Use(endpoint) => Some(endpoint),
408 ProxyEntry::Disabled | ProxyEntry::Unusable(_) => None,
409 }
410 }
411
412 /// Whether the entry is [`ProxyEntry::Disabled`]. `false` for
413 /// [`Unusable`](Self::Unusable): a lost setting is not an off switch.
414 #[must_use]
415 pub fn is_disabled(&self) -> bool {
416 matches!(self, ProxyEntry::Disabled)
417 }
418
419 /// The drop record, for [`ProxyEntry::Unusable`] only.
420 #[must_use]
421 pub fn rejected(&self) -> Option<&crate::diagnostic::RejectedValue> {
422 match self {
423 ProxyEntry::Unusable(rejected) => Some(rejected),
424 ProxyEntry::Use(_) | ProxyEntry::Disabled => None,
425 }
426 }
427}
428
429// Parse a URL host, accepting bracketed and bare IPv6 literals.
430//
431// Both failure messages go into an [`Error`] `reason`, which `crate::trace::SafeError`
432// prints in full, so neither echoes `text` unconditionally; see
433// [`quote_if_not_credential_shaped`] for what arrives here that is not a host at all.
434pub(crate) fn parse_host(text: &str) -> Result<Host, String> {
435 let text = text.trim();
436 if text.is_empty() {
437 return Err("missing host".to_owned());
438 }
439 if let Some(inner) = text.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
440 return inner.parse::<Ipv6Addr>().map(Host::Ipv6).map_err(|_| {
441 format!(
442 "invalid IPv6 literal {}",
443 quote_if_not_credential_shaped(inner)
444 )
445 });
446 }
447 if let Ok(ip) = text.parse::<Ipv6Addr>() {
448 return Ok(Host::Ipv6(ip));
449 }
450 Host::parse(text)
451 .map_err(|e| format!("invalid host {}: {e}", quote_if_not_credential_shaped(text)))
452}
453
454// The host a request URL names, or `None` if it names none. Every "does this URL have a
455// host?" question in the crate comes through here rather than to [`Url::host`], which
456// answers `Some(Host::Domain(""))` for a URL whose host was emptied: `set_host(None)` on a
457// non-special scheme produces that (url 2.5.8; special schemes refuse it with `EmptyHost`).
458// Such a URL has nothing to connect to, so it is hostless here.
459//
460// Lives here rather than beside its first caller in `resolve` because `BypassRules` asks it
461// too, and `resolve` is behind a feature while `BypassRules` is not.
462fn request_host_ref(url: &Url) -> Option<Host<&str>> {
463 match url.host()? {
464 Host::Domain("") => None,
465 other => Some(other),
466 }
467}
468
469// Whether the URL names a host at all. Borrows, so a caller that asks only this does not pay
470// for the `String` the owned form builds and it would drop. Every caller is under `resolve`,
471// which `pac` and the engine flags all pull in.
472#[cfg(feature = "resolve")]
473pub(crate) fn has_request_host(url: &Url) -> bool {
474 request_host_ref(url).is_some()
475}
476
477pub(crate) fn request_host(url: &Url) -> Option<Host> {
478 match request_host_ref(url)? {
479 Host::Domain(domain) => Some(Host::Domain(domain.to_owned())),
480 Host::Ipv4(ip) => Some(Host::Ipv4(ip)),
481 Host::Ipv6(ip) => Some(Host::Ipv6(ip)),
482 }
483}
484
485fn parse_userinfo(userinfo: &str) -> ProxyAuth {
486 // A literal `:` and nothing else. Percent-encoding a reserved character is how RFC 3986
487 // section 2.2 spells "this is data, not the delimiter", so `alice%3Ahunter2` is one
488 // user name that happens to contain a colon; `url` (this crate's own dependency) and
489 // Python's `urllib` both read it that way. Splitting it here would give the caller an
490 // unintended user name and a password absent from the input. The colon then surviving
491 // into `username` is masked by [`ProxyAuth`]'s `Debug`, and that keeps it out of a
492 // snapshot.
493 match userinfo.split_once(':') {
494 Some((user, password)) => {
495 ProxyAuth::new(percent_decode(user), Some(percent_decode(password)))
496 }
497 None => ProxyAuth::from_username(percent_decode(userinfo)),
498 }
499}