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`, and prefixes `scheme://` only when a hint was parsed out. The
362 // prefix is the exception, not the shape: most sources store a bare authority, which
363 // is why [`ProxyScheme`] is a hint in the first place. Never the credentials (see
364 // [`ProxyAuth`]'s own documentation for why).
365 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366 if let Some(scheme) = self.scheme_hint {
367 write!(f, "{scheme}://")?;
368 }
369 f.write_str(&self.authority())
370 }
371}
372
373/// Whether a given [`Scheme`] uses a proxy at all.
374///
375/// `Disabled` distinguishes a scheme explicitly configured without a proxy from an
376/// unconfigured scheme: it suppresses the [`Scheme::All`] fallback, while an unconfigured
377/// scheme allows it. [`Unusable`](Self::Unusable) records a configured scheme whose proxy
378/// could not be read. Absence means the platform has no setting for the scheme.
379#[derive(Debug, Clone, PartialEq, Eq)]
380#[non_exhaustive]
381pub enum ProxyEntry {
382 /// Route this scheme through the given proxy.
383 Use(ProxyEndpoint),
384 /// The platform named this scheme and gave it no proxy: an off switch, or a slot
385 /// left blank.
386 Disabled,
387 /// The platform named this scheme and gave it a proxy that could not be read.
388 ///
389 /// Routing requires an endpoint, which is unavailable here. Returning
390 /// [`Disabled`](Self::Disabled) would incorrectly treat an unreadable proxy setting as
391 /// a request for a direct connection. Under the `resolve` feature this becomes
392 /// [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable).
393 ///
394 /// Every lookup uses it only when no other entry covers the scheme; see
395 /// [`ProxyMode::entry_for`](crate::ProxyMode::entry_for). A usable [`Scheme::All`]
396 /// takes precedence because it supplies a usable configured fallback.
397 Unusable(crate::diagnostic::RejectedValue),
398}
399
400impl ProxyEntry {
401 /// The endpoint, or `None` for [`Disabled`](Self::Disabled) and
402 /// [`Unusable`](Self::Unusable) alike; neither has one, which is why a caller that
403 /// needs to tell "go direct" from "the setting was lost" asks
404 /// [`rejected`](Self::rejected) rather than this.
405 #[must_use]
406 pub fn endpoint(&self) -> Option<&ProxyEndpoint> {
407 match self {
408 ProxyEntry::Use(endpoint) => Some(endpoint),
409 ProxyEntry::Disabled | ProxyEntry::Unusable(_) => None,
410 }
411 }
412
413 /// Whether the entry is [`ProxyEntry::Disabled`]. `false` for
414 /// [`Unusable`](Self::Unusable): a lost setting is not an off switch.
415 #[must_use]
416 pub fn is_disabled(&self) -> bool {
417 matches!(self, ProxyEntry::Disabled)
418 }
419
420 /// The drop record, for [`ProxyEntry::Unusable`] only.
421 #[must_use]
422 pub fn rejected(&self) -> Option<&crate::diagnostic::RejectedValue> {
423 match self {
424 ProxyEntry::Unusable(rejected) => Some(rejected),
425 ProxyEntry::Use(_) | ProxyEntry::Disabled => None,
426 }
427 }
428}
429
430// Parse a URL host, accepting bracketed and bare IPv6 literals.
431//
432// Both failure messages go into an [`Error`] `reason`, which `crate::trace::SafeError`
433// prints in full, so neither echoes `text` unconditionally; see
434// [`quote_if_not_credential_shaped`] for what arrives here that is not a host at all.
435pub(crate) fn parse_host(text: &str) -> Result<Host, String> {
436 let text = text.trim();
437 if text.is_empty() {
438 return Err("missing host".to_owned());
439 }
440 if let Some(inner) = text.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
441 return inner.parse::<Ipv6Addr>().map(Host::Ipv6).map_err(|_| {
442 format!(
443 "invalid IPv6 literal {}",
444 quote_if_not_credential_shaped(inner)
445 )
446 });
447 }
448 if let Ok(ip) = text.parse::<Ipv6Addr>() {
449 return Ok(Host::Ipv6(ip));
450 }
451 Host::parse(text)
452 .map_err(|e| format!("invalid host {}: {e}", quote_if_not_credential_shaped(text)))
453}
454
455// The host a request URL names, or `None` if it names none. Every "does this URL have a
456// host?" question in the crate comes through here rather than to [`Url::host`], which
457// answers `Some(Host::Domain(""))` for a URL whose host was emptied: `set_host(None)` on a
458// non-special scheme produces that (url 2.5.8; special schemes refuse it with `EmptyHost`).
459// Such a URL has nothing to connect to, so it is hostless here.
460//
461// Lives here rather than beside its first caller in `resolve` because `BypassRules` asks it
462// too, and `resolve` is behind a feature while `BypassRules` is not.
463fn request_host_ref(url: &Url) -> Option<Host<&str>> {
464 match url.host()? {
465 Host::Domain("") => None,
466 other => Some(other),
467 }
468}
469
470// Whether the URL names a host at all. Borrows, so a caller that asks only this does not pay
471// for the `String` the owned form builds and it would drop. Every caller is under `resolve`,
472// which `pac` and the engine flags all pull in.
473#[cfg(feature = "resolve")]
474pub(crate) fn has_request_host(url: &Url) -> bool {
475 request_host_ref(url).is_some()
476}
477
478pub(crate) fn request_host(url: &Url) -> Option<Host> {
479 match request_host_ref(url)? {
480 Host::Domain(domain) => Some(Host::Domain(domain.to_owned())),
481 Host::Ipv4(ip) => Some(Host::Ipv4(ip)),
482 Host::Ipv6(ip) => Some(Host::Ipv6(ip)),
483 }
484}
485
486fn parse_userinfo(userinfo: &str) -> ProxyAuth {
487 // A literal `:` and nothing else. Percent-encoding a reserved character is how RFC 3986
488 // section 2.2 spells "this is data, not the delimiter", so `alice%3Ahunter2` is one
489 // user name that happens to contain a colon; `url` (this crate's own dependency) and
490 // Python's `urllib` both read it that way. Splitting it here would give the caller an
491 // unintended user name and a password absent from the input. The colon then surviving
492 // into `username` is masked by [`ProxyAuth`]'s `Debug`, and that keeps it out of a
493 // snapshot.
494 match userinfo.split_once(':') {
495 Some((user, password)) => {
496 ProxyAuth::new(percent_decode(user), Some(percent_decode(password)))
497 }
498 None => ProxyAuth::from_username(percent_decode(userinfo)),
499 }
500}