Skip to main content

runtime_foxdriver/
cookies.rs

1//! Captured browser cookies — preserve a solved-captcha session
2//! across page loads.
3//!
4//! When a captcha is solved, the upstream WAF / vendor typically
5//! issues one or more cookies (`cf_clearance`, `_pxhd`, `datadome`,
6//! etc.) that grant the browser a window of trusted access. Without
7//! capturing + replaying these cookies, every navigation re-triggers
8//! the captcha challenge.
9//!
10//! [`capture_from_page`] grabs every cookie from the live page after
11//! a successful solve. [`apply_to_page`] re-installs them on a fresh
12//! page so the next request rides the trusted session.
13//!
14//! The capture path uses WebDriver BiDi `storage.getCookies`; the apply
15//! path uses `storage.setCookie`. Both are wrapped here so consumers
16//! don't need to import rustenium BiDi storage types directly.
17use serde::{Deserialize, Serialize};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20/// A single captured browser cookie. Fields mirror the subset of
21/// `Network.Cookie` that's relevant for replay.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct CapturedCookie {
24    pub name: String,
25    pub value: String,
26    /// Cookie domain — leading-dot form preserved as-is.
27    pub domain: String,
28    pub path: String,
29    /// Unix epoch seconds; `None` for session cookies that expire
30    /// when the browser closes.
31    pub expires: Option<i64>,
32    pub secure: bool,
33    pub http_only: bool,
34    /// Cookie SameSite attribute as a lowercase string ("strict" /
35    /// "lax" / "none"); `None` if unset.
36    pub same_site: Option<String>,
37}
38
39impl CapturedCookie {
40    /// True iff the cookie has an explicit expiry that has already
41    /// passed. Session cookies (no expiry) are NOT considered
42    /// expired by this helper — caller decides whether to replay
43    /// them across browser restarts.
44    pub fn is_expired_now(&self) -> bool {
45        let Some(exp) = self.expires else {
46            return false;
47        };
48        let now = SystemTime::now()
49            .duration_since(UNIX_EPOCH)
50            .map(|d| d.as_secs() as i64)
51            .unwrap_or(0);
52        exp <= now
53    }
54
55    /// Drop session cookies (no expiry) AND already-expired cookies
56    /// from a slice. The remaining cookies are safe to persist
57    /// across browser restarts and apply later.
58    pub fn keep_persistent_alive(input: &[CapturedCookie]) -> Vec<CapturedCookie> {
59        input
60            .iter()
61            .filter(|c| c.expires.is_some() && !c.is_expired_now())
62            .cloned()
63            .collect()
64    }
65}
66
67/// Capture every cookie on `page` via BiDi `storage.getCookies`.
68/// Includes HttpOnly cookies — no JavaScript limitation.
69pub async fn capture_from_page(
70    page: &crate::browser::Page,
71) -> anyhow::Result<Vec<CapturedCookie>> {
72    page.get_cookies().await
73}
74
75/// Apply previously-[`capture_from_page`]-captured cookies to a
76/// fresh `page` via BiDi `storage.setCookie`. Skips entries that
77/// have already expired; returns the count of cookies actually
78/// installed.
79pub async fn apply_to_page(
80    page: &crate::browser::Page,
81    cookies: &[CapturedCookie],
82) -> anyhow::Result<usize> {
83    let now = SystemTime::now()
84        .duration_since(UNIX_EPOCH)
85        .map(|d| d.as_secs() as i64)
86        .unwrap_or(0);
87
88    let mut installed = 0usize;
89    for c in cookies {
90        if let Some(exp) = c.expires {
91            if exp <= now {
92                continue; // skip expired
93            }
94        }
95        let same_site = c.same_site.as_ref().and_then(|s| match s.as_str() {
96            "strict" => Some(rustenium_bidi_definitions::network::types::SameSite::Strict),
97            "lax" => Some(rustenium_bidi_definitions::network::types::SameSite::Lax),
98            "none" => Some(rustenium_bidi_definitions::network::types::SameSite::None),
99            _ => None,
100        });
101        page.set_cookie(
102            &c.name,
103            &c.value,
104            &c.domain,
105            Some(&c.path),
106            c.expires.map(|e| e as u64),
107            Some(c.secure),
108            Some(c.http_only),
109            same_site,
110        )
111        .await?;
112        installed += 1;
113    }
114    Ok(installed)
115}
116
117/// Filter `cookies` to only those whose name matches one of `vendor`'s
118/// known anti-bot tokens. Useful for trimming a full session capture
119/// down to the minimum subset that proves a vendor's challenge passed
120/// — handy when you want to forward auth state to a non-browser HTTP
121/// client (curl/reqwest) without leaking unrelated session data.
122///
123/// The vendor → cookie-name map is derived from the bundled rule
124/// pack's `cookie_names` triggers.
125pub fn vendor_cookies(input: &[CapturedCookie], vendor: &str) -> Vec<CapturedCookie> {
126    let names: &[&str] = match vendor.to_lowercase().as_str() {
127        "cloudflare" | "cf" => &["__cf_bm", "cf_chl_2", "cf_clearance"],
128        "akamai" => &["_abck", "bm_sz", "ak_bmsc"],
129        "datadome" => &["datadome", "_dd_s"],
130        "perimeterx" | "human" => &["_px2", "_pxhd", "_px3", "_pxvid"],
131        "incapsula" | "imperva" => &["visid_incap", "incap_ses"],
132        "kasada" => &["KP_UIDz", "x-kpsdk-cd", "x-kpsdk-ct"],
133        "fastly" => &["_fastly_ngwaf"],
134        "sucuri" => &["sucuri_cloudproxy_uuid"],
135        "anubis" => &["anubis-auth"],
136        _ => return Vec::new(),
137    };
138    input
139        .iter()
140        .filter(|c| names.iter().any(|n| c.name.starts_with(*n) || c.name == *n))
141        .cloned()
142        .collect()
143}
144
145#[cfg(test)]
146mod vendor_cookie_tests {
147    use super::*;
148
149    fn ck(name: &str) -> CapturedCookie {
150        CapturedCookie {
151            name: name.into(),
152            value: "test".into(),
153            domain: ".example.com".into(),
154            path: "/".into(),
155            expires: None,
156            secure: false,
157            http_only: false,
158            same_site: None,
159        }
160    }
161
162    #[test]
163    fn vendor_cookies_filters_cloudflare_set() {
164        let all = vec![
165            ck("__cf_bm"),
166            ck("cf_clearance"),
167            ck("session_id"),
168            ck("_ga"),
169        ];
170        let filtered = vendor_cookies(&all, "cloudflare");
171        let names: Vec<&str> = filtered.iter().map(|c| c.name.as_str()).collect();
172        assert!(names.contains(&"__cf_bm"));
173        assert!(names.contains(&"cf_clearance"));
174        assert!(!names.contains(&"_ga"));
175    }
176
177    #[test]
178    fn vendor_cookies_matches_prefixed_cookie_names() {
179        // Imperva uses dynamic cookie suffixes like `visid_incap_<n>`.
180        let all = vec![ck("visid_incap_12345"), ck("incap_ses_99_99")];
181        let filtered = vendor_cookies(&all, "imperva");
182        assert_eq!(filtered.len(), 2);
183    }
184
185    #[test]
186    fn vendor_cookies_unknown_vendor_returns_empty() {
187        let all = vec![ck("__cf_bm")];
188        let filtered = vendor_cookies(&all, "totally-not-a-vendor");
189        assert!(filtered.is_empty());
190    }
191
192    #[test]
193    fn vendor_cookies_is_case_insensitive_on_vendor() {
194        let all = vec![ck("__cf_bm")];
195        assert_eq!(vendor_cookies(&all, "Cloudflare").len(), 1);
196        assert_eq!(vendor_cookies(&all, "CLOUDFLARE").len(), 1);
197        assert_eq!(vendor_cookies(&all, "cf").len(), 1);
198    }
199
200    #[test]
201    fn vendor_cookies_akamai_set() {
202        let all = vec![ck("_abck"), ck("bm_sz"), ck("ak_bmsc"), ck("other")];
203        let filtered = vendor_cookies(&all, "akamai");
204        assert_eq!(filtered.len(), 3);
205    }
206
207    #[test]
208    fn vendor_cookies_datadome_set() {
209        let all = vec![ck("datadome"), ck("_dd_s"), ck("session")];
210        let filtered = vendor_cookies(&all, "datadome");
211        assert_eq!(filtered.len(), 2);
212    }
213
214    #[test]
215    fn vendor_cookies_perimeterx_aliases() {
216        let all = vec![ck("_px2"), ck("_pxhd"), ck("_px3")];
217        assert_eq!(vendor_cookies(&all, "perimeterx").len(), 3);
218        assert_eq!(vendor_cookies(&all, "human").len(), 3);
219    }
220
221    #[test]
222    fn vendor_cookies_kasada_set() {
223        let all = vec![ck("KP_UIDz"), ck("x-kpsdk-cd")];
224        assert_eq!(vendor_cookies(&all, "kasada").len(), 2);
225    }
226
227    #[test]
228    fn vendor_cookies_fastly_set() {
229        let all = vec![ck("_fastly_ngwaf"), ck("other")];
230        assert_eq!(vendor_cookies(&all, "fastly").len(), 1);
231    }
232
233    #[test]
234    fn vendor_cookies_sucuri_set() {
235        let all = vec![ck("sucuri_cloudproxy_uuid")];
236        assert_eq!(vendor_cookies(&all, "sucuri").len(), 1);
237    }
238
239    #[test]
240    fn vendor_cookies_anubis_set() {
241        let all = vec![ck("anubis-auth")];
242        assert_eq!(vendor_cookies(&all, "anubis").len(), 1);
243    }
244
245    #[test]
246    fn vendor_cookies_empty_input_returns_empty() {
247        assert!(vendor_cookies(&[], "cloudflare").is_empty());
248    }
249
250    #[test]
251    fn vendor_cookies_prefix_match_dynamic_suffix() {
252        // Imperva cookies have dynamic suffixes; the vendor rule uses
253        // `starts_with` so `visid_incap_` matches `visid_incap_12345`.
254        let all = vec![ck("visid_incap_12345"), ck("visid_incap_99999")];
255        let filtered = vendor_cookies(&all, "imperva");
256        assert_eq!(filtered.len(), 2);
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn cookie(name: &str, expires: Option<i64>) -> CapturedCookie {
265        CapturedCookie {
266            name: name.into(),
267            value: "v".into(),
268            domain: ".example.com".into(),
269            path: "/".into(),
270            expires,
271            secure: true,
272            http_only: false,
273            same_site: None,
274        }
275    }
276
277    #[test]
278    fn is_expired_now_true_for_past_expires() {
279        let c = cookie("c", Some(1)); // 1970-01-01: definitely past
280        assert!(c.is_expired_now());
281    }
282
283    #[test]
284    fn is_expired_now_false_for_far_future_expires() {
285        let c = cookie("c", Some(i64::MAX));
286        assert!(!c.is_expired_now());
287    }
288
289    #[test]
290    fn is_expired_now_false_for_session_cookie() {
291        let c = cookie("c", None);
292        assert!(!c.is_expired_now());
293    }
294
295    #[test]
296    fn keep_persistent_alive_drops_session_cookies() {
297        let cookies = vec![
298            cookie("session", None),
299            cookie("persistent", Some(i64::MAX)),
300        ];
301        let kept = CapturedCookie::keep_persistent_alive(&cookies);
302        assert_eq!(kept.len(), 1);
303        assert_eq!(kept[0].name, "persistent");
304    }
305
306    #[test]
307    fn keep_persistent_alive_drops_expired_cookies() {
308        let cookies = vec![cookie("expired", Some(1)), cookie("alive", Some(i64::MAX))];
309        let kept = CapturedCookie::keep_persistent_alive(&cookies);
310        assert_eq!(kept.len(), 1);
311        assert_eq!(kept[0].name, "alive");
312    }
313
314    #[test]
315    fn captured_cookie_serde_roundtrip() {
316        let c = cookie("cf_clearance", Some(1234567890));
317        let json = serde_json::to_string(&c).unwrap();
318        let back: CapturedCookie = serde_json::from_str(&json).unwrap();
319        assert_eq!(c, back);
320    }
321
322    #[test]
323    fn is_expired_now_false_at_exact_boundary() {
324        // Since we compare `exp <= now`, a cookie that expires exactly at
325        // the current second may or may not be expired depending on timing.
326        // We test the structural property: `now` is >= 0 and `exp` = 0
327        // should be expired because `0 <= now` is always true for now >= 0.
328        let c = cookie("c", Some(0));
329        assert!(c.is_expired_now());
330    }
331
332    #[test]
333    fn is_expired_now_negative_expiry_treated_as_expired() {
334        // Negative epoch seconds are in the past
335        let c = cookie("c", Some(-1));
336        assert!(c.is_expired_now());
337    }
338
339    #[test]
340    fn keep_persistent_alive_empty_input() {
341        let kept = CapturedCookie::keep_persistent_alive(&[]);
342        assert!(kept.is_empty());
343    }
344
345    #[test]
346    fn keep_persistent_alive_all_expired_returns_empty() {
347        let cookies = vec![cookie("a", Some(1)), cookie("b", Some(2))];
348        let kept = CapturedCookie::keep_persistent_alive(&cookies);
349        assert!(kept.is_empty());
350    }
351
352    #[test]
353    fn keep_persistent_alive_all_session_returns_empty() {
354        let cookies = vec![cookie("a", None), cookie("b", None)];
355        let kept = CapturedCookie::keep_persistent_alive(&cookies);
356        assert!(kept.is_empty());
357    }
358
359    #[test]
360    fn keep_persistent_alive_preserves_order() {
361        let cookies = vec![
362            cookie("first", Some(i64::MAX)),
363            cookie("second", Some(i64::MAX - 1)),
364        ];
365        let kept = CapturedCookie::keep_persistent_alive(&cookies);
366        assert_eq!(kept.len(), 2);
367        assert_eq!(kept[0].name, "first");
368        assert_eq!(kept[1].name, "second");
369    }
370
371    #[test]
372    fn captured_cookie_equality() {
373        let a = cookie("a", Some(100));
374        let b = cookie("a", Some(100));
375        let c = cookie("a", Some(200));
376        assert_eq!(a, b);
377        assert_ne!(a, c);
378    }
379
380    #[test]
381    fn captured_cookie_serde_with_all_fields() {
382        let c = CapturedCookie {
383            name: "session".into(),
384            value: "abc123".into(),
385            domain: ".example.com".into(),
386            path: "/api".into(),
387            expires: Some(1893456000),
388            secure: true,
389            http_only: true,
390            same_site: Some("strict".into()),
391        };
392        let json = serde_json::to_string(&c).unwrap();
393        let back: CapturedCookie = serde_json::from_str(&json).unwrap();
394        assert_eq!(c, back);
395        assert!(json.contains("same_site"));
396        assert!(json.contains("http_only"));
397    }
398}