use std::net::IpAddr;
use std::sync::Arc;
use base64::prelude::*;
use ipnet::IpNet;
use tracing::info;
use url::Url;
use crate::config::ProxyConfig;
use crate::filter::{canonical, parse_net};
use crate::http_client::Endpoint;
#[derive(Clone, PartialEq, Eq)]
pub struct ProxyTarget {
endpoint: Endpoint,
authorization: Option<String>,
redacted: String,
}
impl ProxyTarget {
pub(crate) fn endpoint(&self) -> &Endpoint {
&self.endpoint
}
pub(crate) fn authorization(&self) -> Option<&str> {
self.authorization.as_deref()
}
pub(crate) fn redacted(&self) -> &str {
&self.redacted
}
}
#[cfg(test)]
impl ProxyTarget {
pub(crate) fn for_test(url: &str) -> Self {
Self::parse(url, "proxy.http_url").expect("the test URL must parse")
}
}
impl std::fmt::Debug for ProxyTarget {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProxyTarget")
.field("url", &self.redacted)
.field("authenticated", &self.authorization.is_some())
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BypassRule {
Everything,
Suffix(String),
Network(IpNet),
}
#[derive(Debug, Clone, Default)]
pub struct OutboundProxies {
http: Option<ProxyTarget>,
https: Option<ProxyTarget>,
bypass: Vec<BypassRule>,
}
impl OutboundProxies {
pub fn from_config(cfg: &ProxyConfig) -> anyhow::Result<Self> {
let (http_url, http_source) = resolve(&cfg.http_url, &["http_proxy"]);
let (https_url, https_source) = resolve(&cfg.https_url, &["https_proxy", "HTTPS_PROXY"]);
let (no_proxy, no_proxy_source) = match cfg.no_proxy.is_empty() {
false => (cfg.no_proxy.clone(), Source::Config),
true => {
let (raw, source) = resolve("", &["no_proxy", "NO_PROXY"]);
let entries = raw
.map(|value| value.split(',').map(str::to_string).collect())
.unwrap_or_default();
(entries, source)
}
};
let http = http_url
.as_deref()
.map(|url| ProxyTarget::parse(url, "proxy.http_url"))
.transpose()?;
let https = https_url
.as_deref()
.map(|url| ProxyTarget::parse(url, "proxy.https_url"))
.transpose()?;
let bypass = parse_bypass(&no_proxy)?;
let resolved = Self {
http,
https,
bypass,
};
if resolved.is_configured() {
info!(
event = "proxy_configured",
outcome = "advisory",
source = %Source::describe(&[http_source, https_source, no_proxy_source]),
http_proxy = resolved.http.as_ref().map_or("-", ProxyTarget::redacted),
https_proxy = resolved.https.as_ref().map_or("-", ProxyTarget::redacted),
no_proxy_rules = resolved.bypass.len(),
);
}
Ok(resolved)
}
pub fn direct() -> Self {
Self::default()
}
pub fn is_configured(&self) -> bool {
self.http.is_some() || self.https.is_some()
}
pub(crate) fn select(&self, endpoint: &Endpoint) -> Option<&ProxyTarget> {
let proxy = match endpoint.https {
true => self.https.as_ref(),
false => self.http.as_ref(),
}?;
let host = normalize_host(endpoint.host_for_lookup());
let address = host.parse::<IpAddr>().ok().map(canonical);
if host == "localhost" || address.is_some_and(|ip| ip.is_loopback()) {
return None;
}
let bypassed = self.bypass.iter().any(|rule| match rule {
BypassRule::Everything => true,
BypassRule::Suffix(suffix) => host == *suffix || host.ends_with(&format!(".{suffix}")),
BypassRule::Network(net) => address.is_some_and(|ip| net.contains(&ip)),
});
match bypassed {
true => None,
false => Some(proxy),
}
}
#[cfg(test)]
pub(crate) fn always(target: ProxyTarget) -> Self {
Self {
http: Some(target.clone()),
https: Some(target),
bypass: Vec::new(),
}
}
#[cfg(test)]
pub(crate) fn with_bypass(mut self, entries: &[&str]) -> anyhow::Result<Self> {
let entries: Vec<String> = entries.iter().map(|entry| (*entry).to_string()).collect();
self.bypass = parse_bypass(&entries)?;
Ok(self)
}
}
impl ProxyTarget {
fn parse(url: &str, setting: &str) -> anyhow::Result<Self> {
let trimmed = url.trim();
let spelled = match trimmed.contains("://") {
true => trimmed.to_string(),
false => format!("http://{trimmed}"),
};
let parsed = Url::parse(&spelled)
.map_err(|error| anyhow::anyhow!("{setting}: {url} is not a URL: {error}"))?;
match parsed.scheme() {
"http" => {}
"https" => anyhow::bail!(
"{setting}: {url} would reach the proxy over TLS, which is not supported. \
This key names the proxy used *for* https targets, and that proxy is \
normally still spelled http://host:port"
),
other => anyhow::bail!(
"{setting}: unsupported proxy scheme {other}, expected http (there is no \
SOCKS support)"
),
}
let endpoint = Endpoint::from_url(&parsed)
.map_err(|error| anyhow::anyhow!("{setting}: {url}: {error}"))?;
let user = percent_decode(parsed.username())
.map_err(|error| anyhow::anyhow!("{setting}: {url}: username {error}"))?;
let authorization = match user.is_empty() {
true => None,
false => {
let password = match parsed.password() {
Some(password) => percent_decode(password)
.map_err(|error| anyhow::anyhow!("{setting}: {url}: password {error}"))?,
None => String::new(),
};
Some(format!(
"Basic {}",
BASE64_STANDARD.encode(format!("{user}:{password}"))
))
}
};
Ok(Self {
redacted: redact(&parsed),
endpoint,
authorization,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Source {
Config,
Environment,
Unset,
}
impl Source {
fn describe(sources: &[Self]) -> &'static str {
let config = sources.contains(&Self::Config);
let environment = sources.contains(&Self::Environment);
match (config, environment) {
(true, true) => "config+environment",
(true, false) => "config",
(false, true) => "environment",
(false, false) => "unset",
}
}
}
fn resolve(configured: &str, variables: &[&str]) -> (Option<String>, Source) {
if !configured.trim().is_empty() {
return (Some(configured.trim().to_string()), Source::Config);
}
for name in variables {
if let Ok(value) = std::env::var(name)
&& !value.trim().is_empty()
{
return (Some(value.trim().to_string()), Source::Environment);
}
}
(None, Source::Unset)
}
fn parse_bypass(entries: &[String]) -> anyhow::Result<Vec<BypassRule>> {
let mut rules = Vec::new();
for entry in entries {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if entry == "*" {
rules.push(BypassRule::Everything);
continue;
}
if let Ok(net) = parse_net(entry) {
rules.push(BypassRule::Network(net));
continue;
}
if entry.contains(':') {
anyhow::bail!(
"proxy.no_proxy: {entry} carries a port, which is not honoured — \
entries match on the host alone"
);
}
let suffix = entry
.trim_start_matches('.')
.trim_end_matches('.')
.to_ascii_lowercase();
if suffix.is_empty() {
anyhow::bail!("proxy.no_proxy: {entry} is not a domain, address or network");
}
rules.push(BypassRule::Suffix(suffix));
}
Ok(rules)
}
fn normalize_host(host: &str) -> String {
host.trim_end_matches('.').to_ascii_lowercase()
}
fn percent_decode(value: &str) -> Result<String, String> {
percent_encoding::percent_decode_str(value)
.decode_utf8()
.map(|decoded| decoded.into_owned())
.map_err(|error| format!("is not valid UTF-8 once decoded: {error}"))
}
fn redact(url: &Url) -> String {
match url.password().is_some() {
false => url.as_str().trim_end_matches('/').to_string(),
true => {
let mut redacted = url.clone();
let _ = redacted.set_password(Some("***"));
redacted.as_str().trim_end_matches('/').to_string()
}
}
}
pub fn from_config(cfg: &ProxyConfig) -> anyhow::Result<Arc<OutboundProxies>> {
OutboundProxies::from_config(cfg).map(Arc::new)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::EnvGuard;
fn config(http: &str, https: &str, no_proxy: &[&str]) -> ProxyConfig {
ProxyConfig {
http_url: http.to_string(),
https_url: https.to_string(),
no_proxy: no_proxy.iter().map(|entry| (*entry).to_string()).collect(),
}
}
fn without_env() -> EnvGuard {
EnvGuard::new(&[])
}
fn endpoint(host: &str, https: bool) -> Endpoint {
Endpoint {
host: host.to_string(),
port: if https { 443 } else { 80 },
https,
}
}
#[test]
fn both_urls_are_parsed() {
let _guard = without_env();
let proxies = OutboundProxies::from_config(&config(
"http://p1.example:3128",
"http://p2.example",
&[],
))
.unwrap();
let http = proxies.select(&endpoint("example.com", false)).unwrap();
assert_eq!(http.endpoint().host, "p1.example");
assert_eq!(http.endpoint().port, 3128);
assert!(!http.endpoint().https);
let https = proxies.select(&endpoint("example.com", true)).unwrap();
assert_eq!(https.endpoint().host, "p2.example");
assert_eq!(https.endpoint().port, 80);
}
#[test]
fn a_bare_authority_is_read_as_http() {
let _guard = without_env();
let proxies = OutboundProxies::from_config(&config("proxy.example:3128", "", &[])).unwrap();
let target = proxies.select(&endpoint("example.com", false)).unwrap();
assert_eq!(target.endpoint().host, "proxy.example");
assert_eq!(target.endpoint().port, 3128);
}
#[test]
fn the_two_keys_are_not_interchangeable() {
let _guard = without_env();
let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
assert!(proxies.select(&endpoint("example.com", false)).is_some());
assert!(proxies.select(&endpoint("example.com", true)).is_none());
}
#[test]
fn nothing_configured_is_direct() {
let _guard = without_env();
let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
assert!(!proxies.is_configured());
assert!(proxies.select(&endpoint("example.com", true)).is_none());
assert!(!OutboundProxies::direct().is_configured());
}
#[test]
fn credentials_become_a_basic_header() {
let _guard = without_env();
let proxies =
OutboundProxies::from_config(&config("http://user:pass@p.example", "", &[])).unwrap();
let target = proxies.select(&endpoint("example.com", false)).unwrap();
assert_eq!(target.authorization(), Some("Basic dXNlcjpwYXNz"));
}
#[test]
fn percent_encoded_credentials_are_decoded_before_encoding() {
let _guard = without_env();
let proxies =
OutboundProxies::from_config(&config("http://user%40corp:p%3Ass@p.example", "", &[]))
.unwrap();
let target = proxies.select(&endpoint("example.com", false)).unwrap();
assert_eq!(
target.authorization(),
Some(format!("Basic {}", BASE64_STANDARD.encode("user@corp:p:ss")).as_str())
);
}
#[test]
fn a_url_without_userinfo_carries_no_authorization() {
let _guard = without_env();
let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
assert!(
proxies
.select(&endpoint("example.com", false))
.unwrap()
.authorization()
.is_none()
);
}
#[test]
fn neither_debug_nor_redacted_leaks_the_password() {
let _guard = without_env();
let proxies =
OutboundProxies::from_config(&config("http://user:hunter2@p.example", "", &[]))
.unwrap();
let target = proxies.select(&endpoint("example.com", false)).unwrap();
assert!(
!target.redacted().contains("hunter2"),
"{}",
target.redacted()
);
assert!(target.redacted().contains("***"), "{}", target.redacted());
let rendered = format!("{target:?}");
assert!(!rendered.contains("hunter2"), "{rendered}");
assert!(!rendered.contains("Basic"), "{rendered}");
let whole = format!("{proxies:?}");
assert!(!whole.contains("hunter2"), "{whole}");
}
#[test]
fn every_refusal_names_what_has_to_change() {
let _guard = without_env();
let cases: &[(&str, &[&str])] = &[
(
"https://p.example:3128",
&["proxy.http_url", "http://host:port"],
),
("socks5://p.example:1080", &["proxy.http_url", "socks5"]),
("http://", &["proxy.http_url"]),
];
for (url, expected) in cases {
let error = OutboundProxies::from_config(&config(url, "", &[]))
.expect_err("{url} must be refused")
.to_string();
for fragment in *expected {
assert!(error.contains(fragment), "{url}: {error}");
}
}
let https_error = OutboundProxies::from_config(&config("", "https://p.example", &[]))
.expect_err("an https proxy URL must be refused")
.to_string();
assert!(https_error.contains("proxy.https_url"), "{https_error}");
}
#[test]
fn a_no_proxy_entry_with_a_port_is_refused_by_name() {
let _guard = without_env();
let error =
OutboundProxies::from_config(&config("http://p.example", "", &["example.com:8080"]))
.expect_err("a port in no_proxy must be refused")
.to_string();
assert!(error.contains("example.com:8080"), "{error}");
assert!(error.contains("port"), "{error}");
}
#[test]
fn an_unusable_no_proxy_entry_is_refused() {
let _guard = without_env();
let error = OutboundProxies::from_config(&config("http://p.example", "", &["."]))
.expect_err("a bare dot must be refused")
.to_string();
assert!(error.contains("proxy.no_proxy"), "{error}");
}
#[test]
fn no_proxy_matching() {
let _guard = without_env();
let cases: &[(&[&str], &str, bool)] = &[
(&["example.com"], "example.com", true),
(&["example.com"], "a.b.example.com", true),
(&[".example.com"], "example.com", true),
(&[".example.com"], "a.example.com", true),
(&["example.com"], "notexample.com", false),
(&["example.com"], "example.com.evil.net", false),
(&["EXAMPLE.COM"], "Example.Com", true),
(&["example.com"], "example.com.", true),
(&["*"], "anything.example", true),
(&["192.0.2.7"], "192.0.2.7", true),
(&["192.0.2.7"], "192.0.2.8", false),
(&["10.0.0.0/8"], "10.1.2.3", true),
(&["10.0.0.0/8"], "11.1.2.3", false),
(&["2001:db8::/32"], "[2001:db8::1]", true),
(&["10.0.0.0/8"], "host.example", false),
(&["other.example", "example.com"], "www.example.com", true),
];
for (rules, host, bypassed) in cases {
let proxies =
OutboundProxies::from_config(&config("http://p.example", "", rules)).unwrap();
assert_eq!(
proxies.select(&endpoint(host, false)).is_none(),
*bypassed,
"{rules:?} against {host}"
);
}
}
#[test]
fn loopback_and_localhost_bypass_unconditionally() {
let _guard = without_env();
let proxies =
OutboundProxies::from_config(&config("http://p.example", "http://p.example", &["*"]))
.unwrap();
for host in ["localhost", "LocalHost", "127.0.0.1", "[::1]", "127.9.9.9"] {
assert!(
proxies.select(&endpoint(host, false)).is_none(),
"{host} must bypass"
);
}
let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
assert!(proxies.select(&endpoint("203.0.113.7", false)).is_some());
}
#[test]
fn the_environment_fills_in_an_unset_key() {
let _guard = EnvGuard::new(&[
("http_proxy", "http://env-http.example:3128"),
("https_proxy", "http://env-https.example:3128"),
("no_proxy", "one.example, .two.example"),
]);
let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
assert_eq!(
proxies
.select(&endpoint("example.com", false))
.unwrap()
.endpoint()
.host,
"env-http.example"
);
assert_eq!(
proxies
.select(&endpoint("example.com", true))
.unwrap()
.endpoint()
.host,
"env-https.example"
);
assert!(proxies.select(&endpoint("one.example", false)).is_none());
assert!(proxies.select(&endpoint("a.two.example", false)).is_none());
}
#[test]
fn a_configured_key_beats_the_environment() {
let _guard = EnvGuard::new(&[
("http_proxy", "http://env.example:3128"),
("no_proxy", "env.example"),
]);
let proxies =
OutboundProxies::from_config(&config("http://file.example", "", &["file.example.com"]))
.unwrap();
assert_eq!(
proxies
.select(&endpoint("example.com", false))
.unwrap()
.endpoint()
.host,
"file.example"
);
assert!(proxies.select(&endpoint("env.example", false)).is_some());
assert!(
proxies
.select(&endpoint("file.example.com", false))
.is_none()
);
}
#[test]
fn uppercase_http_proxy_is_ignored_while_https_proxy_is_not() {
let _guard = EnvGuard::new(&[
("HTTP_PROXY", "http://attacker.example:3128"),
("HTTPS_PROXY", "http://upper.example:3128"),
]);
let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
assert!(proxies.select(&endpoint("example.com", false)).is_none());
assert_eq!(
proxies
.select(&endpoint("example.com", true))
.unwrap()
.endpoint()
.host,
"upper.example"
);
}
#[test]
fn an_empty_environment_variable_is_unset() {
let _guard = EnvGuard::new(&[("http_proxy", ""), ("no_proxy", "")]);
let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
assert!(!proxies.is_configured());
}
#[test]
fn the_startup_source_is_described_from_where_the_values_came() {
assert_eq!(Source::describe(&[Source::Unset, Source::Unset]), "unset");
assert_eq!(Source::describe(&[Source::Config, Source::Unset]), "config");
assert_eq!(
Source::describe(&[Source::Environment, Source::Unset]),
"environment"
);
assert_eq!(
Source::describe(&[Source::Config, Source::Environment]),
"config+environment"
);
}
#[test]
fn from_config_hands_back_a_shared_value() {
let _guard = without_env();
let proxies = crate::proxy::from_config(&ProxyConfig::default()).unwrap();
assert!(!proxies.is_configured());
}
}