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(page: &crate::browser::Page) -> anyhow::Result<Vec<CapturedCookie>> {
70    page.get_cookies().await
71}
72
73/// Apply previously-[`capture_from_page`]-captured cookies to a
74/// fresh `page` via BiDi `storage.setCookie`. Skips entries that
75/// have already expired; returns the count of cookies actually
76/// installed.
77pub async fn apply_to_page(
78    page: &crate::browser::Page,
79    cookies: &[CapturedCookie],
80) -> anyhow::Result<usize> {
81    let now = SystemTime::now()
82        .duration_since(UNIX_EPOCH)
83        .map(|d| d.as_secs() as i64)
84        .unwrap_or(0);
85
86    let mut installed = 0usize;
87    for c in cookies {
88        if let Some(exp) = c.expires {
89            if exp <= now {
90                continue; // skip expired
91            }
92        }
93        let same_site = c.same_site.as_ref().and_then(|s| match s.as_str() {
94            "strict" => Some(rustenium_bidi_definitions::network::types::SameSite::Strict),
95            "lax" => Some(rustenium_bidi_definitions::network::types::SameSite::Lax),
96            "none" => Some(rustenium_bidi_definitions::network::types::SameSite::None),
97            _ => None,
98        });
99        page.set_cookie(
100            &c.name,
101            &c.value,
102            &c.domain,
103            Some(&c.path),
104            c.expires.map(|e| e as u64),
105            Some(c.secure),
106            Some(c.http_only),
107            same_site,
108        )
109        .await?;
110        installed += 1;
111    }
112    Ok(installed)
113}
114
115/// Filter `cookies` to only those whose name matches one of `vendor`'s
116/// known anti-bot tokens. Useful for trimming a full session capture
117/// down to the minimum subset that proves a vendor's challenge passed
118///: handy when you want to forward auth state to a non-browser HTTP
119/// client (curl/reqwest) without leaking unrelated session data.
120///
121/// The vendor → cookie-name map is derived from the bundled rule
122/// pack's `cookie_names` triggers.
123pub fn vendor_cookies(input: &[CapturedCookie], vendor: &str) -> Vec<CapturedCookie> {
124    let names: &[&str] = match vendor.to_lowercase().as_str() {
125        "cloudflare" | "cf" => &["__cf_bm", "cf_chl_2", "cf_clearance"],
126        "akamai" => &["_abck", "bm_sz", "ak_bmsc"],
127        "datadome" => &["datadome", "_dd_s"],
128        "perimeterx" | "human" => &["_px2", "_pxhd", "_px3", "_pxvid"],
129        "incapsula" | "imperva" => &["visid_incap", "incap_ses"],
130        "kasada" => &["KP_UIDz", "x-kpsdk-cd", "x-kpsdk-ct"],
131        "fastly" => &["_fastly_ngwaf"],
132        "sucuri" => &["sucuri_cloudproxy_uuid"],
133        "anubis" => &["anubis-auth"],
134        _ => return Vec::new(),
135    };
136    input
137        .iter()
138        .filter(|c| names.iter().any(|n| c.name.starts_with(*n) || c.name == *n))
139        .cloned()
140        .collect()
141}
142
143#[cfg(test)]
144mod vendor_cookie_tests {
145    use super::*;
146
147    fn ck(name: &str) -> CapturedCookie {
148        CapturedCookie {
149            name: name.into(),
150            value: "test".into(),
151            domain: ".example.com".into(),
152            path: "/".into(),
153            expires: None,
154            secure: false,
155            http_only: false,
156            same_site: None,
157        }
158    }
159
160    #[test]
161    fn vendor_cookies_filters_cloudflare_set() {
162        let all = vec![
163            ck("__cf_bm"),
164            ck("cf_clearance"),
165            ck("session_id"),
166            ck("_ga"),
167        ];
168        let filtered = vendor_cookies(&all, "cloudflare");
169        let names: Vec<&str> = filtered.iter().map(|c| c.name.as_str()).collect();
170        assert!(names.contains(&"__cf_bm"));
171        assert!(names.contains(&"cf_clearance"));
172        assert!(!names.contains(&"_ga"));
173    }
174
175    #[test]
176    fn vendor_cookies_matches_prefixed_cookie_names() {
177        // Imperva uses dynamic cookie suffixes like `visid_incap_<n>`.
178        let all = vec![ck("visid_incap_12345"), ck("incap_ses_99_99")];
179        let filtered = vendor_cookies(&all, "imperva");
180        assert_eq!(filtered.len(), 2);
181    }
182
183    #[test]
184    fn vendor_cookies_unknown_vendor_returns_empty() {
185        let all = vec![ck("__cf_bm")];
186        let filtered = vendor_cookies(&all, "totally-not-a-vendor");
187        assert!(filtered.is_empty());
188    }
189
190    #[test]
191    fn vendor_cookies_is_case_insensitive_on_vendor() {
192        let all = vec![ck("__cf_bm")];
193        assert_eq!(vendor_cookies(&all, "Cloudflare").len(), 1);
194        assert_eq!(vendor_cookies(&all, "CLOUDFLARE").len(), 1);
195        assert_eq!(vendor_cookies(&all, "cf").len(), 1);
196    }
197
198    #[test]
199    fn vendor_cookies_akamai_set() {
200        let all = vec![ck("_abck"), ck("bm_sz"), ck("ak_bmsc"), ck("other")];
201        let filtered = vendor_cookies(&all, "akamai");
202        assert_eq!(filtered.len(), 3);
203    }
204
205    #[test]
206    fn vendor_cookies_datadome_set() {
207        let all = vec![ck("datadome"), ck("_dd_s"), ck("session")];
208        let filtered = vendor_cookies(&all, "datadome");
209        assert_eq!(filtered.len(), 2);
210    }
211
212    #[test]
213    fn vendor_cookies_perimeterx_aliases() {
214        let all = vec![ck("_px2"), ck("_pxhd"), ck("_px3")];
215        assert_eq!(vendor_cookies(&all, "perimeterx").len(), 3);
216        assert_eq!(vendor_cookies(&all, "human").len(), 3);
217    }
218
219    #[test]
220    fn vendor_cookies_kasada_set() {
221        let all = vec![ck("KP_UIDz"), ck("x-kpsdk-cd")];
222        assert_eq!(vendor_cookies(&all, "kasada").len(), 2);
223    }
224
225    #[test]
226    fn vendor_cookies_fastly_set() {
227        let all = vec![ck("_fastly_ngwaf"), ck("other")];
228        assert_eq!(vendor_cookies(&all, "fastly").len(), 1);
229    }
230
231    #[test]
232    fn vendor_cookies_sucuri_set() {
233        let all = vec![ck("sucuri_cloudproxy_uuid")];
234        assert_eq!(vendor_cookies(&all, "sucuri").len(), 1);
235    }
236
237    #[test]
238    fn vendor_cookies_anubis_set() {
239        let all = vec![ck("anubis-auth")];
240        assert_eq!(vendor_cookies(&all, "anubis").len(), 1);
241    }
242
243    #[test]
244    fn vendor_cookies_empty_input_returns_empty() {
245        assert!(vendor_cookies(&[], "cloudflare").is_empty());
246    }
247
248    #[test]
249    fn vendor_cookies_prefix_match_dynamic_suffix() {
250        // Imperva cookies have dynamic suffixes; the vendor rule uses
251        // `starts_with` so `visid_incap_` matches `visid_incap_12345`.
252        let all = vec![ck("visid_incap_12345"), ck("visid_incap_99999")];
253        let filtered = vendor_cookies(&all, "imperva");
254        assert_eq!(filtered.len(), 2);
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn cookie(name: &str, expires: Option<i64>) -> CapturedCookie {
263        CapturedCookie {
264            name: name.into(),
265            value: "v".into(),
266            domain: ".example.com".into(),
267            path: "/".into(),
268            expires,
269            secure: true,
270            http_only: false,
271            same_site: None,
272        }
273    }
274
275    #[test]
276    fn is_expired_now_true_for_past_expires() {
277        let c = cookie("c", Some(1)); // 1970-01-01: definitely past
278        assert!(c.is_expired_now());
279    }
280
281    #[test]
282    fn is_expired_now_false_for_far_future_expires() {
283        let c = cookie("c", Some(i64::MAX));
284        assert!(!c.is_expired_now());
285    }
286
287    #[test]
288    fn is_expired_now_false_for_session_cookie() {
289        let c = cookie("c", None);
290        assert!(!c.is_expired_now());
291    }
292
293    #[test]
294    fn keep_persistent_alive_drops_session_cookies() {
295        let cookies = vec![
296            cookie("session", None),
297            cookie("persistent", Some(i64::MAX)),
298        ];
299        let kept = CapturedCookie::keep_persistent_alive(&cookies);
300        assert_eq!(kept.len(), 1);
301        assert_eq!(kept[0].name, "persistent");
302    }
303
304    #[test]
305    fn keep_persistent_alive_drops_expired_cookies() {
306        let cookies = vec![cookie("expired", Some(1)), cookie("alive", Some(i64::MAX))];
307        let kept = CapturedCookie::keep_persistent_alive(&cookies);
308        assert_eq!(kept.len(), 1);
309        assert_eq!(kept[0].name, "alive");
310    }
311
312    #[test]
313    fn captured_cookie_serde_roundtrip() {
314        let c = cookie("cf_clearance", Some(1234567890));
315        let json = serde_json::to_string(&c).unwrap();
316        let back: CapturedCookie = serde_json::from_str(&json).unwrap();
317        assert_eq!(c, back);
318    }
319
320    #[test]
321    fn is_expired_now_false_at_exact_boundary() {
322        // Since we compare `exp <= now`, a cookie that expires exactly at
323        // the current second may or may not be expired depending on timing.
324        // We test the structural property: `now` is >= 0 and `exp` = 0
325        // should be expired because `0 <= now` is always true for now >= 0.
326        let c = cookie("c", Some(0));
327        assert!(c.is_expired_now());
328    }
329
330    #[test]
331    fn is_expired_now_negative_expiry_treated_as_expired() {
332        // Negative epoch seconds are in the past
333        let c = cookie("c", Some(-1));
334        assert!(c.is_expired_now());
335    }
336
337    #[test]
338    fn keep_persistent_alive_empty_input() {
339        let kept = CapturedCookie::keep_persistent_alive(&[]);
340        assert!(kept.is_empty());
341    }
342
343    #[test]
344    fn keep_persistent_alive_all_expired_returns_empty() {
345        let cookies = vec![cookie("a", Some(1)), cookie("b", Some(2))];
346        let kept = CapturedCookie::keep_persistent_alive(&cookies);
347        assert!(kept.is_empty());
348    }
349
350    #[test]
351    fn keep_persistent_alive_all_session_returns_empty() {
352        let cookies = vec![cookie("a", None), cookie("b", None)];
353        let kept = CapturedCookie::keep_persistent_alive(&cookies);
354        assert!(kept.is_empty());
355    }
356
357    #[test]
358    fn keep_persistent_alive_preserves_order() {
359        let cookies = vec![
360            cookie("first", Some(i64::MAX)),
361            cookie("second", Some(i64::MAX - 1)),
362        ];
363        let kept = CapturedCookie::keep_persistent_alive(&cookies);
364        assert_eq!(kept.len(), 2);
365        assert_eq!(kept[0].name, "first");
366        assert_eq!(kept[1].name, "second");
367    }
368
369    #[test]
370    fn captured_cookie_equality() {
371        let a = cookie("a", Some(100));
372        let b = cookie("a", Some(100));
373        let c = cookie("a", Some(200));
374        assert_eq!(a, b);
375        assert_ne!(a, c);
376    }
377
378    #[test]
379    fn captured_cookie_serde_with_all_fields() {
380        let c = CapturedCookie {
381            name: "session".into(),
382            value: "abc123".into(),
383            domain: ".example.com".into(),
384            path: "/api".into(),
385            expires: Some(1893456000),
386            secure: true,
387            http_only: true,
388            same_site: Some("strict".into()),
389        };
390        let json = serde_json::to_string(&c).unwrap();
391        let back: CapturedCookie = serde_json::from_str(&json).unwrap();
392        assert_eq!(c, back);
393        assert!(json.contains("same_site"));
394        assert!(json.contains("http_only"));
395    }
396}