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