1pub mod blocklist;
9pub mod cookies;
10pub mod csp;
11pub mod headers;
12pub mod robots;
13pub mod ssrf;
14
15use std::{collections::HashMap, sync::Arc};
16
17pub use cookies::CookieJar;
18use tokio::sync::Mutex;
19use url::Url;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum RedirectPolicy {
31 Follow(u8),
32 Manual,
33}
34
35impl RedirectPolicy {
36 #[inline]
37 pub const fn max_redirects(self) -> u8 {
38 match self {
39 Self::Follow(n) => n,
40 Self::Manual => 0,
41 }
42 }
43}
44
45#[derive(Debug, thiserror::Error)]
50pub enum NetError {
51 #[error("HTTP error: {0}")]
52 Http(String),
53
54 #[error("URL parse error: {0}")]
55 Url(#[from] url::ParseError),
56
57 #[error("Request failed: {0}")]
58 Request(String),
59
60 #[error("hpx client error: {0}")]
61 Client(#[from] hpx::Error),
62
63 #[error("SSRF protection: request to {0} is forbidden (use --allow-private-network to bypass)")]
64 Ssrf(String),
65}
66
67#[derive(Debug, Clone, Default)]
72pub struct TimingStats {
73 pub dns_start_ms: f64,
74 pub dns_end_ms: f64,
75 pub connect_start_ms: f64,
76 pub connect_end_ms: f64,
77 pub tls_start_ms: f64,
78 pub tls_end_ms: f64,
79 pub request_start_ms: f64,
80 pub response_start_ms: f64,
81 pub response_end_ms: f64,
82}
83
84#[derive(Debug, Clone, Default)]
89pub struct Response {
90 pub status: u16,
91 pub status_text: String,
92 pub headers: HashMap<String, String>,
93 pub set_cookies: Vec<String>,
96 pub body: Vec<u8>,
97 pub url: String,
98 pub accept_ch_upgrade: bool,
100 pub timings: TimingStats,
101}
102
103impl Response {
104 pub fn text(&self) -> String {
105 String::from_utf8_lossy(&self.body).to_string()
106 }
107
108 pub fn ok(&self) -> bool {
109 (200..300).contains(&self.status)
110 }
111}
112
113#[derive(Clone)]
118pub struct SharedSession {
119 pub cookies: Arc<Mutex<CookieJar>>,
120 pub accept_ch: scc::HashSet<String>,
121 pub h1_only_hosts: scc::HashSet<String>,
122}
123
124impl SharedSession {
125 pub fn new() -> Self {
126 Self {
127 cookies: Arc::new(Mutex::new(CookieJar::new())),
128 accept_ch: scc::HashSet::new(),
129 h1_only_hosts: scc::HashSet::new(),
130 }
131 }
132}
133
134impl Default for SharedSession {
135 fn default() -> Self {
136 Self::new()
137 }
138}
139
140#[derive(Clone)]
145pub struct HttpClient {
146 inner: hpx::Client,
147 cookies: Arc<Mutex<CookieJar>>,
148 accept_ch_origins: scc::HashSet<String>,
149 h1_only_hosts: scc::HashSet<String>,
150 browser_profile: hpx::BrowserProfile,
151 allow_private_network: bool,
152}
153
154impl HttpClient {
155 pub fn new(browser_profile: hpx::BrowserProfile) -> Result<Self, NetError> {
157 let session = SharedSession::new();
158 Self::with_session(Arc::new(session), browser_profile)
159 }
160
161 pub fn with_session(
163 session: Arc<SharedSession>,
164 browser_profile: hpx::BrowserProfile,
165 ) -> Result<Self, NetError> {
166 let inner = hpx::Client::builder()
167 .build()
168 .map_err(|e| NetError::Http(format!("failed to build hpx client: {e}")))?;
169
170 Ok(Self {
171 inner,
172 cookies: session.cookies.clone(),
173 accept_ch_origins: session.accept_ch.clone(),
174 h1_only_hosts: session.h1_only_hosts.clone(),
175 browser_profile,
176 allow_private_network: false,
177 })
178 }
179
180 pub fn cookies(&self) -> Arc<Mutex<CookieJar>> {
181 self.cookies.clone()
182 }
183
184 pub fn browser_profile(&self) -> &hpx::BrowserProfile {
185 &self.browser_profile
186 }
187
188 pub fn allow_private_network(mut self, allow: bool) -> Self {
190 self.allow_private_network = allow;
191 self
192 }
193
194 pub fn has_accept_ch(&self, host: &str) -> bool {
196 self.accept_ch_origins.contains_sync(host)
197 }
198
199 fn learn_accept_ch(&self, host: &str, headers: &HashMap<String, String>) -> bool {
202 let has_ch = headers.keys().any(|k| {
203 let k = k.to_ascii_lowercase();
204 k == "accept-ch" || k == "critical-ch"
205 });
206 if has_ch {
207 return self.accept_ch_origins.insert_sync(host.to_string()).is_ok();
208 }
209 false
210 }
211
212 pub async fn cookies_for_url(&self, url: &Url) -> Option<String> {
214 let jar = self.cookies.lock().await;
215 jar.cookies_for(url)
216 }
217
218 pub async fn inject_cookies(&self, url: &Url, cookies: &[String]) {
220 let mut jar = self.cookies.lock().await;
221 jar.set_cookies(url, cookies);
222 }
223
224 pub async fn set_cookie_str(&self, url: &Url, raw: &str) {
226 let mut jar = self.cookies.lock().await;
227 jar.set_cookies(url, &[raw.to_string()]);
228 }
229
230 pub async fn clear_cookies_for_domain(&self, target_domain: &str) {
232 let mut jar = self.cookies.lock().await;
233 jar.clear_for_domain(target_domain);
234 }
235
236 #[deprecated(note = "Use HttpClient::request() instead")]
240 pub async fn get(&self, url: &str) -> Result<Response, NetError> {
241 self.request("GET", url, None, &[], RedirectPolicy::Manual)
242 .await
243 }
244
245 #[deprecated(note = "Use HttpClient::request() instead")]
247 pub async fn get_with_headers(
248 &self,
249 url: &str,
250 extra_headers: &[(String, String)],
251 ) -> Result<Response, NetError> {
252 self.request("GET", url, None, extra_headers, RedirectPolicy::Manual)
253 .await
254 }
255
256 #[deprecated(note = "Use HttpClient::request() with explicit headers instead")]
258 pub async fn fetch_get(
259 &self,
260 url: &str,
261 extra_headers: &[(String, String)],
262 _origin: Option<&str>,
263 ) -> Result<Response, NetError> {
264 let mut headers = extra_headers.to_vec();
265 headers.push(("accept".to_string(), "*/*".to_string()));
266 headers.push(("sec-fetch-mode".to_string(), "cors".to_string()));
267 headers.push(("sec-fetch-dest".to_string(), "empty".to_string()));
268 headers.push(("sec-fetch-site".to_string(), "same-origin".to_string()));
269
270 self.request("GET", url, None, &headers, RedirectPolicy::Manual)
271 .await
272 }
273
274 #[deprecated(note = "Use HttpClient::request() with explicit headers instead")]
276 pub async fn fetch_post_bytes(
277 &self,
278 url: &str,
279 body: &[u8],
280 extra_headers: &[(String, String)],
281 _origin: Option<&str>,
282 ) -> Result<Response, NetError> {
283 let mut headers = extra_headers.to_vec();
284 headers.push(("accept".to_string(), "*/*".to_string()));
285 headers.push(("sec-fetch-mode".to_string(), "cors".to_string()));
286 headers.push(("sec-fetch-dest".to_string(), "empty".to_string()));
287 headers.push(("sec-fetch-site".to_string(), "same-origin".to_string()));
288
289 self.request("POST", url, Some(body), &headers, RedirectPolicy::Manual)
290 .await
291 }
292
293 #[deprecated(note = "Use HttpClient::request() instead")]
295 pub async fn post(&self, url: &str, body: &str) -> Result<Response, NetError> {
296 self.request(
297 "POST",
298 url,
299 Some(body.as_bytes()),
300 &[],
301 RedirectPolicy::Manual,
302 )
303 .await
304 }
305
306 #[deprecated(note = "Use HttpClient::request() instead")]
308 pub async fn post_with_headers(
309 &self,
310 url: &str,
311 body: &str,
312 extra_headers: &[(String, String)],
313 ) -> Result<Response, NetError> {
314 self.request(
315 "POST",
316 url,
317 Some(body.as_bytes()),
318 extra_headers,
319 RedirectPolicy::Manual,
320 )
321 .await
322 }
323
324 #[deprecated(note = "Use HttpClient::request() instead")]
326 pub async fn post_bytes_with_headers(
327 &self,
328 url: &str,
329 body: &[u8],
330 extra_headers: &[(String, String)],
331 ) -> Result<Response, NetError> {
332 self.request(
333 "POST",
334 url,
335 Some(body),
336 extra_headers,
337 RedirectPolicy::Manual,
338 )
339 .await
340 }
341
342 #[deprecated(note = "Use HttpClient::request() with RedirectPolicy::Follow(n) instead")]
344 pub async fn get_follow(&self, url: &str, max_redirects: u8) -> Result<Response, NetError> {
345 self.request("GET", url, None, &[], RedirectPolicy::Follow(max_redirects))
346 .await
347 }
348
349 #[deprecated(note = "Use HttpClient::request() with RedirectPolicy::Follow(n) instead")]
351 pub async fn get_follow_with_headers(
352 &self,
353 url: &str,
354 extra_headers: &[(String, String)],
355 max_redirects: u8,
356 ) -> Result<Response, NetError> {
357 self.request(
358 "GET",
359 url,
360 None,
361 extra_headers,
362 RedirectPolicy::Follow(max_redirects),
363 )
364 .await
365 }
366
367 #[deprecated(note = "Use HttpClient::request() with RedirectPolicy::Follow(n) instead")]
369 pub async fn post_follow(
370 &self,
371 url: &str,
372 body: &str,
373 max_redirects: u8,
374 ) -> Result<Response, NetError> {
375 self.request(
376 "POST",
377 url,
378 Some(body.as_bytes()),
379 &[],
380 RedirectPolicy::Follow(max_redirects),
381 )
382 .await
383 }
384
385 #[deprecated(note = "Use HttpClient::request() with RedirectPolicy::Follow(n) instead")]
387 pub async fn post_bytes_follow(
388 &self,
389 url: &str,
390 body: &[u8],
391 extra_headers: &[(String, String)],
392 max_redirects: u8,
393 ) -> Result<Response, NetError> {
394 self.request(
395 "POST",
396 url,
397 Some(body),
398 extra_headers,
399 RedirectPolicy::Follow(max_redirects),
400 )
401 .await
402 }
403
404 pub async fn preconnect(&self, url: &str) -> Result<(), NetError> {
407 let _ = self
411 .inner
412 .head(url)
413 .emulation(self.browser_profile)
414 .send()
415 .await;
416 Ok(())
417 }
418
419 pub async fn request(
425 &self,
426 method: &str,
427 url: &str,
428 body: Option<&[u8]>,
429 extra_headers: &[(String, String)],
430 policy: RedirectPolicy,
431 ) -> Result<Response, NetError> {
432 let mut current_url = url.to_string();
433 let mut current_method = method.to_string();
434 let mut current_body = body.map(<[u8]>::to_vec);
435 let max_redirects = policy.max_redirects();
436 let mut remaining = max_redirects;
437
438 loop {
439 let parsed_current = Url::parse(¤t_url)?;
440 let hpx_resp = self
441 .execute_single_request(
442 ¤t_method,
443 ¤t_url,
444 current_body.as_deref(),
445 extra_headers,
446 )
447 .await?;
448
449 let resp = self
450 .process_response(hpx_resp, ¤t_url, &parsed_current)
451 .await?;
452
453 if !matches!(resp.status, 301 | 302 | 303 | 307 | 308) {
454 return Ok(resp);
455 }
456
457 match policy {
458 RedirectPolicy::Manual => return Ok(resp),
459 RedirectPolicy::Follow(_) => {
460 let loc = resp.headers.get("location").ok_or_else(|| {
461 NetError::Request("redirect missing Location header".into())
462 })?;
463 let next_url = resolve_redirect(¤t_url, loc)?;
464
465 if current_method == "POST" && matches!(resp.status, 301..=303) {
467 current_method = "GET".to_string();
468 current_body = None;
469 }
470
471 if remaining == 0 {
472 return Ok(resp);
473 }
474 remaining -= 1;
475 current_url = next_url;
476 }
477 }
478 }
479 }
480
481 async fn execute_single_request(
483 &self,
484 method: &str,
485 url: &str,
486 body: Option<&[u8]>,
487 extra_headers: &[(String, String)],
488 ) -> Result<hpx::Response, NetError> {
489 let parsed = Url::parse(url)?;
490
491 if !self.allow_private_network {
494 if let Some(host) = parsed.host_str() {
495 if crate::net::ssrf::is_forbidden_resolved(host).await {
496 return Err(NetError::Ssrf(host.to_string()));
497 }
498 }
499 }
500
501 let builder = match method {
502 "GET" | "HEAD" => self.inner.get(url),
503 "POST" => self.inner.post(url),
504 "PUT" => self.inner.put(url),
505 "PATCH" => self.inner.patch(url),
506 "DELETE" => self.inner.delete(url),
507 _ => {
508 return Err(NetError::Request(format!(
509 "unsupported HTTP method: {method}"
510 )));
511 }
512 }
513 .emulation(self.browser_profile);
514
515 let builder = self
516 .inject_request_headers(builder, &parsed, extra_headers)
517 .await;
518
519 let builder = if let Some(b) = body {
520 builder.body(b.to_vec())
521 } else {
522 builder
523 };
524
525 builder.send().await.map_err(|e| e.into())
526 }
527
528 async fn inject_request_headers(
532 &self,
533 mut builder: hpx::RequestBuilder,
534 parsed: &Url,
535 extra_headers: &[(String, String)],
536 ) -> hpx::RequestBuilder {
537 let cookie_str = {
538 let jar = self.cookies.lock().await;
539 jar.cookies_for(parsed)
540 };
541
542 if let Some(cs) = cookie_str {
543 builder = builder.header("cookie", cs);
544 }
545
546 for (k, v) in extra_headers {
547 if k.eq_ignore_ascii_case("host") || k.eq_ignore_ascii_case("connection") {
548 continue;
549 }
550 builder = builder.header(k.as_str(), v.as_str());
551 }
552
553 builder
554 }
555
556 async fn process_response(
558 &self,
559 hpx_resp: hpx::Response,
560 url: &str,
561 parsed: &Url,
562 ) -> Result<Response, NetError> {
563 let status = hpx_resp.status().as_u16();
564 let status_text = hpx_resp
565 .status()
566 .canonical_reason()
567 .unwrap_or("")
568 .to_string();
569
570 let mut headers = HashMap::new();
571 let mut set_cookies = Vec::new();
572
573 for (key, value) in hpx_resp.headers() {
574 if let Ok(v) = value.to_str() {
575 if key.as_str().eq_ignore_ascii_case("set-cookie") {
576 set_cookies.push(v.to_string());
577 } else {
578 headers.insert(key.to_string(), v.to_string());
579 }
580 }
581 }
582
583 let body = hpx_resp
584 .bytes()
585 .await
586 .map_err(|e| NetError::Http(format!("failed to read body: {e}")))?;
587
588 let host = parsed.host_str().unwrap_or("");
590 let upgrade = self.learn_accept_ch(host, &headers);
591
592 if !set_cookies.is_empty() {
594 let mut jar = self.cookies.lock().await;
595 jar.set_cookies(parsed, &set_cookies);
596 }
597
598 Ok(Response {
599 status,
600 status_text,
601 headers,
602 set_cookies,
603 body: body.to_vec(),
604 url: url.to_string(),
605 accept_ch_upgrade: upgrade,
606 timings: TimingStats::default(),
607 })
608 }
609}
610
611fn resolve_redirect(current_url: &str, location: &str) -> Result<String, NetError> {
617 let base = Url::parse(current_url).map_err(|e| NetError::Request(e.to_string()))?;
618 let resolved = base.join(location).map_err(|e| {
619 NetError::Request(format!(
620 "redirect resolve: {e} (base={current_url}, loc={location})"
621 ))
622 })?;
623 Ok(resolved.to_string())
624}
625
626#[cfg(test)]
631#[allow(deprecated)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn client_creates_successfully() {
637 let client = HttpClient::new(hpx::BrowserProfile::Chrome);
638 assert!(client.is_ok());
639 }
640
641 #[test]
642 fn with_session_creates_successfully() {
643 let session = Arc::new(SharedSession::new());
644 let client = HttpClient::with_session(session, hpx::BrowserProfile::Chrome);
645 assert!(client.is_ok());
646 }
647
648 #[test]
649 fn shared_session_new_isolation() {
650 let s1 = SharedSession::new();
651 let s2 = SharedSession::new();
652 assert!(!Arc::ptr_eq(&s1.cookies, &s2.cookies));
653 }
654
655 #[test]
656 fn shared_session_default() {
657 let s: SharedSession = Default::default();
658 let rt = tokio::runtime::Runtime::new().unwrap();
660 let cookies = rt.block_on(async { s.cookies.lock().await.cookie_count() });
661 assert_eq!(cookies, 0);
662 }
663
664 #[test]
665 fn redirect_resolve_handles_rfc3986_cases() {
666 assert_eq!(
668 resolve_redirect("https://a.com/x", "https://b.com/y").unwrap(),
669 "https://b.com/y"
670 );
671 assert_eq!(
673 resolve_redirect("https://a.com/x/y", "/z").unwrap(),
674 "https://a.com/z"
675 );
676 assert_eq!(
678 resolve_redirect("https://a.com/x/y", "z.html").unwrap(),
679 "https://a.com/x/z.html"
680 );
681 assert_eq!(
683 resolve_redirect("https://a.com/x/y/", "../z.html").unwrap(),
684 "https://a.com/x/z.html"
685 );
686 assert_eq!(
688 resolve_redirect("https://a.com/x", "//b.com/y").unwrap(),
689 "https://b.com/y"
690 );
691 assert_eq!(
693 resolve_redirect("https://a.com/x?old=1", "?new=2").unwrap(),
694 "https://a.com/x?new=2"
695 );
696 }
697
698 #[test]
699 fn response_text_and_ok() {
700 let resp = Response {
701 status: 200,
702 status_text: "OK".into(),
703 headers: HashMap::new(),
704 set_cookies: Vec::new(),
705 body: b"Hello world".to_vec(),
706 url: "https://example.com".into(),
707 accept_ch_upgrade: false,
708 timings: TimingStats::default(),
709 };
710 assert_eq!(resp.text(), "Hello world");
711 assert!(resp.ok());
712 }
713
714 #[test]
715 fn response_not_ok() {
716 let resp = Response {
717 status: 404,
718 status_text: "Not Found".into(),
719 headers: HashMap::new(),
720 set_cookies: Vec::new(),
721 body: vec![],
722 url: "https://example.com/missing".into(),
723 accept_ch_upgrade: false,
724 timings: TimingStats::default(),
725 };
726 assert!(!resp.ok());
727 }
728
729 #[test]
730 fn cookie_jar_set_and_get() {
731 let mut jar = CookieJar::new();
732 let url = Url::parse("https://example.com/path").unwrap();
733 jar.set_cookies(&url, &["session=abc123; Path=/; Secure".to_string()]);
734 assert_eq!(jar.cookie_count(), 1);
735 let cookies = jar.cookies_for(&url);
736 assert_eq!(cookies, Some("session=abc123".to_string()));
737 }
738
739 #[test]
740 fn cookie_jar_domain_scope() {
741 let mut jar = CookieJar::new();
742 let url = Url::parse("https://sub.example.com").unwrap();
743 jar.set_cookies(&url, &["token=xyz; Domain=example.com".to_string()]);
744 assert_eq!(jar.cookie_count(), 1);
746 let cookies = jar.cookies_for(&url);
747 assert!(cookies.is_some());
748 assert!(cookies.unwrap().contains("token=xyz"));
749 }
750
751 #[test]
752 fn cookie_jar_cross_domain_reject() {
753 let mut jar = CookieJar::new();
754 let url = Url::parse("https://example.com").unwrap();
755 jar.set_cookies(&url, &["evil=hack; Domain=evil.com".to_string()]);
756 assert_eq!(jar.cookie_count(), 0);
757 }
758
759 #[test]
760 fn cookie_jar_clear_for_domain() {
761 let mut jar = CookieJar::new();
762 let url = Url::parse("https://example.com").unwrap();
763 jar.set_cookies(&url, &["a=1".to_string(), "b=2".to_string()]);
764 assert_eq!(jar.cookie_count(), 2);
765 jar.clear_for_domain("example.com");
766 assert_eq!(jar.cookie_count(), 0);
767 }
768
769 #[test]
770 fn accept_ch_starts_false_then_true() {
771 let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
772 assert!(!client.has_accept_ch("example.com"));
773
774 let mut headers = HashMap::new();
775 headers.insert(
776 "accept-ch".to_string(),
777 "Sec-CH-UA-Full-Version-List".to_string(),
778 );
779 client.learn_accept_ch("example.com", &headers);
780
781 assert!(client.has_accept_ch("example.com"));
782 assert!(!client.has_accept_ch("other.com"));
783 }
784
785 #[test]
786 fn accept_ch_case_insensitive() {
787 let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
788 let mut headers = HashMap::new();
789 headers.insert("Accept-CH".to_string(), "Sec-CH-UA-Arch".to_string());
790 client.learn_accept_ch("site.example", &headers);
791 assert!(client.has_accept_ch("site.example"));
792 }
793
794 #[test]
795 fn response_without_accept_ch_does_not_upgrade() {
796 let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
797 let mut headers = HashMap::new();
798 headers.insert("content-type".to_string(), "text/html".to_string());
799 client.learn_accept_ch("boring.example", &headers);
800 assert!(!client.has_accept_ch("boring.example"));
801 }
802
803 #[tokio::test]
804 #[ignore] async fn get_request() {
806 let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
807 let resp = client.get("https://httpbin.org/get").await.unwrap();
808 assert_eq!(resp.status, 200);
809 assert!(resp.text().contains("httpbin"));
810 }
811
812 #[tokio::test]
813 #[ignore] async fn post_request() {
815 let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
816 let resp = client
817 .post("https://httpbin.org/post", "hello")
818 .await
819 .unwrap();
820 assert_eq!(resp.status, 200);
821 assert!(resp.text().contains("hello"));
822 }
823
824 #[tokio::test]
825 #[ignore] async fn get_follow_redirects() {
827 let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
828 let resp = client
829 .get_follow("https://httpbin.org/redirect/2", 5)
830 .await
831 .unwrap();
832 assert_eq!(resp.status, 200);
833 }
834
835 #[test]
836 fn redirect_policy_max_redirects() {
837 assert_eq!(RedirectPolicy::Follow(5).max_redirects(), 5);
838 assert_eq!(RedirectPolicy::Follow(0).max_redirects(), 0);
839 assert_eq!(RedirectPolicy::Manual.max_redirects(), 0);
840 }
841
842 #[test]
843 fn redirect_policy_clone_copy() {
844 let p = RedirectPolicy::Follow(3);
845 let q = p;
846 assert_eq!(p, q); }
848
849 #[tokio::test]
852 async fn ssrf_blocks_loopback_by_default() {
853 let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
854 let result = client
855 .execute_single_request("GET", "http://127.0.0.1", None, &[])
856 .await;
857 assert!(result.is_err());
858 let err = result.unwrap_err().to_string();
859 assert!(
860 err.contains("127.0.0.1"),
861 "error should mention the blocked host, got: {err}"
862 );
863 assert!(
864 err.contains("--allow-private-network"),
865 "error should mention --allow-private-network bypass, got: {err}"
866 );
867 }
868
869 #[tokio::test]
870 async fn ssrf_allows_private_network_when_enabled() {
871 let client = HttpClient::new(hpx::BrowserProfile::Chrome)
872 .unwrap()
873 .allow_private_network(true);
874 let result = client
876 .execute_single_request("GET", "http://127.0.0.1", None, &[])
877 .await;
878 match result {
879 Ok(_) => {}
880 Err(e) => {
881 let msg = e.to_string();
882 assert!(
883 !msg.contains("SSRF"),
884 "should not be SSRF rejection, got: {msg}"
885 );
886 }
887 }
888 }
889
890 #[tokio::test]
891 async fn ssrf_allows_public_ip() {
892 let client = HttpClient::new(hpx::BrowserProfile::Chrome).unwrap();
893 let result = client
896 .execute_single_request("GET", "http://93.184.216.34", None, &[])
897 .await;
898 match result {
899 Ok(_) => {}
900 Err(e) => {
901 let msg = e.to_string();
902 assert!(
903 !msg.contains("SSRF"),
904 "public IP should not trigger SSRF, got: {msg}"
905 );
906 }
907 }
908 }
909}