Skip to main content

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}