use std::fmt;
use http::Uri;
use zeroize::Zeroizing;
use crate::config::ConfigError;
use crate::response::BackendError;
const PROXY_VARS: [&str; 6] = ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
const NO_PROXY_VARS: [&str; 2] = ["NO_PROXY", "no_proxy"];
#[derive(Clone, Default)]
pub struct ProxySettings {
mode: Mode,
}
#[derive(Clone, Default)]
enum Mode {
#[default]
Env,
Url(Zeroizing<String>),
Direct,
}
impl fmt::Debug for ProxySettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.mode {
Mode::Env => f.write_str("ProxySettings::from_env"),
Mode::Direct => f.write_str("ProxySettings::direct"),
Mode::Url(url) => match parse_proxy(url) {
Some(target) => write!(f, "ProxySettings::url({})", target.shown()),
None => f.write_str("ProxySettings::url(<invalid>)"),
},
}
}
}
impl ProxySettings {
pub fn from_env() -> Self {
Self { mode: Mode::Env }
}
pub fn url(url: impl Into<String>) -> Self {
Self { mode: Mode::Url(Zeroizing::new(url.into())) }
}
pub fn direct() -> Self {
Self { mode: Mode::Direct }
}
pub fn is_from_env(&self) -> bool {
matches!(self.mode, Mode::Env)
}
pub fn is_direct(&self) -> bool {
matches!(self.mode, Mode::Direct)
}
pub fn validate(&self) -> Result<(), ConfigError> {
let Mode::Url(url) = &self.mode else { return Ok(()) };
match parse_proxy(url) {
None => Err(ConfigError::Proxy("not a proxy URL ([scheme://][user[:password]@]host[:port])".into())),
Some(ProxyTarget { scheme: Scheme::Unsupported(scheme), .. }) => {
Err(ConfigError::Proxy(format!("a {scheme}:// proxy is not supported (http:// and https:// proxies are)")))
}
Some(_) => Ok(()),
}
}
pub(crate) fn resolve(&self) -> ProxyRoute {
match &self.mode {
Mode::Env => ProxyRoute::from_vars(|name| std::env::var(name).ok()),
Mode::Direct => ProxyRoute { proxy: None, no_proxy: Vec::new() },
Mode::Url(url) => ProxyRoute {
proxy: Some(parse_proxy(url).ok_or_else(|| "the proxy URL of the ProxySettings is not a proxy URL".to_string())),
no_proxy: Vec::new(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Scheme {
Http,
Https,
Unsupported(String),
}
pub(crate) struct ProxyTarget {
pub(crate) scheme: Scheme,
host: String,
port: u16,
#[cfg_attr(not(feature = "ws"), allow(dead_code))]
pub(crate) authorization: Option<Zeroizing<String>>,
#[cfg_attr(not(feature = "http"), allow(dead_code))]
url: Zeroizing<String>,
}
impl ProxyTarget {
#[cfg_attr(not(feature = "ws"), allow(dead_code))]
pub(crate) fn address(&self) -> (&str, u16) {
let host = self.host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(&self.host);
(host, self.port)
}
fn scheme_name(&self) -> &str {
match &self.scheme {
Scheme::Http => "http",
Scheme::Https => "https",
Scheme::Unsupported(scheme) => scheme,
}
}
fn shown(&self) -> String {
format!("{}://{}:{}", self.scheme_name(), self.host, self.port)
}
#[cfg(feature = "http")]
pub(crate) fn ureq(&self) -> Option<ureq::Proxy> {
match self.scheme {
Scheme::Http | Scheme::Https => ureq::Proxy::new(&self.url).ok(),
Scheme::Unsupported(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum NoProxyEntry {
Exact(String),
Prefix(String),
Suffix(String),
All,
}
impl NoProxyEntry {
fn parse(entry: &str) -> Self {
let entry = entry.trim();
match entry {
"*" => Self::All,
e if e.starts_with('*') => Self::Suffix(e.chars().skip(1).collect::<String>().to_ascii_lowercase()),
e if e.starts_with('.') => Self::Suffix(e.to_ascii_lowercase()),
e if e.ends_with('*') => Self::Prefix(e.chars().take(e.chars().count().saturating_sub(1)).collect::<String>().to_ascii_lowercase()),
e if e.ends_with('.') => Self::Prefix(e.to_ascii_lowercase()),
e => Self::Exact(e.to_ascii_lowercase()),
}
}
fn matches(&self, host: &str) -> bool {
let host = host.to_ascii_lowercase();
match self {
Self::All => true,
Self::Exact(pattern) => *pattern == host,
Self::Prefix(prefix) => host.starts_with(prefix.as_str()),
Self::Suffix(suffix) => host.ends_with(suffix.as_str()),
}
}
}
#[derive(Default)]
pub(crate) struct ProxyRoute {
proxy: Option<Result<ProxyTarget, String>>,
no_proxy: Vec<NoProxyEntry>,
}
impl fmt::Debug for ProxyRoute {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let proxy = match &self.proxy {
None => "none".to_string(),
Some(Ok(target)) => target.shown(),
Some(Err(_)) => "<invalid>".to_string(),
};
f.debug_struct("ProxyRoute").field("proxy", &proxy).field("no_proxy_entries", &self.no_proxy.len()).finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Via {
#[cfg_attr(not(feature = "http"), allow(dead_code))]
Http,
#[cfg_attr(not(feature = "ws"), allow(dead_code))]
WebSocket,
}
impl ProxyRoute {
pub(crate) fn from_vars(var: impl Fn(&str) -> Option<String>) -> Self {
let proxy = PROXY_VARS.iter().find_map(|name| var(name).and_then(|value| parse_proxy(&value))).map(Ok);
let no_proxy = NO_PROXY_VARS
.iter()
.find_map(|name| var(name))
.map(|list| list.split(',').filter(|e| !e.trim().is_empty()).map(NoProxyEntry::parse).collect())
.unwrap_or_default();
Self { proxy, no_proxy }
}
#[cfg_attr(not(feature = "http"), allow(dead_code))]
pub(crate) fn proxy(&self) -> Option<Result<&ProxyTarget, &str>> {
self.proxy.as_ref().map(|p| p.as_ref().map_err(String::as_str))
}
pub(crate) fn route(&self, uri: &Uri, via: Via) -> Result<Option<&ProxyTarget>, BackendError> {
let Some(proxy) = &self.proxy else { return Ok(None) };
let host = uri.host().unwrap_or_default();
if crate::request::is_loopback_host(host) || self.no_proxy.iter().any(|entry| entry.matches(host)) {
return Ok(None);
}
let target = proxy.as_ref().map_err(|why| BackendError::InvalidRequest(why.clone()))?;
match (&target.scheme, via) {
(Scheme::Http, _) | (Scheme::Https, Via::Http) => Ok(Some(target)),
(Scheme::Https, Via::WebSocket) => Err(BackendError::InvalidRequest(
"the proxy is an https:// proxy; WebSocket connections go through http:// proxies (CONNECT) only, and never around the proxy".into(),
)),
(Scheme::Unsupported(scheme), Via::Http) => Err(BackendError::InvalidRequest(format!(
"the proxy is a {scheme}:// proxy, which is not supported; HTTP requests go through http:// or https:// proxies only, and never around the proxy"
))),
(Scheme::Unsupported(scheme), Via::WebSocket) => Err(BackendError::InvalidRequest(format!(
"the proxy is a {scheme}:// proxy; WebSocket connections go through http:// proxies (CONNECT) only, and never around the proxy"
))),
}
}
}
fn parse_proxy(value: &str) -> Option<ProxyTarget> {
let value = value.trim();
let uri = value.parse::<Uri>().ok()?;
let authority = uri.authority()?;
if authority.host().is_empty() {
return None;
}
let scheme_text = uri.scheme_str().unwrap_or("http").to_ascii_lowercase();
let (scheme, default_port) = match scheme_text.as_str() {
"http" => (Scheme::Http, 80),
"https" => (Scheme::Https, 443),
"socks4" | "socks4a" | "socks" | "socks5" | "socks5h" => (Scheme::Unsupported(scheme_text.clone()), 1080),
_ => return None,
};
let text = authority.as_str();
let userinfo = text.rfind('@').and_then(|at| text.get(..at));
let authorization = userinfo.map(|info| {
let (user, password) = match info.rfind(':') {
Some(colon) => (info.get(..colon).unwrap_or_default(), info.get(colon + 1..).unwrap_or_default()),
None => (info, ""),
};
let mut plain = Zeroizing::new(String::with_capacity(user.len() + password.len() + 1));
plain.push_str(user);
plain.push(':');
plain.push_str(password);
let mut header = Zeroizing::new(String::with_capacity(6 + base64_len(plain.len())));
header.push_str("Basic ");
base64_into(plain.as_bytes(), &mut header);
header
});
let url = Zeroizing::new(if uri.scheme_str().is_some() { value.to_string() } else { format!("http://{value}") });
Some(ProxyTarget { scheme, host: authority.host().to_string(), port: authority.port_u16().unwrap_or(default_port), authorization, url })
}
fn base64_len(len: usize) -> usize {
len.div_ceil(3) * 4
}
fn base64_into(bytes: &[u8], out: &mut String) {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let symbol = |index: u32| char::from(ALPHABET.get(usize::try_from(index & 63).unwrap_or(0)).copied().unwrap_or(b'A'));
for chunk in bytes.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(symbol(n >> 18));
out.push(symbol(n >> 12));
out.push(if chunk.len() > 1 { symbol(n >> 6) } else { '=' });
out.push(if chunk.len() > 2 { symbol(n) } else { '=' });
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn env(vars: &[(&str, &str)]) -> ProxyRoute {
let vars: HashMap<String, String> = vars.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect();
ProxyRoute::from_vars(|name| vars.get(name).cloned())
}
fn routed(route: &ProxyRoute, url: &str, via: Via) -> Result<Option<(String, u16)>, BackendError> {
let uri: Uri = url.parse().unwrap_or_else(|e| panic!("{e}"));
route.route(&uri, via).map(|p| p.map(|p| (p.address().0.to_string(), p.address().1)))
}
#[test]
fn the_first_valid_variable_wins_in_ureqs_order() {
let proxy = Some(("proxy.test".to_string(), 3128));
for via in [Via::Http, Via::WebSocket] {
assert_eq!(routed(&env(&[("HTTPS_PROXY", "http://proxy.test:3128")]), "wss://game.test/ws", via).ok(), Some(proxy.clone()));
assert_eq!(routed(&env(&[("http_proxy", "proxy.test:3128")]), "ws://game.test/ws", via).ok(), Some(proxy.clone()));
assert_eq!(
routed(&env(&[("ALL_PROXY", "http://proxy.test:3128"), ("HTTPS_PROXY", "http://other.test:1")]), "wss://game.test/", via).ok(),
Some(proxy.clone())
);
assert_eq!(
routed(&env(&[("ALL_PROXY", "fakeproto://x.test"), ("HTTP_PROXY", "http://proxy.test:3128")]), "wss://game.test/", via).ok(),
Some(proxy.clone())
);
assert_eq!(routed(&env(&[("HTTP_PROXY", "http://proxy.test")]), "wss://game.test/", via).ok(), Some(Some(("proxy.test".to_string(), 80))));
assert_eq!(routed(&env(&[("HTTP_PROXY", "http://[::1]:8080")]), "wss://game.test/", via).ok(), Some(Some(("::1".to_string(), 8080))));
assert_eq!(routed(&env(&[]), "wss://game.test/", via).ok(), Some(None));
}
}
#[test]
fn loopback_and_no_proxy_hosts_go_direct() {
let base = [("HTTPS_PROXY", "http://proxy.test:3128")];
for via in [Via::Http, Via::WebSocket] {
for url in ["ws://127.0.0.1:9000/", "http://localhost/", "ws://[::1]:9000/"] {
assert_eq!(routed(&env(&base), url, via).ok(), Some(None), "{url}");
}
let with = |list: &str| env(&[base[0], ("NO_PROXY", list)]);
assert_eq!(routed(&with("game.test"), "wss://game.test/", via).ok(), Some(None));
assert_eq!(routed(&with("GAME.test"), "wss://Game.Test/", via).ok(), Some(None));
assert!(routed(&with("game.test"), "wss://eu.game.test/", via).is_ok_and(|p| p.is_some()));
assert_eq!(routed(&with(".game.test"), "wss://eu.game.test/", via).ok(), Some(None));
assert_eq!(routed(&with("*.game.test"), "wss://eu.game.test/", via).ok(), Some(None));
assert!(routed(&with(".game.test"), "wss://game.test/", via).is_ok_and(|p| p.is_some()));
assert_eq!(routed(&with("10.0.*"), "ws://10.0.0.7/", via).ok(), Some(None));
assert_eq!(routed(&with("*"), "wss://anything.test/", via).ok(), Some(None));
assert_eq!(routed(&env(&[base[0], ("no_proxy", "game.test")]), "wss://game.test/", via).ok(), Some(None));
assert!(routed(&with("other.test, game.test"), "wss://game.test/", via).is_ok_and(|p| p.is_none()));
}
}
#[test]
fn socks_proxies_are_refused_never_bypassed_and_https_only_carries_http() {
for proxy in ["socks5://proxy.test:1080", "socks5h://proxy.test", "socks4://proxy.test", "socks://proxy.test"] {
for via in [Via::Http, Via::WebSocket] {
let error = routed(&env(&[("ALL_PROXY", proxy)]), "https://game.test/", via).err();
assert!(matches!(&error, Some(BackendError::InvalidRequest(why)) if why.contains("never around")), "{proxy}: {error:?}");
assert_eq!(error.and_then(|e| e.was_sent()), Some(false));
assert_eq!(routed(&env(&[("ALL_PROXY", proxy)]), "http://127.0.0.1:1/", via).ok(), Some(None));
assert_eq!(routed(&env(&[("ALL_PROXY", proxy), ("NO_PROXY", "game.test")]), "https://game.test/", via).ok(), Some(None));
}
}
let https = env(&[("HTTPS_PROXY", "https://proxy.test")]);
assert_eq!(routed(&https, "https://game.test/", Via::Http).ok(), Some(Some(("proxy.test".to_string(), 443))));
let error = routed(&https, "wss://game.test/", Via::WebSocket).err();
assert!(matches!(&error, Some(BackendError::InvalidRequest(why)) if why.contains("http:// proxies")), "{error:?}");
}
#[test]
fn settings_in_code_override_the_environment() {
let uri: Uri = "https://game.test/".parse().unwrap_or_else(|e| panic!("{e}"));
let direct = ProxySettings::direct().resolve();
assert!(direct.route(&uri, Via::Http).is_ok_and(|p| p.is_none()) && direct.proxy().is_none());
let url = ProxySettings::url("http://user:pw@proxy.test:3128").resolve();
assert_eq!(url.route(&uri, Via::WebSocket).ok().flatten().map(ProxyTarget::address), Some(("proxy.test", 3128)));
let local: Uri = "http://127.0.0.1:8080/".parse().unwrap_or_else(|e| panic!("{e}"));
assert!(url.route(&local, Via::Http).is_ok_and(|p| p.is_none()));
let bad = ProxySettings::url("::not a url::").resolve();
assert!(matches!(bad.route(&uri, Via::Http), Err(BackendError::InvalidRequest(_))));
assert!(bad.route(&local, Via::Http).is_ok_and(|p| p.is_none()));
assert!(ProxySettings::from_env().validate().is_ok() && ProxySettings::direct().validate().is_ok());
assert!(ProxySettings::url("proxy.test:3128").validate().is_ok());
assert!(ProxySettings::url("https://proxy.test").validate().is_ok());
for bad in ["::not a url::", "ftp://proxy.test", "socks5://proxy.test", ""] {
assert!(matches!(ProxySettings::url(bad).validate(), Err(ConfigError::Proxy(_))), "{bad}");
}
}
#[test]
fn credentials_become_basic_authorization_and_never_show_in_debug() {
let route = env(&[("HTTPS_PROXY", "http://user:p@ss:word@proxy.test:3128")]);
let Some(Ok(proxy)) = route.proxy() else { panic!("no proxy") };
let mut expected = String::from("Basic ");
base64_into(b"user:p@ss:word", &mut expected);
assert_eq!(proxy.authorization.as_deref().map(String::as_str), Some(expected.as_str()));
assert_eq!(proxy.address(), ("proxy.test", 3128));
let settings = ProxySettings::url("http://user:p@ss:word@proxy.test:3128");
for shown in [format!("{route:?}"), format!("{settings:?}")] {
assert!(!shown.contains("user") && !shown.contains("word") && !shown.contains("Basic"), "{shown}");
assert!(shown.contains("proxy.test:3128"), "{shown}");
}
let mut out = String::new();
base64_into(b"Aladdin:open sesame", &mut out);
assert_eq!(out, "QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
for (input, encoded) in [(&b""[..], ""), (b"f", "Zg=="), (b"fo", "Zm8="), (b"foo", "Zm9v"), (b"foob", "Zm9vYg==")] {
let mut out = String::new();
base64_into(input, &mut out);
assert_eq!(out, encoded);
assert_eq!(out.len(), base64_len(input.len()));
}
}
#[test]
#[cfg(feature = "http")]
fn ureq_gets_http_and_https_proxies_only() {
let target = |url: &str| parse_proxy(url).unwrap_or_else(|| panic!("{url}"));
assert!(target("http://proxy.test:3128").ureq().is_some_and(|p| p.protocol() == ureq::ProxyProtocol::Http && p.port() == 3128));
assert!(target("proxy.test").ureq().is_some_and(|p| p.protocol() == ureq::ProxyProtocol::Http && p.port() == 80));
assert!(target("https://proxy.test").ureq().is_some_and(|p| p.protocol() == ureq::ProxyProtocol::Https));
assert!(target("socks5://proxy.test").ureq().is_none());
}
}