Skip to main content

browser_control/cli/
cookies.rs

1//! `browser-control cookies` — export cookies from the active browser.
2//!
3//! Browser-wide command — tab suffixes on `--browser` are rejected.
4//!
5//! Output formats:
6//! - `json`: pretty JSON array of normalised cookies. Always contains full values
7//!   (machine-readable; user explicitly opted in).
8//! - `netscape`: Netscape HTTP Cookie File (tab-separated). Always full values
9//!   (the format is intended for tools like `curl --cookie`).
10//! - `header`: a single `Cookie: k=v; ...` line. Values are replaced with
11//!   `<redacted>` when writing to stdout unless `--reveal` is given. Writing
12//!   to a file via `-o` always emits full values; the file is `0600` on Unix.
13//!
14//! Domain/name filters are unanchored regexes — use `^…$` for strict matching.
15
16use anyhow::{anyhow, bail, Context, Result};
17use regex::Regex;
18use serde::Serialize;
19use serde_json::{json, Value};
20use std::path::{Path, PathBuf};
21
22use crate::cli::env_resolver::{self, ResolvedBrowser};
23use crate::cli::mcp::{acquire_bidi_lock_if_needed, resolve_browser};
24use crate::cli::trace::CommandTrace;
25use crate::detect::Engine;
26use crate::registry::Registry;
27use crate::session::targets::{open_bidi, open_cdp};
28
29#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
30pub(crate) struct NormalCookie {
31    pub(crate) domain: String,
32    pub(crate) name: String,
33    pub(crate) value: String,
34    pub(crate) path: String,
35    pub(crate) secure: bool,
36    pub(crate) http_only: bool,
37    pub(crate) same_site: Option<String>,
38    pub(crate) expires: Option<i64>,
39}
40
41/// Fetch all cookies from the resolved browser, normalised across engines.
42pub(crate) async fn fetch_cookies(resolved: &ResolvedBrowser) -> Result<Vec<NormalCookie>> {
43    match resolved.engine {
44        Engine::Cdp => fetch_cdp(&resolved.endpoint).await,
45        Engine::Bidi => fetch_bidi(&resolved.endpoint).await,
46    }
47}
48
49#[allow(clippy::too_many_arguments)]
50pub async fn run(
51    browser: Option<String>,
52    domain: Option<String>,
53    name: Option<String>,
54    format: String,
55    output: Option<PathBuf>,
56    reveal: bool,
57    json: bool,
58) -> Result<()> {
59    let mut trace = CommandTrace::new("cookies");
60    let result: Result<()> = async {
61        let effective_format = if json { "json".to_string() } else { format };
62        match effective_format.as_str() {
63            "json" | "netscape" | "header" => {}
64            other => {
65                bail!("unknown --format `{other}`: expected one of `netscape`, `json`, `header`")
66            }
67        }
68
69        let domain_re = domain
70            .as_deref()
71            .map(Regex::new)
72            .transpose()
73            .context("invalid --domain regex")?;
74        let name_re = name
75            .as_deref()
76            .map(Regex::new)
77            .transpose()
78            .context("invalid --name regex")?;
79
80        // Accept `<browser>[/<tab>]` for syntactic consistency with
81        // `eval`/`fetch`/`storage`. Cookies are inherently browser-wide
82        // (CDP `Storage.getCookies` / BiDi `storage.getCookies` return the
83        // full cookie jar), so tab suffixes are rejected.
84        let raw = browser.unwrap_or_default();
85        let parsed = if raw.is_empty() {
86            None
87        } else {
88            Some(env_resolver::parse_target(&raw)?)
89        };
90        if parsed.as_ref().and_then(|p| p.tab.as_ref()).is_some() {
91            bail!(
92                "`cookies` operates browser-wide; tab suffixes are not supported \
93                 (got `{raw}`). Use a bare browser selector instead."
94            );
95        }
96        let browser_only = parsed.as_ref().map(|_| raw.clone()).unwrap_or_default();
97        let resolved = resolve_browser(if browser_only.is_empty() {
98            None
99        } else {
100            Some(browser_only.clone())
101        })
102        .await?;
103        trace.browser(&browser_only).engine(resolved.engine);
104        trace.route("browser-wide");
105        // Hold the BiDi single-session lock for the read; releases on Drop.
106        // No-op for CDP and for external URL endpoints.
107        let _bidi_lock = {
108            let registry = Registry::open()?;
109            acquire_bidi_lock_if_needed(&registry, &resolved)?
110        };
111        let raw = fetch_cookies(&resolved).await?;
112
113        let cookies: Vec<NormalCookie> = raw
114            .into_iter()
115            .filter(|c| matches_filter(c, domain_re.as_ref(), name_re.as_ref()))
116            .collect();
117
118        // For `-o FILE` writes we always emit full values (the file is 0600).
119        // Redaction only applies to stdout + `header` format without `--reveal`.
120        let to_stdout = output.is_none();
121        let body = match effective_format.as_str() {
122            "json" => format_json(&cookies)?,
123            "netscape" => format_netscape(&cookies),
124            "header" => format_header(&cookies, !to_stdout || reveal),
125            _ => unreachable!(),
126        };
127
128        match output {
129            Some(path) => {
130                write_file(&path, &body)?;
131                eprintln!("wrote {} cookies to {}", cookies.len(), path.display());
132            }
133            None => {
134                use std::io::Write;
135                let mut out = std::io::stdout().lock();
136                out.write_all(body.as_bytes())?;
137                if !body.ends_with('\n') {
138                    out.write_all(b"\n")?;
139                }
140            }
141        }
142        Ok(())
143    }
144    .await;
145    trace.finish(result)
146}
147
148async fn fetch_cdp(endpoint: &str) -> Result<Vec<NormalCookie>> {
149    let client = open_cdp(endpoint).await?;
150    let result = client.get_all_cookies().await?;
151    client.close().await;
152    let arr = result
153        .get("cookies")
154        .and_then(|v| v.as_array())
155        .ok_or_else(|| anyhow!("CDP cookie export: missing `cookies` array"))?;
156    Ok(arr.iter().map(normalize_cdp).collect())
157}
158
159async fn fetch_bidi(endpoint: &str) -> Result<Vec<NormalCookie>> {
160    let client = open_bidi(endpoint).await?;
161    client.session_new().await?;
162    let result = client.send("storage.getCookies", json!({})).await;
163    let _ = client.session_end().await;
164    let result = result?;
165    let arr = result
166        .get("cookies")
167        .and_then(|v| v.as_array())
168        .ok_or_else(|| anyhow!("BiDi storage.getCookies: missing `cookies` array"))?;
169    Ok(arr.iter().map(normalize_bidi).collect())
170}
171
172fn str_field(v: &Value, k: &str) -> String {
173    v.get(k)
174        .and_then(|x| x.as_str())
175        .unwrap_or_default()
176        .to_string()
177}
178
179fn bool_field(v: &Value, k: &str) -> bool {
180    v.get(k).and_then(|x| x.as_bool()).unwrap_or(false)
181}
182
183pub(crate) fn normalize_cdp(v: &Value) -> NormalCookie {
184    let expires =
185        v.get("expires")
186            .and_then(|x| x.as_i64())
187            .and_then(|n| if n < 0 { None } else { Some(n) });
188    let same_site = v
189        .get("sameSite")
190        .and_then(|x| x.as_str())
191        .map(|s| s.to_string());
192    NormalCookie {
193        domain: str_field(v, "domain"),
194        name: str_field(v, "name"),
195        value: str_field(v, "value"),
196        path: str_field(v, "path"),
197        secure: bool_field(v, "secure"),
198        http_only: bool_field(v, "httpOnly"),
199        same_site,
200        expires,
201    }
202}
203
204pub(crate) fn normalize_bidi(v: &Value) -> NormalCookie {
205    let value = v
206        .get("value")
207        .and_then(|inner| inner.get("value").and_then(|x| x.as_str()))
208        .unwrap_or_default()
209        .to_string();
210    let expires = v.get("expiry").and_then(|x| x.as_i64());
211    let same_site = v
212        .get("sameSite")
213        .and_then(|x| x.as_str())
214        .map(|s| s.to_string());
215    NormalCookie {
216        domain: str_field(v, "domain"),
217        name: str_field(v, "name"),
218        value,
219        path: str_field(v, "path"),
220        secure: bool_field(v, "secure"),
221        http_only: bool_field(v, "httpOnly"),
222        same_site,
223        expires,
224    }
225}
226
227fn matches_filter(c: &NormalCookie, domain: Option<&Regex>, name: Option<&Regex>) -> bool {
228    domain.map_or(true, |re| re.is_match(&c.domain)) && name.map_or(true, |re| re.is_match(&c.name))
229}
230
231fn format_json(cookies: &[NormalCookie]) -> Result<String> {
232    Ok(serde_json::to_string_pretty(cookies)?)
233}
234
235fn format_netscape(cookies: &[NormalCookie]) -> String {
236    let mut out = String::from("# Netscape HTTP Cookie File\n");
237    for c in cookies {
238        let include_sub = if c.domain.starts_with('.') {
239            "TRUE"
240        } else {
241            "FALSE"
242        };
243        let secure = if c.secure { "TRUE" } else { "FALSE" };
244        let expires = c.expires.unwrap_or(0);
245        out.push_str(&format!(
246            "{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
247            c.domain, include_sub, c.path, secure, expires, c.name, c.value
248        ));
249    }
250    out
251}
252
253fn format_header(cookies: &[NormalCookie], reveal: bool) -> String {
254    let parts: Vec<String> = cookies
255        .iter()
256        .map(|c| {
257            let v = if reveal {
258                c.value.as_str()
259            } else {
260                "<redacted>"
261            };
262            format!("{}={}", c.name, v)
263        })
264        .collect();
265    format!("Cookie: {}", parts.join("; "))
266}
267
268fn write_file(path: &Path, body: &str) -> Result<()> {
269    std::fs::write(path, body).with_context(|| format!("failed to write {}", path.display()))?;
270    #[cfg(unix)]
271    {
272        use std::os::unix::fs::PermissionsExt;
273        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
274            .with_context(|| format!("failed to chmod 600 {}", path.display()))?;
275    }
276    Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn c(domain: &str, name: &str, value: &str) -> NormalCookie {
284        NormalCookie {
285            domain: domain.to_string(),
286            name: name.to_string(),
287            value: value.to_string(),
288            path: "/".to_string(),
289            secure: false,
290            http_only: false,
291            same_site: None,
292            expires: None,
293        }
294    }
295
296    #[test]
297    fn netscape_byte_for_byte() {
298        let cookies = vec![
299            NormalCookie {
300                domain: ".example.com".to_string(),
301                name: "a".to_string(),
302                value: "1".to_string(),
303                path: "/".to_string(),
304                secure: true,
305                http_only: false,
306                same_site: None,
307                expires: Some(1700000000),
308            },
309            NormalCookie {
310                domain: "host.test".to_string(),
311                name: "b".to_string(),
312                value: "two".to_string(),
313                path: "/x".to_string(),
314                secure: false,
315                http_only: true,
316                same_site: Some("Lax".to_string()),
317                expires: None,
318            },
319        ];
320        let got = format_netscape(&cookies);
321        let want = "# Netscape HTTP Cookie File\n\
322            .example.com\tTRUE\t/\tTRUE\t1700000000\ta\t1\n\
323            host.test\tFALSE\t/x\tFALSE\t0\tb\ttwo\n";
324        assert_eq!(got, want);
325    }
326
327    #[test]
328    fn header_output_revealed() {
329        let cookies = vec![c("x.test", "a", "1"), c("x.test", "b", "2")];
330        assert_eq!(format_header(&cookies, true), "Cookie: a=1; b=2");
331    }
332
333    #[test]
334    fn header_output_redacted() {
335        let cookies = vec![c("x.test", "a", "1"), c("x.test", "b", "2")];
336        assert_eq!(
337            format_header(&cookies, false),
338            "Cookie: a=<redacted>; b=<redacted>"
339        );
340    }
341
342    #[test]
343    fn domain_regex_filter_unanchored() {
344        // Pattern `\.example\.com$` matches `.example.com` literally and
345        // `www.example.com` (`.example.com` is a substring at the end).
346        let re = Regex::new(r"\.example\.com$").unwrap();
347        let dot = c(".example.com", "a", "1");
348        let www = c("www.example.com", "a", "1");
349        let other = c("evil.test", "a", "1");
350        assert!(matches_filter(&dot, Some(&re), None));
351        assert!(matches_filter(&www, Some(&re), None));
352        assert!(!matches_filter(&other, Some(&re), None));
353    }
354
355    #[test]
356    fn name_regex_filter() {
357        let re = Regex::new(r"^session_").unwrap();
358        let yes = c("x.test", "session_id", "1");
359        let no = c("x.test", "csrf", "2");
360        assert!(matches_filter(&yes, None, Some(&re)));
361        assert!(!matches_filter(&no, None, Some(&re)));
362    }
363
364    #[test]
365    fn json_output_round_trips() {
366        let cookies = vec![NormalCookie {
367            domain: ".example.com".to_string(),
368            name: "a".to_string(),
369            value: "v".to_string(),
370            path: "/".to_string(),
371            secure: true,
372            http_only: true,
373            same_site: Some("Strict".to_string()),
374            expires: Some(42),
375        }];
376        let s = format_json(&cookies).unwrap();
377        let v: Value = serde_json::from_str(&s).unwrap();
378        assert_eq!(v[0]["domain"], ".example.com");
379        assert_eq!(v[0]["name"], "a");
380        assert_eq!(v[0]["value"], "v");
381        assert_eq!(v[0]["secure"], true);
382        assert_eq!(v[0]["http_only"], true);
383        assert_eq!(v[0]["same_site"], "Strict");
384        assert_eq!(v[0]["expires"], 42);
385    }
386
387    #[test]
388    fn normalize_cdp_maps_fields() {
389        let v = json!({
390            "name": "sid",
391            "value": "abc",
392            "domain": ".example.com",
393            "path": "/",
394            "expires": 1700000000_i64,
395            "httpOnly": true,
396            "secure": true,
397            "sameSite": "Lax",
398            "session": false
399        });
400        let n = normalize_cdp(&v);
401        assert_eq!(n.name, "sid");
402        assert_eq!(n.value, "abc");
403        assert_eq!(n.domain, ".example.com");
404        assert_eq!(n.path, "/");
405        assert_eq!(n.expires, Some(1700000000));
406        assert!(n.http_only);
407        assert!(n.secure);
408        assert_eq!(n.same_site.as_deref(), Some("Lax"));
409    }
410
411    #[test]
412    fn normalize_cdp_session_cookie_has_no_expires() {
413        let v = json!({
414            "name": "s",
415            "value": "x",
416            "domain": "host.test",
417            "path": "/",
418            "expires": -1_i64,
419            "httpOnly": false,
420            "secure": false,
421            "session": true
422        });
423        let n = normalize_cdp(&v);
424        assert_eq!(n.expires, None);
425        assert_eq!(n.same_site, None);
426    }
427
428    #[test]
429    fn normalize_bidi_maps_fields() {
430        let v = json!({
431            "name": "sid",
432            "value": {"type": "string", "value": "abc"},
433            "domain": "example.com",
434            "path": "/",
435            "expiry": 1700000000_i64,
436            "httpOnly": true,
437            "secure": true,
438            "sameSite": "lax",
439            "size": 10
440        });
441        let n = normalize_bidi(&v);
442        assert_eq!(n.name, "sid");
443        assert_eq!(n.value, "abc");
444        assert_eq!(n.domain, "example.com");
445        assert_eq!(n.expires, Some(1700000000));
446        assert!(n.http_only);
447        assert!(n.secure);
448        assert_eq!(n.same_site.as_deref(), Some("lax"));
449    }
450
451    #[test]
452    fn normalize_bidi_no_expiry_is_session() {
453        let v = json!({
454            "name": "s",
455            "value": {"type": "string", "value": "x"},
456            "domain": "example.com",
457            "path": "/",
458            "httpOnly": false,
459            "secure": false,
460            "size": 1
461        });
462        let n = normalize_bidi(&v);
463        assert_eq!(n.expires, None);
464    }
465}