Skip to main content

hpx_browser/net/
mod.rs

1//! Stealth HTTP client with cookie management, Accept-CH tracking, and
2//! redirect following.
3//!
4//! Wraps `hpx::Client` as the underlying HTTP/1.1 + HTTP/2 transport with
5//! BoringSSL TLS and browser-profile emulation. Higher-level browser
6//! session concerns (cookies, Client Hints, H1-only host memory) live here.
7
8pub 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// ---------------------------------------------------------------------------
22// Error
23// ---------------------------------------------------------------------------
24
25// ---------------------------------------------------------------------------
26// RedirectPolicy — controls automatic redirect following behaviour.
27// ---------------------------------------------------------------------------
28
29#[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// ---------------------------------------------------------------------------
46// Error
47// ---------------------------------------------------------------------------
48
49#[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// ---------------------------------------------------------------------------
68// TimingStats
69// ---------------------------------------------------------------------------
70
71#[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// ---------------------------------------------------------------------------
85// Response
86// ---------------------------------------------------------------------------
87
88#[derive(Debug, Clone, Default)]
89pub struct Response {
90    pub status: u16,
91    pub status_text: String,
92    pub headers: HashMap<String, String>,
93    /// All Set-Cookie header values, preserved separately because HTTP
94    /// responses can contain multiple Set-Cookie headers.
95    pub set_cookies: Vec<String>,
96    pub body: Vec<u8>,
97    pub url: String,
98    /// Whether this response taught the client Accept-CH for the first time.
99    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// ---------------------------------------------------------------------------
114// SharedSession — process-wide cookie jar + Accept-CH origins
115// ---------------------------------------------------------------------------
116
117#[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// ---------------------------------------------------------------------------
141// HttpClient
142// ---------------------------------------------------------------------------
143
144#[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    /// Create a new client with an isolated session and the given browser profile.
156    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    /// Build a client that participates in a provided shared session.
162    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    /// Allow requests to private/internal IP addresses (disabled by default).
189    pub fn allow_private_network(mut self, allow: bool) -> Self {
190        self.allow_private_network = allow;
191        self
192    }
193
194    /// Whether `host` has previously sent `Accept-CH`.
195    pub fn has_accept_ch(&self, host: &str) -> bool {
196        self.accept_ch_origins.contains_sync(host)
197    }
198
199    /// Learn Accept-CH from response headers. Returns `true` if this is a
200    /// new origin that just opted in.
201    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    /// Snapshot all cookies for a URL.
213    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    /// Inject cookies from external sources (e.g., JS `document.cookie`).
219    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    /// Set a single cookie from a raw Set-Cookie-style string.
225    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    /// Drop all cookies matching `target_domain`.
231    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    // ----- Request methods -----
237
238    /// Perform a GET request.
239    #[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    /// GET with extra headers.
246    #[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    /// Fetch-API-style GET with `accept: */*` semantics.
257    #[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    /// Fetch-API-style POST with raw bytes.
275    #[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    /// Perform a POST request with a string body.
294    #[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    /// POST with extra headers.
307    #[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    /// POST with raw bytes and extra headers.
325    #[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    /// GET with explicit redirect following.
343    #[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    /// GET with extra headers and redirect following.
350    #[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    /// POST with redirect following. 307/308 preserve the body.
368    #[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    /// POST with raw bytes and redirect following.
386    #[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    /// Pre-establish a connection to a host. hpx handles connection pooling
405    /// internally, so this is a lightweight GET that warms the pool.
406    pub async fn preconnect(&self, url: &str) -> Result<(), NetError> {
407        // ponytail: hpx manages its own pool; a HEAD is the cheapest way to
408        // establish a connection. If hpx ever exposes a dedicated preconnect,
409        // switch to that.
410        let _ = self
411            .inner
412            .head(url)
413            .emulation(self.browser_profile)
414            .send()
415            .await;
416        Ok(())
417    }
418
419    /// Unified request dispatch — single entry point for all HTTP verbs.
420    ///
421    /// `method`: HTTP verb string (e.g. "GET", "POST"). `url`: target.
422    /// `body`: optional request body. `extra_headers`: appended after cookies.
423    /// `policy`: redirect following behaviour.
424    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(&current_url)?;
440            let hpx_resp = self
441                .execute_single_request(
442                    &current_method,
443                    &current_url,
444                    current_body.as_deref(),
445                    extra_headers,
446                )
447                .await?;
448
449            let resp = self
450                .process_response(hpx_resp, &current_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(&current_url, loc)?;
464
465                    // 301/302/303 on POST → switch to GET (no body)
466                    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    /// Execute a single non-redirecting request.
482    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        // SSRF check — reject private/special-use IPs unless explicitly allowed.
492        // Uses DNS resolution to prevent DNS rebinding attacks.
493        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    // ----- Internal helpers -----
529
530    /// Inject cookies and extra headers into a request builder.
531    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    /// Convert an hpx Response into our Response type.
557    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        // Learn Accept-CH
589        let host = parsed.host_str().unwrap_or("");
590        let upgrade = self.learn_accept_ch(host, &headers);
591
592        // Store Set-Cookie
593        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
611// ---------------------------------------------------------------------------
612// Helpers
613// ---------------------------------------------------------------------------
614
615/// Resolve a redirect Location header to an absolute URL.
616fn 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// ---------------------------------------------------------------------------
627// Tests
628// ---------------------------------------------------------------------------
629
630#[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        // Default-constructed session has empty cookie jar
659        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        // Absolute
667        assert_eq!(
668            resolve_redirect("https://a.com/x", "https://b.com/y").unwrap(),
669            "https://b.com/y"
670        );
671        // Root-relative
672        assert_eq!(
673            resolve_redirect("https://a.com/x/y", "/z").unwrap(),
674            "https://a.com/z"
675        );
676        // Relative
677        assert_eq!(
678            resolve_redirect("https://a.com/x/y", "z.html").unwrap(),
679            "https://a.com/x/z.html"
680        );
681        // Dot segments
682        assert_eq!(
683            resolve_redirect("https://a.com/x/y/", "../z.html").unwrap(),
684            "https://a.com/x/z.html"
685        );
686        // Scheme-relative
687        assert_eq!(
688            resolve_redirect("https://a.com/x", "//b.com/y").unwrap(),
689            "https://b.com/y"
690        );
691        // Query-only
692        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        // Parent domain cookie visible on subdomain
745        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] // requires network
805    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] // requires network
814    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] // requires network
826    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); // Copy semantics — both still usable
847    }
848
849    // --- SSRF integration tests ---
850
851    #[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        // This will fail with a connection error (no server listening), NOT an SSRF error
875        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        // 93.184.216.34 is example.com — real public IP; may fail with
894        // connection error in CI but must NOT be SSRF rejection.
895        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}