Skip to main content

pipedash_core/infrastructure/
http_client.rs

1use std::net::{
2    IpAddr,
3    Ipv4Addr,
4    Ipv6Addr,
5};
6use std::sync::{
7    Arc,
8    OnceLock,
9    PoisonError,
10    RwLock,
11};
12use std::time::Duration;
13
14use dashmap::DashMap;
15use pipedash_plugin_api::ClientOptions;
16use reqwest::{
17    redirect,
18    Client,
19    ClientBuilder,
20    NoProxy,
21    Proxy,
22    Url,
23};
24
25use crate::domain::{
26    DomainError,
27    DomainResult,
28};
29
30const USER_AGENT: &str = concat!("pipedash/", env!("CARGO_PKG_VERSION"));
31const STATUS_TIMEOUT: Duration = Duration::from_secs(10);
32const STATUS_MAX_REDIRECTS: usize = 3;
33const MAX_REDIRECTS: usize = 10;
34
35type ClientKey = (Option<String>, bool, bool);
36
37pub struct HttpClientManager {
38    clients: RwLock<Arc<Clients>>,
39}
40
41struct Clients {
42    proxy: Option<Url>,
43    default_client: Arc<Client>,
44    by_origin: DashMap<ClientKey, Arc<Client>>,
45    status_client: OnceLock<Arc<Client>>,
46}
47
48impl Clients {
49    fn new(proxy: Option<Url>) -> DomainResult<Self> {
50        let default_client = HttpClientManager::build_client(false, false, proxy.as_ref())?;
51        Ok(Self {
52            proxy,
53            default_client: Arc::new(default_client),
54            by_origin: DashMap::new(),
55            status_client: OnceLock::new(),
56        })
57    }
58}
59
60impl HttpClientManager {
61    pub fn new() -> DomainResult<Self> {
62        Ok(Self {
63            clients: RwLock::new(Arc::new(Clients::new(None)?)),
64        })
65    }
66
67    pub fn set_proxy(&self, proxy_url: &str) -> DomainResult<bool> {
68        let proxy = parse_proxy_url(proxy_url)?;
69        if self.current().proxy == proxy {
70            return Ok(false);
71        }
72
73        let clients = Arc::new(Clients::new(proxy)?);
74        *self.clients.write().unwrap_or_else(PoisonError::into_inner) = clients;
75        Ok(true)
76    }
77
78    fn current(&self) -> Arc<Clients> {
79        Arc::clone(&self.clients.read().unwrap_or_else(PoisonError::into_inner))
80    }
81
82    pub fn client_for(&self, options: &ClientOptions) -> DomainResult<Arc<Client>> {
83        let clients = self.current();
84        if *options == ClientOptions::default() {
85            return Ok(Arc::clone(&clients.default_client));
86        }
87
88        let key = (
89            options.origin.as_ref().map(normalize_origin),
90            options.accept_invalid_certs,
91            options.same_host_redirects,
92        );
93        let entry = clients.by_origin.entry(key).or_try_insert_with(|| {
94            Self::build_client(
95                options.accept_invalid_certs,
96                options.same_host_redirects,
97                clients.proxy.as_ref(),
98            )
99            .map(Arc::new)
100        })?;
101        Ok(Arc::clone(entry.value()))
102    }
103
104    pub fn status_client(&self) -> DomainResult<Arc<Client>> {
105        let clients = self.current();
106        if let Some(client) = clients.status_client.get() {
107            return Ok(Arc::clone(client));
108        }
109        let client = Arc::new(Self::build_status_client(clients.proxy.as_ref())?);
110        Ok(Arc::clone(clients.status_client.get_or_init(|| client)))
111    }
112
113    fn base_builder(proxy: Option<&Url>) -> DomainResult<ClientBuilder> {
114        let _ = rustls::crypto::ring::default_provider().install_default();
115        let builder = Client::builder()
116            .tls_backend_rustls()
117            .https_only(cfg!(target_os = "ios"))
118            .user_agent(USER_AGENT);
119        let Some(proxy) = proxy else {
120            return Ok(builder);
121        };
122
123        let proxy = Proxy::all(proxy.clone())
124            .map_err(|e| DomainError::InvalidConfig(format!("Failed to use the proxy URL: {e}")))?
125            .no_proxy(NoProxy::from_env());
126        Ok(builder.proxy(proxy))
127    }
128
129    fn build_client(
130        accept_invalid_certs: bool, same_host_redirects: bool, proxy: Option<&Url>,
131    ) -> DomainResult<Client> {
132        let pool_size = std::env::var("PIPEDASH_HTTP_POOL_SIZE")
133            .ok()
134            .and_then(|s| s.parse().ok())
135            .unwrap_or(10);
136
137        let mut builder = Self::base_builder(proxy)?;
138        if same_host_redirects {
139            builder = builder.redirect(redirect::Policy::custom(|attempt| {
140                let first = attempt.previous().first();
141                if attempt.previous().len() > MAX_REDIRECTS {
142                    attempt.error("too many redirects")
143                } else if first.is_some_and(|first| stays_on_host(first, attempt.url())) {
144                    attempt.follow()
145                } else {
146                    attempt.error("redirect to another host refused")
147                }
148            }));
149        }
150        builder
151            .tls_danger_accept_invalid_certs(accept_invalid_certs)
152            .pool_max_idle_per_host(pool_size)
153            .pool_idle_timeout(Duration::from_secs(90))
154            .tcp_keepalive(Duration::from_secs(60))
155            .timeout(Duration::from_secs(30))
156            .connect_timeout(Duration::from_secs(10))
157            .build()
158            .map_err(|e| DomainError::InternalError(format!("Failed to create HTTP client: {}", e)))
159    }
160
161    fn build_status_client(proxy: Option<&Url>) -> DomainResult<Client> {
162        let policy = redirect::Policy::custom(|attempt| {
163            if attempt.previous().len() > STATUS_MAX_REDIRECTS {
164                attempt.error("too many redirects")
165            } else if is_safe_redirect_target(attempt.url()) {
166                attempt.follow()
167            } else {
168                attempt.error("redirect target refused")
169            }
170        });
171
172        Self::base_builder(proxy)?
173            .redirect(policy)
174            .timeout(STATUS_TIMEOUT)
175            .connect_timeout(STATUS_TIMEOUT)
176            .build()
177            .map_err(|e| {
178                DomainError::InternalError(format!("Failed to create status HTTP client: {}", e))
179            })
180    }
181}
182
183pub fn parse_proxy_url(value: &str) -> DomainResult<Option<Url>> {
184    let value = value.trim();
185    if value.is_empty() {
186        return Ok(None);
187    }
188
189    let url = Url::parse(value)
190        .ok()
191        .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
192        .ok_or_else(|| {
193            DomainError::InvalidConfig(
194                "Proxy URL must look like http://proxy.example.com:3128 or https://...".to_string(),
195            )
196        })?;
197    if !url.username().is_empty() || url.password().is_some() {
198        return Err(DomainError::InvalidConfig(
199            "Proxy URL can't contain a username or password. Pipedash supports proxies without \
200             authentication only."
201                .to_string(),
202        ));
203    }
204
205    Ok(Some(url))
206}
207
208fn normalize_origin(url: &Url) -> String {
209    format!(
210        "{}://{}:{}",
211        url.scheme().to_ascii_lowercase(),
212        url.host_str().unwrap_or_default().to_ascii_lowercase(),
213        url.port_or_known_default().unwrap_or_default()
214    )
215}
216
217fn stays_on_host(first: &Url, next: &Url) -> bool {
218    first.host_str() == next.host_str() && (first.scheme() != "https" || next.scheme() == "https")
219}
220
221fn is_safe_redirect_target(url: &Url) -> bool {
222    if url.scheme() != "https" {
223        return false;
224    }
225    let Some(host) = url.host_str() else {
226        return false;
227    };
228    match host
229        .trim_start_matches('[')
230        .trim_end_matches(']')
231        .parse::<IpAddr>()
232    {
233        Ok(ip) => !is_internal_ip(ip),
234        Err(_) => true,
235    }
236}
237
238fn is_internal_ip(ip: IpAddr) -> bool {
239    match ip {
240        IpAddr::V4(v4) => is_internal_v4(v4),
241        IpAddr::V6(v6) => v6
242            .to_ipv4_mapped()
243            .map_or_else(|| is_internal_v6(v6), is_internal_v4),
244    }
245}
246
247fn is_internal_v4(ip: Ipv4Addr) -> bool {
248    ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified()
249}
250
251fn is_internal_v6(ip: Ipv6Addr) -> bool {
252    ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() || ip.is_unspecified()
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn options(origin: &str, accept_invalid_certs: bool) -> ClientOptions {
260        ClientOptions {
261            origin: Some(Url::parse(origin).unwrap()),
262            accept_invalid_certs,
263            ..ClientOptions::default()
264        }
265    }
266
267    static HTTPS_CERTIFICATE: std::sync::LazyLock<rcgen::CertifiedKey<rcgen::KeyPair>> =
268        std::sync::LazyLock::new(|| {
269            let now = std::time::SystemTime::now();
270            let mut params =
271                rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])
272                    .unwrap();
273            params.not_before = (now - Duration::from_secs(60)).into();
274            params.not_after = (now + Duration::from_secs(86400)).into();
275            params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature];
276            params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
277            let signing_key = rcgen::KeyPair::generate().unwrap();
278            let cert = params.self_signed(&signing_key).unwrap();
279            rcgen::CertifiedKey { cert, signing_key }
280        });
281
282    fn https_only_client() -> Client {
283        HttpClientManager::base_builder(None)
284            .unwrap()
285            .https_only(true)
286            .no_proxy()
287            .add_root_certificate(
288                reqwest::Certificate::from_der(HTTPS_CERTIFICATE.cert.der()).unwrap(),
289            )
290            .timeout(Duration::from_secs(2))
291            .build()
292            .unwrap()
293    }
294
295    async fn tls_stub(response: String) -> (String, tokio::task::JoinHandle<String>) {
296        use std::io::{
297            Read,
298            Write,
299        };
300
301        let _ = rustls::crypto::ring::default_provider().install_default();
302        let certificate = &*HTTPS_CERTIFICATE;
303        let cert = certificate.cert.der().clone();
304        let key =
305            rustls::pki_types::PrivateKeyDer::Pkcs8(certificate.signing_key.serialize_der().into());
306        let config = Arc::new(
307            rustls::ServerConfig::builder()
308                .with_no_client_auth()
309                .with_single_cert(vec![cert], key)
310                .unwrap(),
311        );
312        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
313        let url = format!(
314            "https://127.0.0.1:{}",
315            listener.local_addr().unwrap().port()
316        );
317        let server = tokio::spawn(async move {
318            let (stream, _) = tokio::time::timeout(Duration::from_secs(2), listener.accept())
319                .await
320                .unwrap()
321                .unwrap();
322            let stream = stream.into_std().unwrap();
323            stream.set_nonblocking(false).unwrap();
324            stream
325                .set_read_timeout(Some(Duration::from_secs(2)))
326                .unwrap();
327            stream
328                .set_write_timeout(Some(Duration::from_secs(2)))
329                .unwrap();
330            tokio::task::spawn_blocking(move || {
331                let connection = rustls::ServerConnection::new(config).unwrap();
332                let mut stream = rustls::StreamOwned::new(connection, stream);
333                let mut request = Vec::new();
334                let mut buffer = [0; 4096];
335                while !request.windows(4).any(|part| part == b"\r\n\r\n") {
336                    let count = stream.read(&mut buffer).unwrap();
337                    assert!(count > 0 && request.len() + count <= 8192);
338                    request.extend_from_slice(&buffer[..count]);
339                }
340                stream.write_all(response.as_bytes()).unwrap();
341                stream.flush().unwrap();
342                String::from_utf8(request).unwrap()
343            })
344            .await
345            .unwrap()
346        });
347        (url, server)
348    }
349
350    #[tokio::test]
351    async fn https_only_rejects_http_before_sending_credentials() {
352        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
353        let error = https_only_client()
354            .get(format!("http://{}", listener.local_addr().unwrap()))
355            .header("Authorization", "token fixture")
356            .send()
357            .await
358            .unwrap_err();
359
360        assert!(error.is_builder(), "{error}");
361        assert!(
362            tokio::time::timeout(Duration::from_millis(50), listener.accept())
363                .await
364                .is_err()
365        );
366    }
367
368    #[tokio::test]
369    async fn https_only_allows_https_with_credentials() {
370        let (url, server) = tls_stub(
371            "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_string(),
372        )
373        .await;
374        let response = https_only_client()
375            .get(url)
376            .header("Authorization", "token fixture")
377            .send()
378            .await
379            .unwrap();
380
381        assert_eq!(response.text().await.unwrap(), "ok");
382        assert!(server
383            .await
384            .unwrap()
385            .to_ascii_lowercase()
386            .contains("authorization: token fixture"));
387    }
388
389    #[tokio::test]
390    async fn https_only_rejects_http_downgrade_redirect_before_traffic() {
391        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
392        let (url, server) = tls_stub(format!(
393            "HTTP/1.1 302 Found\r\nLocation: http://{}/token\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
394            listener.local_addr().unwrap()
395        ))
396        .await;
397        let error = https_only_client()
398            .get(url)
399            .header("Authorization", "token fixture")
400            .send()
401            .await
402            .unwrap_err();
403
404        assert!(error.is_redirect(), "{error}");
405        assert!(server
406            .await
407            .unwrap()
408            .to_ascii_lowercase()
409            .contains("authorization: token fixture"));
410        assert!(
411            tokio::time::timeout(Duration::from_millis(50), listener.accept())
412                .await
413                .is_err()
414        );
415    }
416
417    async fn redirect_stub(location: String) -> String {
418        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
419        let url = format!("http://{}/start", listener.local_addr().unwrap());
420        tokio::spawn(async move {
421            while let Ok((mut stream, _)) = listener.accept().await {
422                let location = location.clone();
423                tokio::spawn(async move {
424                    use tokio::io::{
425                        AsyncReadExt,
426                        AsyncWriteExt,
427                    };
428                    let mut buffer = [0; 4096];
429                    let count = stream.read(&mut buffer).await.unwrap_or(0);
430                    let request = String::from_utf8_lossy(&buffer[..count]);
431                    let response = if request.starts_with("GET /start ") {
432                        format!("HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
433                    } else {
434                        "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"
435                            .to_string()
436                    };
437                    let _ = stream.write_all(response.as_bytes()).await;
438                });
439            }
440        });
441        url
442    }
443
444    #[tokio::test]
445    async fn same_host_redirect_clients_refuse_redirects_to_another_host() {
446        let manager = HttpClientManager::new().unwrap();
447        let guarded = ClientOptions {
448            same_host_redirects: true,
449            ..ClientOptions::default()
450        };
451        let client = manager.client_for(&guarded).unwrap();
452        assert!(!Arc::ptr_eq(
453            &client,
454            &manager.client_for(&ClientOptions::default()).unwrap()
455        ));
456
457        let same = redirect_stub("/done".to_string()).await;
458        let response = client.get(&same).send().await.unwrap();
459        assert_eq!(response.text().await.unwrap(), "ok");
460
461        let other = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
462        let elsewhere = redirect_stub(format!(
463            "http://localhost:{}/secret",
464            other.local_addr().unwrap().port()
465        ))
466        .await;
467        let error = client.get(&elsewhere).send().await.unwrap_err();
468        assert!(error.is_redirect(), "{error}");
469        assert!(
470            tokio::time::timeout(Duration::from_millis(50), other.accept())
471                .await
472                .is_err()
473        );
474    }
475
476    #[test]
477    fn redirects_stay_on_the_host_and_never_downgrade() {
478        let url = |value: &str| Url::parse(value).unwrap();
479        assert!(stays_on_host(
480            &url("https://a.example/x"),
481            &url("https://a.example:8443/y")
482        ));
483        assert!(stays_on_host(
484            &url("http://a.example/x"),
485            &url("https://a.example/y")
486        ));
487        assert!(!stays_on_host(
488            &url("https://a.example/x"),
489            &url("http://a.example/y")
490        ));
491        assert!(!stays_on_host(
492            &url("https://a.example/x"),
493            &url("https://169.254.169.254/")
494        ));
495    }
496
497    #[cfg(target_os = "ios")]
498    #[tokio::test]
499    async fn ios_shared_clients_reject_http_in_every_cache_and_proxy_configuration() {
500        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
501        let url = format!("http://{}", listener.local_addr().unwrap());
502        let manager = HttpClientManager::new().unwrap();
503        for proxy in ["", url.as_str()] {
504            manager.set_proxy(proxy).unwrap();
505            let clients = [
506                manager.client_for(&ClientOptions::default()).unwrap(),
507                manager.client_for(&options(&url, false)).unwrap(),
508                manager.client_for(&options(&url, true)).unwrap(),
509                manager.status_client().unwrap(),
510            ];
511            for client in clients {
512                let error = client
513                    .get(&url)
514                    .header("Authorization", "token fixture")
515                    .send()
516                    .await
517                    .unwrap_err();
518                assert!(error.is_builder(), "{error}");
519            }
520        }
521        assert!(
522            tokio::time::timeout(Duration::from_millis(50), listener.accept())
523                .await
524                .is_err()
525        );
526    }
527
528    #[test]
529    fn default_options_share_the_default_client() {
530        let manager = HttpClientManager::new().unwrap();
531        let a = manager.client_for(&ClientOptions::default()).unwrap();
532        let b = manager.client_for(&ClientOptions::default()).unwrap();
533        assert!(Arc::ptr_eq(&a, &b));
534    }
535
536    #[test]
537    fn equivalent_origins_share_one_client() {
538        let manager = HttpClientManager::new().unwrap();
539        let a = manager.client_for(&options("https://h", false)).unwrap();
540        let b = manager
541            .client_for(&options("https://H:443/", false))
542            .unwrap();
543        assert!(Arc::ptr_eq(&a, &b));
544        assert!(!Arc::ptr_eq(&a, &manager.current().default_client));
545    }
546
547    #[test]
548    fn insecure_and_secure_clients_differ_for_same_origin() {
549        let manager = HttpClientManager::new().unwrap();
550        let secure = manager
551            .client_for(&options("https://argo.local", false))
552            .unwrap();
553        let insecure = manager
554            .client_for(&options("https://argo.local", true))
555            .unwrap();
556        assert!(!Arc::ptr_eq(&secure, &insecure));
557    }
558
559    #[test]
560    fn different_origins_get_different_clients() {
561        let manager = HttpClientManager::new().unwrap();
562        let a = manager
563            .client_for(&options("https://a.example", false))
564            .unwrap();
565        let b = manager
566            .client_for(&options("https://a.example:8443", false))
567            .unwrap();
568        assert!(!Arc::ptr_eq(&a, &b));
569    }
570
571    #[test]
572    fn status_client_is_cached_and_separate() {
573        let manager = HttpClientManager::new().unwrap();
574        let a = manager.status_client().unwrap();
575        let b = manager.status_client().unwrap();
576        assert!(Arc::ptr_eq(&a, &b));
577        let insecure = manager
578            .client_for(&options("https://a.example", true))
579            .unwrap();
580        assert!(!Arc::ptr_eq(&a, &insecure));
581        assert!(!Arc::ptr_eq(&a, &manager.current().default_client));
582    }
583
584    #[test]
585    fn redirect_targets_must_be_public_https() {
586        for allowed in [
587            "https://www.githubstatus.com/api/v2/summary.json",
588            "https://8.8.8.8/",
589        ] {
590            assert!(
591                is_safe_redirect_target(&Url::parse(allowed).unwrap()),
592                "{allowed}"
593            );
594        }
595        for refused in [
596            "http://www.githubstatus.com/",
597            "http://127.0.0.1:8765/",
598            "https://127.0.0.1/",
599            "https://10.1.2.3/",
600            "https://192.168.0.1/",
601            "https://169.254.169.254/",
602            "https://0.0.0.0/",
603            "https://[::1]/",
604            "https://[fe80::1]/",
605            "https://[fd00::1]/",
606            "https://[::ffff:127.0.0.1]/",
607        ] {
608            assert!(
609                !is_safe_redirect_target(&Url::parse(refused).unwrap()),
610                "{refused}"
611            );
612        }
613    }
614
615    #[cfg(not(target_os = "ios"))]
616    async fn proxy_stub() -> (String, Arc<std::sync::Mutex<Vec<String>>>) {
617        use tokio::io::{
618            AsyncReadExt,
619            AsyncWriteExt,
620        };
621
622        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
623        let url = format!("http://{}", listener.local_addr().unwrap());
624        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
625        let log = Arc::clone(&seen);
626        tokio::spawn(async move {
627            loop {
628                let (mut stream, _) = listener.accept().await.unwrap();
629                let mut buf = vec![0; 4096];
630                let n = stream.read(&mut buf).await.unwrap();
631                let head = String::from_utf8_lossy(&buf[..n]);
632                let line = head.lines().next().unwrap_or_default().to_string();
633                log.lock().unwrap().push(line);
634                let _ = stream
635                    .write_all(
636                        b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
637                    )
638                    .await;
639            }
640        });
641        (url, seen)
642    }
643
644    #[cfg(not(target_os = "ios"))]
645    #[tokio::test]
646    async fn every_client_goes_through_the_proxy_until_it_is_cleared() {
647        let (proxy, seen) = proxy_stub().await;
648        let manager = HttpClientManager::new().unwrap();
649
650        assert!(manager.set_proxy(&format!(" {proxy} ")).unwrap());
651        assert!(!manager.set_proxy(&proxy).unwrap());
652        let clients = [
653            manager.client_for(&ClientOptions::default()).unwrap(),
654            manager
655                .client_for(&options("http://argo.pipedash.test", true))
656                .unwrap(),
657            manager.status_client().unwrap(),
658        ];
659        for (i, client) in clients.iter().enumerate() {
660            let body = client
661                .get(format!("http://host{i}.pipedash.test/api"))
662                .send()
663                .await
664                .unwrap()
665                .text()
666                .await
667                .unwrap();
668            assert_eq!(body, "ok");
669        }
670
671        assert!(manager.set_proxy("").unwrap());
672        let direct = manager
673            .client_for(&ClientOptions::default())
674            .unwrap()
675            .get("http://127.0.0.1:9/never-proxied")
676            .send()
677            .await;
678
679        assert!(direct.is_err());
680        assert_eq!(
681            *seen.lock().unwrap(),
682            [
683                "GET http://host0.pipedash.test/api HTTP/1.1",
684                "GET http://host1.pipedash.test/api HTTP/1.1",
685                "GET http://host2.pipedash.test/api HTTP/1.1",
686            ]
687        );
688    }
689
690    #[test]
691    fn proxy_url_accepts_http_and_https_without_credentials() {
692        assert_eq!(parse_proxy_url("  ").unwrap(), None);
693        assert_eq!(
694            parse_proxy_url(" http://proxy.corp:3128 ")
695                .unwrap()
696                .unwrap()
697                .as_str(),
698            "http://proxy.corp:3128/"
699        );
700        assert!(parse_proxy_url("https://proxy.corp").unwrap().is_some());
701
702        for refused in [
703            "proxy.corp:3128",
704            "socks5://proxy.corp:1080",
705            "http://",
706            "not a url",
707            "http://alice@proxy.corp:3128",
708            "http://alice:secret@proxy.corp:3128",
709        ] {
710            let error = parse_proxy_url(refused).unwrap_err().to_string();
711            assert!(!error.contains("secret"), "{error}");
712            assert!(matches!(
713                parse_proxy_url(refused),
714                Err(DomainError::InvalidConfig(_))
715            ));
716        }
717    }
718}