Skip to main content

browser_control/cli/
cookies.rs

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