1use std::net::IpAddr;
17use std::sync::Arc;
18
19use base64::prelude::*;
20use ipnet::IpNet;
21use tracing::info;
22use url::Url;
23
24use crate::config::ProxyConfig;
25use crate::filter::{canonical, parse_net};
26use crate::http_client::Endpoint;
27
28#[derive(Clone, PartialEq, Eq)]
33pub struct ProxyTarget {
34 endpoint: Endpoint,
35 authorization: Option<String>,
38 redacted: String,
41}
42
43impl ProxyTarget {
44 pub(crate) fn endpoint(&self) -> &Endpoint {
45 &self.endpoint
46 }
47
48 pub(crate) fn authorization(&self) -> Option<&str> {
49 self.authorization.as_deref()
50 }
51
52 pub(crate) fn redacted(&self) -> &str {
55 &self.redacted
56 }
57}
58
59#[cfg(test)]
60impl ProxyTarget {
61 pub(crate) fn for_test(url: &str) -> Self {
63 Self::parse(url, "proxy.http_url").expect("the test URL must parse")
64 }
65}
66
67impl std::fmt::Debug for ProxyTarget {
71 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 formatter
73 .debug_struct("ProxyTarget")
74 .field("url", &self.redacted)
75 .field("authenticated", &self.authorization.is_some())
76 .finish()
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82enum BypassRule {
83 Everything,
85 Suffix(String),
88 Network(IpNet),
91}
92
93#[derive(Debug, Clone, Default)]
99pub struct OutboundProxies {
100 http: Option<ProxyTarget>,
101 https: Option<ProxyTarget>,
102 bypass: Vec<BypassRule>,
103}
104
105impl OutboundProxies {
106 pub fn from_config(cfg: &ProxyConfig) -> anyhow::Result<Self> {
133 let (http_url, http_source) = resolve(&cfg.http_url, &["http_proxy"]);
134 let (https_url, https_source) = resolve(&cfg.https_url, &["https_proxy", "HTTPS_PROXY"]);
135 let (no_proxy, no_proxy_source) = match cfg.no_proxy.is_empty() {
136 false => (cfg.no_proxy.clone(), Source::Config),
137 true => {
138 let (raw, source) = resolve("", &["no_proxy", "NO_PROXY"]);
139 let entries = raw
140 .map(|value| value.split(',').map(str::to_string).collect())
141 .unwrap_or_default();
142 (entries, source)
143 }
144 };
145
146 let http = http_url
147 .as_deref()
148 .map(|url| ProxyTarget::parse(url, "proxy.http_url"))
149 .transpose()?;
150 let https = https_url
151 .as_deref()
152 .map(|url| ProxyTarget::parse(url, "proxy.https_url"))
153 .transpose()?;
154 let bypass = parse_bypass(&no_proxy)?;
155
156 let resolved = Self {
157 http,
158 https,
159 bypass,
160 };
161 if resolved.is_configured() {
162 info!(
163 event = "proxy_configured",
164 outcome = "advisory",
165 source = %Source::describe(&[http_source, https_source, no_proxy_source]),
166 http_proxy = resolved.http.as_ref().map_or("-", ProxyTarget::redacted),
167 https_proxy = resolved.https.as_ref().map_or("-", ProxyTarget::redacted),
168 no_proxy_rules = resolved.bypass.len(),
169 );
170 }
171 Ok(resolved)
172 }
173
174 pub fn direct() -> Self {
176 Self::default()
177 }
178
179 pub fn is_configured(&self) -> bool {
181 self.http.is_some() || self.https.is_some()
182 }
183
184 pub(crate) fn select(&self, endpoint: &Endpoint) -> Option<&ProxyTarget> {
197 let proxy = match endpoint.https {
198 true => self.https.as_ref(),
199 false => self.http.as_ref(),
200 }?;
201
202 let host = normalize_host(endpoint.host_for_lookup());
203 let address = host.parse::<IpAddr>().ok().map(canonical);
204 if host == "localhost" || address.is_some_and(|ip| ip.is_loopback()) {
205 return None;
206 }
207
208 let bypassed = self.bypass.iter().any(|rule| match rule {
209 BypassRule::Everything => true,
210 BypassRule::Suffix(suffix) => host == *suffix || host.ends_with(&format!(".{suffix}")),
211 BypassRule::Network(net) => address.is_some_and(|ip| net.contains(&ip)),
212 });
213
214 match bypassed {
215 true => None,
216 false => Some(proxy),
217 }
218 }
219
220 #[cfg(test)]
223 pub(crate) fn always(target: ProxyTarget) -> Self {
224 Self {
225 http: Some(target.clone()),
226 https: Some(target),
227 bypass: Vec::new(),
228 }
229 }
230
231 #[cfg(test)]
234 pub(crate) fn with_bypass(mut self, entries: &[&str]) -> anyhow::Result<Self> {
235 let entries: Vec<String> = entries.iter().map(|entry| (*entry).to_string()).collect();
236 self.bypass = parse_bypass(&entries)?;
237 Ok(self)
238 }
239}
240
241impl ProxyTarget {
242 fn parse(url: &str, setting: &str) -> anyhow::Result<Self> {
249 let trimmed = url.trim();
250 let spelled = match trimmed.contains("://") {
251 true => trimmed.to_string(),
252 false => format!("http://{trimmed}"),
253 };
254 let parsed = Url::parse(&spelled)
255 .map_err(|error| anyhow::anyhow!("{setting}: {url} is not a URL: {error}"))?;
256
257 match parsed.scheme() {
258 "http" => {}
259 "https" => anyhow::bail!(
260 "{setting}: {url} would reach the proxy over TLS, which is not supported. \
261 This key names the proxy used *for* https targets, and that proxy is \
262 normally still spelled http://host:port"
263 ),
264 other => anyhow::bail!(
265 "{setting}: unsupported proxy scheme {other}, expected http (there is no \
266 SOCKS support)"
267 ),
268 }
269
270 let endpoint = Endpoint::from_url(&parsed)
271 .map_err(|error| anyhow::anyhow!("{setting}: {url}: {error}"))?;
272
273 let user = percent_decode(parsed.username())
274 .map_err(|error| anyhow::anyhow!("{setting}: {url}: username {error}"))?;
275 let authorization = match user.is_empty() {
276 true => None,
277 false => {
278 let password = match parsed.password() {
279 Some(password) => percent_decode(password)
280 .map_err(|error| anyhow::anyhow!("{setting}: {url}: password {error}"))?,
281 None => String::new(),
282 };
283 Some(format!(
284 "Basic {}",
285 BASE64_STANDARD.encode(format!("{user}:{password}"))
286 ))
287 }
288 };
289
290 Ok(Self {
291 redacted: redact(&parsed),
292 endpoint,
293 authorization,
294 })
295 }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300enum Source {
301 Config,
302 Environment,
303 Unset,
304}
305
306impl Source {
307 fn describe(sources: &[Self]) -> &'static str {
308 let config = sources.contains(&Self::Config);
309 let environment = sources.contains(&Self::Environment);
310 match (config, environment) {
311 (true, true) => "config+environment",
312 (true, false) => "config",
313 (false, true) => "environment",
314 (false, false) => "unset",
315 }
316 }
317}
318
319fn resolve(configured: &str, variables: &[&str]) -> (Option<String>, Source) {
321 if !configured.trim().is_empty() {
322 return (Some(configured.trim().to_string()), Source::Config);
323 }
324 for name in variables {
325 if let Ok(value) = std::env::var(name)
326 && !value.trim().is_empty()
327 {
328 return (Some(value.trim().to_string()), Source::Environment);
329 }
330 }
331 (None, Source::Unset)
332}
333
334fn parse_bypass(entries: &[String]) -> anyhow::Result<Vec<BypassRule>> {
336 let mut rules = Vec::new();
337 for entry in entries {
338 let entry = entry.trim();
339 if entry.is_empty() {
340 continue;
341 }
342 if entry == "*" {
343 rules.push(BypassRule::Everything);
344 continue;
345 }
346 if let Ok(net) = parse_net(entry) {
347 rules.push(BypassRule::Network(net));
348 continue;
349 }
350 if entry.contains(':') {
354 anyhow::bail!(
355 "proxy.no_proxy: {entry} carries a port, which is not honoured — \
356 entries match on the host alone"
357 );
358 }
359 let suffix = entry
360 .trim_start_matches('.')
361 .trim_end_matches('.')
362 .to_ascii_lowercase();
363 if suffix.is_empty() {
364 anyhow::bail!("proxy.no_proxy: {entry} is not a domain, address or network");
365 }
366 rules.push(BypassRule::Suffix(suffix));
367 }
368 Ok(rules)
369}
370
371fn normalize_host(host: &str) -> String {
373 host.trim_end_matches('.').to_ascii_lowercase()
374}
375
376fn percent_decode(value: &str) -> Result<String, String> {
384 percent_encoding::percent_decode_str(value)
385 .decode_utf8()
386 .map(|decoded| decoded.into_owned())
387 .map_err(|error| format!("is not valid UTF-8 once decoded: {error}"))
388}
389
390fn redact(url: &Url) -> String {
392 match url.password().is_some() {
393 false => url.as_str().trim_end_matches('/').to_string(),
394 true => {
395 let mut redacted = url.clone();
396 let _ = redacted.set_password(Some("***"));
397 redacted.as_str().trim_end_matches('/').to_string()
398 }
399 }
400}
401
402pub fn from_config(cfg: &ProxyConfig) -> anyhow::Result<Arc<OutboundProxies>> {
405 OutboundProxies::from_config(cfg).map(Arc::new)
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::testutil::EnvGuard;
412
413 fn config(http: &str, https: &str, no_proxy: &[&str]) -> ProxyConfig {
414 ProxyConfig {
415 http_url: http.to_string(),
416 https_url: https.to_string(),
417 no_proxy: no_proxy.iter().map(|entry| (*entry).to_string()).collect(),
418 }
419 }
420
421 fn without_env() -> EnvGuard {
424 EnvGuard::new(&[])
425 }
426
427 fn endpoint(host: &str, https: bool) -> Endpoint {
428 Endpoint {
429 host: host.to_string(),
430 port: if https { 443 } else { 80 },
431 https,
432 }
433 }
434
435 #[test]
436 fn both_urls_are_parsed() {
437 let _guard = without_env();
438 let proxies = OutboundProxies::from_config(&config(
439 "http://p1.example:3128",
440 "http://p2.example",
441 &[],
442 ))
443 .unwrap();
444
445 let http = proxies.select(&endpoint("example.com", false)).unwrap();
446 assert_eq!(http.endpoint().host, "p1.example");
447 assert_eq!(http.endpoint().port, 3128);
448 assert!(!http.endpoint().https);
449
450 let https = proxies.select(&endpoint("example.com", true)).unwrap();
451 assert_eq!(https.endpoint().host, "p2.example");
452 assert_eq!(https.endpoint().port, 80);
453 }
454
455 #[test]
458 fn a_bare_authority_is_read_as_http() {
459 let _guard = without_env();
460 let proxies = OutboundProxies::from_config(&config("proxy.example:3128", "", &[])).unwrap();
461 let target = proxies.select(&endpoint("example.com", false)).unwrap();
462 assert_eq!(target.endpoint().host, "proxy.example");
463 assert_eq!(target.endpoint().port, 3128);
464 }
465
466 #[test]
470 fn the_two_keys_are_not_interchangeable() {
471 let _guard = without_env();
472 let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
473 assert!(proxies.select(&endpoint("example.com", false)).is_some());
474 assert!(proxies.select(&endpoint("example.com", true)).is_none());
475 }
476
477 #[test]
478 fn nothing_configured_is_direct() {
479 let _guard = without_env();
480 let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
481 assert!(!proxies.is_configured());
482 assert!(proxies.select(&endpoint("example.com", true)).is_none());
483 assert!(!OutboundProxies::direct().is_configured());
484 }
485
486 #[test]
487 fn credentials_become_a_basic_header() {
488 let _guard = without_env();
489 let proxies =
490 OutboundProxies::from_config(&config("http://user:pass@p.example", "", &[])).unwrap();
491 let target = proxies.select(&endpoint("example.com", false)).unwrap();
492 assert_eq!(target.authorization(), Some("Basic dXNlcjpwYXNz"));
494 }
495
496 #[test]
500 fn percent_encoded_credentials_are_decoded_before_encoding() {
501 let _guard = without_env();
502 let proxies =
503 OutboundProxies::from_config(&config("http://user%40corp:p%3Ass@p.example", "", &[]))
504 .unwrap();
505 let target = proxies.select(&endpoint("example.com", false)).unwrap();
506 assert_eq!(
507 target.authorization(),
508 Some(format!("Basic {}", BASE64_STANDARD.encode("user@corp:p:ss")).as_str())
509 );
510 }
511
512 #[test]
513 fn a_url_without_userinfo_carries_no_authorization() {
514 let _guard = without_env();
515 let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
516 assert!(
517 proxies
518 .select(&endpoint("example.com", false))
519 .unwrap()
520 .authorization()
521 .is_none()
522 );
523 }
524
525 #[test]
528 fn neither_debug_nor_redacted_leaks_the_password() {
529 let _guard = without_env();
530 let proxies =
531 OutboundProxies::from_config(&config("http://user:hunter2@p.example", "", &[]))
532 .unwrap();
533 let target = proxies.select(&endpoint("example.com", false)).unwrap();
534 assert!(
535 !target.redacted().contains("hunter2"),
536 "{}",
537 target.redacted()
538 );
539 assert!(target.redacted().contains("***"), "{}", target.redacted());
540
541 let rendered = format!("{target:?}");
542 assert!(!rendered.contains("hunter2"), "{rendered}");
543 assert!(!rendered.contains("Basic"), "{rendered}");
545
546 let whole = format!("{proxies:?}");
547 assert!(!whole.contains("hunter2"), "{whole}");
548 }
549
550 #[test]
551 fn every_refusal_names_what_has_to_change() {
552 let _guard = without_env();
553 let cases: &[(&str, &[&str])] = &[
554 (
555 "https://p.example:3128",
556 &["proxy.http_url", "http://host:port"],
557 ),
558 ("socks5://p.example:1080", &["proxy.http_url", "socks5"]),
559 ("http://", &["proxy.http_url"]),
560 ];
561 for (url, expected) in cases {
562 let error = OutboundProxies::from_config(&config(url, "", &[]))
563 .expect_err("{url} must be refused")
564 .to_string();
565 for fragment in *expected {
566 assert!(error.contains(fragment), "{url}: {error}");
567 }
568 }
569
570 let https_error = OutboundProxies::from_config(&config("", "https://p.example", &[]))
571 .expect_err("an https proxy URL must be refused")
572 .to_string();
573 assert!(https_error.contains("proxy.https_url"), "{https_error}");
574 }
575
576 #[test]
577 fn a_no_proxy_entry_with_a_port_is_refused_by_name() {
578 let _guard = without_env();
579 let error =
580 OutboundProxies::from_config(&config("http://p.example", "", &["example.com:8080"]))
581 .expect_err("a port in no_proxy must be refused")
582 .to_string();
583 assert!(error.contains("example.com:8080"), "{error}");
584 assert!(error.contains("port"), "{error}");
585 }
586
587 #[test]
588 fn an_unusable_no_proxy_entry_is_refused() {
589 let _guard = without_env();
590 let error = OutboundProxies::from_config(&config("http://p.example", "", &["."]))
591 .expect_err("a bare dot must be refused")
592 .to_string();
593 assert!(error.contains("proxy.no_proxy"), "{error}");
594 }
595
596 #[test]
597 fn no_proxy_matching() {
598 let _guard = without_env();
599 let cases: &[(&[&str], &str, bool)] = &[
601 (&["example.com"], "example.com", true),
602 (&["example.com"], "a.b.example.com", true),
603 (&[".example.com"], "example.com", true),
604 (&[".example.com"], "a.example.com", true),
605 (&["example.com"], "notexample.com", false),
608 (&["example.com"], "example.com.evil.net", false),
609 (&["EXAMPLE.COM"], "Example.Com", true),
610 (&["example.com"], "example.com.", true),
611 (&["*"], "anything.example", true),
612 (&["192.0.2.7"], "192.0.2.7", true),
613 (&["192.0.2.7"], "192.0.2.8", false),
614 (&["10.0.0.0/8"], "10.1.2.3", true),
615 (&["10.0.0.0/8"], "11.1.2.3", false),
616 (&["2001:db8::/32"], "[2001:db8::1]", true),
617 (&["10.0.0.0/8"], "host.example", false),
619 (&["other.example", "example.com"], "www.example.com", true),
620 ];
621 for (rules, host, bypassed) in cases {
622 let proxies =
623 OutboundProxies::from_config(&config("http://p.example", "", rules)).unwrap();
624 assert_eq!(
625 proxies.select(&endpoint(host, false)).is_none(),
626 *bypassed,
627 "{rules:?} against {host}"
628 );
629 }
630 }
631
632 #[test]
635 fn loopback_and_localhost_bypass_unconditionally() {
636 let _guard = without_env();
637 let proxies =
638 OutboundProxies::from_config(&config("http://p.example", "http://p.example", &["*"]))
639 .unwrap();
640 for host in ["localhost", "LocalHost", "127.0.0.1", "[::1]", "127.9.9.9"] {
641 assert!(
642 proxies.select(&endpoint(host, false)).is_none(),
643 "{host} must bypass"
644 );
645 }
646 let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
649 assert!(proxies.select(&endpoint("203.0.113.7", false)).is_some());
650 }
651
652 #[test]
653 fn the_environment_fills_in_an_unset_key() {
654 let _guard = EnvGuard::new(&[
655 ("http_proxy", "http://env-http.example:3128"),
656 ("https_proxy", "http://env-https.example:3128"),
657 ("no_proxy", "one.example, .two.example"),
658 ]);
659 let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
660 assert_eq!(
661 proxies
662 .select(&endpoint("example.com", false))
663 .unwrap()
664 .endpoint()
665 .host,
666 "env-http.example"
667 );
668 assert_eq!(
669 proxies
670 .select(&endpoint("example.com", true))
671 .unwrap()
672 .endpoint()
673 .host,
674 "env-https.example"
675 );
676 assert!(proxies.select(&endpoint("one.example", false)).is_none());
677 assert!(proxies.select(&endpoint("a.two.example", false)).is_none());
678 }
679
680 #[test]
681 fn a_configured_key_beats_the_environment() {
682 let _guard = EnvGuard::new(&[
683 ("http_proxy", "http://env.example:3128"),
684 ("no_proxy", "env.example"),
685 ]);
686 let proxies =
687 OutboundProxies::from_config(&config("http://file.example", "", &["file.example.com"]))
688 .unwrap();
689 assert_eq!(
690 proxies
691 .select(&endpoint("example.com", false))
692 .unwrap()
693 .endpoint()
694 .host,
695 "file.example"
696 );
697 assert!(proxies.select(&endpoint("env.example", false)).is_some());
700 assert!(
701 proxies
702 .select(&endpoint("file.example.com", false))
703 .is_none()
704 );
705 }
706
707 #[test]
710 fn uppercase_http_proxy_is_ignored_while_https_proxy_is_not() {
711 let _guard = EnvGuard::new(&[
712 ("HTTP_PROXY", "http://attacker.example:3128"),
713 ("HTTPS_PROXY", "http://upper.example:3128"),
714 ]);
715 let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
716 assert!(proxies.select(&endpoint("example.com", false)).is_none());
717 assert_eq!(
718 proxies
719 .select(&endpoint("example.com", true))
720 .unwrap()
721 .endpoint()
722 .host,
723 "upper.example"
724 );
725 }
726
727 #[test]
730 fn an_empty_environment_variable_is_unset() {
731 let _guard = EnvGuard::new(&[("http_proxy", ""), ("no_proxy", "")]);
732 let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
733 assert!(!proxies.is_configured());
734 }
735
736 #[test]
737 fn the_startup_source_is_described_from_where_the_values_came() {
738 assert_eq!(Source::describe(&[Source::Unset, Source::Unset]), "unset");
739 assert_eq!(Source::describe(&[Source::Config, Source::Unset]), "config");
740 assert_eq!(
741 Source::describe(&[Source::Environment, Source::Unset]),
742 "environment"
743 );
744 assert_eq!(
745 Source::describe(&[Source::Config, Source::Environment]),
746 "config+environment"
747 );
748 }
749
750 #[test]
751 fn from_config_hands_back_a_shared_value() {
752 let _guard = without_env();
753 let proxies = crate::proxy::from_config(&ProxyConfig::default()).unwrap();
754 assert!(!proxies.is_configured());
755 }
756}