Skip to main content

browser_commander/browser/
browser_cookies.rs

1//! Import cookies from installed Chrome-family and Firefox profiles.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, Context, Result};
7use rusqlite::{params, Connection, OpenFlags};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Map, Value};
10
11use super::browser_sources::{browser_family, Environment};
12use super::default_browser::RunCommand;
13
14use super::browser_cookie_cache::{
15    get_cached_credential, normalize_cookie_cache, read_cookie_result_cache,
16    write_cookie_result_cache, NormalizedCookieCache,
17};
18use super::browser_cookie_credentials::{
19    decrypt_windows_dpapi, read_safe_storage_password, read_windows_encryption_key,
20};
21use super::browser_cookie_crypto::{
22    app_bound_cookie_error, chromium_same_site, decode_chromium_plaintext, decrypt_chromium_cookie,
23    derive_chromium_cookie_key, firefox_same_site,
24};
25use super::browser_profiles::{
26    find_cookie_database, resolve_browser_profile, resolve_source_browser, BrowserProfileOptions,
27};
28
29const CHROME_EPOCH_OFFSET_SECONDS: i64 = 11_644_473_600;
30
31/// A browser cookie in the shape accepted by Playwright/Puppeteer contexts.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct BrowserCookie {
35    /// Cookie name.
36    pub name: String,
37    /// Decrypted cookie value.
38    pub value: String,
39    /// Cookie domain, including any leading dot.
40    pub domain: String,
41    /// Cookie path.
42    pub path: String,
43    /// Unix expiry seconds, or `-1` for a session cookie.
44    pub expires: i64,
45    /// Whether JavaScript is prevented from reading the cookie.
46    pub http_only: bool,
47    /// Whether the cookie is restricted to secure transports.
48    pub secure: bool,
49    /// `Strict`, `Lax`, or `None`.
50    pub same_site: String,
51}
52
53/// Options for [`read_browser_cookies`].
54#[derive(Clone)]
55pub struct BrowserCookieReadOptions {
56    /// Installed browser name, or `default`/`auto` for the system default.
57    pub browser: String,
58    /// Optional on-disk or display profile name.
59    pub profile: Option<String>,
60    /// Explicit profile directory. When set, the reader uses it directly
61    /// instead of resolving the default profile location (for example a
62    /// migration honouring a custom `userDataDir`).
63    pub profile_dir: Option<PathBuf>,
64    /// Optional domain substring used by the SQLite query.
65    pub domain_filter: Option<String>,
66    /// Enable the owner-only decrypted-result and derived-key cache.
67    pub cache: bool,
68    /// Override the cache directory.
69    pub cache_dir: Option<PathBuf>,
70    /// Cache lifetime in minutes.
71    pub ttl_minutes: Option<f64>,
72    /// Bypass cached values and coordinate one refreshed credential read.
73    pub refresh: bool,
74    /// Skip individual cookies that cannot be decrypted.
75    pub ignore_decryption_errors: bool,
76    /// Home directory used for profile discovery and the default cache.
77    pub home_dir: PathBuf,
78    /// Platform convention (`linux`, `darwin`, or `win32`).
79    pub platform: String,
80    /// Environment used to expand profile-root templates (`%APPDATA%`, ...).
81    pub environment: Environment,
82    /// Command runner used to resolve the system default browser, injectable
83    /// for deterministic tests. `None` uses a real subprocess.
84    pub run_command: Option<RunCommand>,
85}
86
87impl std::fmt::Debug for BrowserCookieReadOptions {
88    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        formatter
90            .debug_struct("BrowserCookieReadOptions")
91            .field("browser", &self.browser)
92            .field("profile", &self.profile)
93            .field("profile_dir", &self.profile_dir)
94            .field("domain_filter", &self.domain_filter)
95            .field("cache", &self.cache)
96            .field("cache_dir", &self.cache_dir)
97            .field("ttl_minutes", &self.ttl_minutes)
98            .field("refresh", &self.refresh)
99            .field("ignore_decryption_errors", &self.ignore_decryption_errors)
100            .field("home_dir", &self.home_dir)
101            .field("platform", &self.platform)
102            .field("environment", &self.environment)
103            .field("run_command", &self.run_command.as_ref().map(|_| "<fn>"))
104            .finish()
105    }
106}
107
108impl BrowserCookieReadOptions {
109    /// Create options for one installed browser.
110    pub fn new(browser: impl Into<String>) -> Self {
111        Self {
112            browser: browser.into(),
113            profile: None,
114            profile_dir: None,
115            domain_filter: None,
116            cache: true,
117            cache_dir: None,
118            ttl_minutes: None,
119            refresh: false,
120            ignore_decryption_errors: false,
121            home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
122            platform: super::browser_profiles::current_platform().to_string(),
123            environment: super::browser_sources::current_environment(),
124            run_command: None,
125        }
126    }
127
128    /// Select a named installed-browser profile.
129    pub fn profile(mut self, profile: impl Into<String>) -> Self {
130        self.profile = Some(profile.into());
131        self
132    }
133
134    /// Read from an explicit profile directory instead of resolving the
135    /// default profile location for the browser.
136    pub fn profile_dir(mut self, profile_dir: impl Into<PathBuf>) -> Self {
137        self.profile_dir = Some(profile_dir.into());
138        self
139    }
140
141    /// Override the environment used when expanding profile-root templates.
142    pub fn environment(mut self, environment: Environment) -> Self {
143        self.environment = environment;
144        self
145    }
146
147    /// Inject the command runner used to resolve the system default browser.
148    pub fn run_command(mut self, run_command: RunCommand) -> Self {
149        self.run_command = Some(run_command);
150        self
151    }
152
153    /// Restrict the SQLite query to domains containing this value.
154    pub fn domain_filter(mut self, domain: impl Into<String>) -> Self {
155        self.domain_filter = Some(domain.into());
156        self
157    }
158
159    /// Enable or disable disk caching.
160    pub fn cache(mut self, enabled: bool) -> Self {
161        self.cache = enabled;
162        self
163    }
164
165    /// Override the owner-only cache directory.
166    pub fn cache_dir(mut self, directory: impl Into<PathBuf>) -> Self {
167        self.cache_dir = Some(directory.into());
168        self
169    }
170
171    /// Set the decrypted-result and derived-key cache TTL.
172    pub fn ttl_minutes(mut self, minutes: f64) -> Self {
173        self.ttl_minutes = Some(minutes);
174        self
175    }
176
177    /// Force a coordinated refresh of cached results and credentials.
178    pub fn refresh(mut self, refresh: bool) -> Self {
179        self.refresh = refresh;
180        self
181    }
182
183    /// Skip cookies whose platform decryption fails.
184    pub fn ignore_decryption_errors(mut self, ignore: bool) -> Self {
185        self.ignore_decryption_errors = ignore;
186        self
187    }
188
189    /// Override the home directory used for discovery.
190    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
191        self.home_dir = home_dir.into();
192        self
193    }
194
195    /// Override the platform convention, primarily for portable tooling/tests.
196    pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
197        self.platform = super::browser_profiles::normalize_platform(platform.as_ref()).to_string();
198        self
199    }
200}
201
202#[derive(Debug)]
203struct ChromiumRow {
204    host: String,
205    name: String,
206    value: String,
207    encrypted_value: Vec<u8>,
208    path: String,
209    expires: i64,
210    secure: bool,
211    http_only: bool,
212    same_site: i64,
213}
214
215#[derive(Default)]
216struct OperationKeyCache {
217    attempts: HashMap<String, std::result::Result<Vec<u8>, String>>,
218}
219
220struct CookieDecryptionState<'a> {
221    cache: &'a NormalizedCookieCache,
222    operation_keys: OperationKeyCache,
223}
224
225impl OperationKeyCache {
226    fn get_or_try_create<F>(&mut self, identity: &str, create: F) -> Result<Vec<u8>>
227    where
228        F: FnOnce() -> Result<Vec<u8>>,
229    {
230        if !self.attempts.contains_key(identity) {
231            self.attempts.insert(
232                identity.to_string(),
233                create().map_err(|error| error.to_string()),
234            );
235        }
236        self.attempts[identity]
237            .clone()
238            .map_err(|error| anyhow!(error))
239    }
240}
241
242pub(crate) fn open_cookie_database(path: &std::path::Path) -> Result<Connection> {
243    Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
244        .with_context(|| format!("Could not open browser cookie database: {}", path.display()))
245}
246
247fn read_database_version(database: &Connection) -> i64 {
248    database
249        .query_row("SELECT value FROM meta WHERE key = 'version'", [], |row| {
250            row.get::<_, String>(0)
251        })
252        .ok()
253        .and_then(|value| value.parse().ok())
254        .unwrap_or_default()
255}
256
257fn domain_pattern(domain_filter: Option<&str>) -> Option<String> {
258    domain_filter.map(|domain| format!("%{domain}%"))
259}
260
261fn read_chromium_rows(
262    database: &Connection,
263    domain_filter: Option<&str>,
264) -> Result<Vec<ChromiumRow>> {
265    let where_clause = domain_filter
266        .map(|_| " WHERE host_key LIKE ?1")
267        .unwrap_or("");
268    let query = format!(
269        "SELECT host_key, name, value, encrypted_value, path, expires_utc, \
270         is_secure, is_httponly, samesite FROM cookies{where_clause} \
271         ORDER BY host_key, name, path"
272    );
273    let mut statement = database.prepare(&query)?;
274    let pattern = domain_pattern(domain_filter);
275    let mapper = |row: &rusqlite::Row<'_>| {
276        Ok(ChromiumRow {
277            host: row.get(0)?,
278            name: row.get(1)?,
279            value: row.get(2)?,
280            encrypted_value: row.get(3)?,
281            path: row.get(4)?,
282            expires: row.get(5)?,
283            secure: row.get::<_, i64>(6)? != 0,
284            http_only: row.get::<_, i64>(7)? != 0,
285            same_site: row.get(8)?,
286        })
287    };
288    let rows = if let Some(pattern) = pattern.as_deref() {
289        statement.query_map(params![pattern], mapper)?
290    } else {
291        statement.query_map([], mapper)?
292    };
293    rows.collect::<rusqlite::Result<Vec<_>>>()
294        .map_err(Into::into)
295}
296
297pub(crate) fn read_firefox_cookies(
298    database: &Connection,
299    domain_filter: Option<&str>,
300) -> Result<Vec<BrowserCookie>> {
301    let where_clause = domain_filter.map(|_| " WHERE host LIKE ?1").unwrap_or("");
302    let query = format!(
303        "SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite \
304         FROM moz_cookies{where_clause} ORDER BY host, name, path"
305    );
306    let mut statement = database.prepare(&query)?;
307    let pattern = domain_pattern(domain_filter);
308    let mapper = |row: &rusqlite::Row<'_>| {
309        let expires = row.get::<_, i64>(4)?;
310        Ok(BrowserCookie {
311            name: row.get(0)?,
312            value: row.get(1)?,
313            domain: row.get(2)?,
314            path: row.get::<_, String>(3).map(|path| {
315                if path.is_empty() {
316                    "/".to_string()
317                } else {
318                    path
319                }
320            })?,
321            expires: if expires > 0 { expires } else { -1 },
322            secure: row.get::<_, i64>(5)? != 0,
323            http_only: row.get::<_, i64>(6)? != 0,
324            same_site: firefox_same_site(row.get(7)?).to_string(),
325        })
326    };
327    let rows = if let Some(pattern) = pattern.as_deref() {
328        statement.query_map(params![pattern], mapper)?
329    } else {
330        statement.query_map([], mapper)?
331    };
332    rows.collect::<rusqlite::Result<Vec<_>>>()
333        .map_err(Into::into)
334}
335
336fn credential_metadata(browser: &str, platform: &str, source: &str) -> Map<String, Value> {
337    let mut metadata = Map::new();
338    metadata.insert("browser".into(), json!(browser));
339    metadata.insert("platform".into(), json!(platform));
340    metadata.insert("source".into(), json!(source));
341    metadata
342}
343
344fn chromium_key_for_prefix(
345    prefix: &[u8],
346    browser: &str,
347    platform: &str,
348    profile_path: &Path,
349    refresh: bool,
350    state: &mut CookieDecryptionState<'_>,
351) -> Result<Vec<u8>> {
352    if platform == "linux" && prefix == b"v10" {
353        // Chromium's legacy Linux v10 format uses this public fallback secret;
354        // a different value would make existing source cookies unreadable.
355        return derive_chromium_cookie_key("peanuts", "linux");
356    }
357    if platform == "linux" || platform == "darwin" {
358        let identity = format!("{browser}:{platform}:safe-storage");
359        return state.operation_keys.get_or_try_create(&identity, || {
360            get_cached_credential(
361                state.cache,
362                &identity,
363                refresh,
364                credential_metadata(browser, platform, "safe-storage"),
365                || {
366                    derive_chromium_cookie_key(
367                        &read_safe_storage_password(browser, platform)?,
368                        platform,
369                    )
370                },
371            )
372        });
373    }
374    if platform == "win32" {
375        let identity = format!("{browser}:win32:legacy-aes-key");
376        return state.operation_keys.get_or_try_create(&identity, || {
377            get_cached_credential(
378                state.cache,
379                &identity,
380                refresh,
381                credential_metadata(browser, platform, "dpapi"),
382                || {
383                    read_windows_encryption_key(
384                        &profile_path
385                            .parent()
386                            .unwrap_or(profile_path)
387                            .join("Local State"),
388                    )
389                },
390            )
391        });
392    }
393    Err(anyhow!(
394        "Chromium cookie decryption is unsupported on {platform}"
395    ))
396}
397
398fn chromium_expires(value: i64) -> i64 {
399    if value == 0 {
400        -1
401    } else {
402        value / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS
403    }
404}
405
406fn decrypt_chromium_row(
407    row: ChromiumRow,
408    database_version: i64,
409    browser: &str,
410    platform: &str,
411    profile_path: &Path,
412    refresh: bool,
413    state: &mut CookieDecryptionState<'_>,
414) -> Result<BrowserCookie> {
415    let value = if !row.value.is_empty() {
416        row.value
417    } else if row.encrypted_value.is_empty() {
418        String::new()
419    } else {
420        let prefix = row.encrypted_value.get(..3).unwrap_or_default();
421        if platform == "win32" && prefix != b"v10" && prefix != b"v11" {
422            if prefix == b"v20" {
423                return Err(app_bound_cookie_error());
424            }
425            decode_chromium_plaintext(
426                &decrypt_windows_dpapi(&row.encrypted_value)?,
427                &row.host,
428                database_version,
429            )?
430        } else {
431            let key =
432                chromium_key_for_prefix(prefix, browser, platform, profile_path, refresh, state)?;
433            decrypt_chromium_cookie(
434                &row.encrypted_value,
435                &row.host,
436                database_version,
437                platform,
438                &key,
439            )?
440        }
441    };
442    Ok(BrowserCookie {
443        name: row.name,
444        value,
445        domain: row.host,
446        path: if row.path.is_empty() {
447            "/".into()
448        } else {
449            row.path
450        },
451        expires: chromium_expires(row.expires),
452        http_only: row.http_only,
453        secure: row.secure,
454        same_site: chromium_same_site(row.same_site).to_string(),
455    })
456}
457
458fn read_chromium_cookies(
459    database: &Connection,
460    profile_path: &Path,
461    options: &BrowserCookieReadOptions,
462    cache: &NormalizedCookieCache,
463) -> Result<Vec<BrowserCookie>> {
464    let version = read_database_version(database);
465    let mut cookies = Vec::new();
466    let mut state = CookieDecryptionState {
467        cache,
468        operation_keys: OperationKeyCache::default(),
469    };
470    for row in read_chromium_rows(database, options.domain_filter.as_deref())? {
471        let name = row.name.clone();
472        let host = row.host.clone();
473        match decrypt_chromium_row(
474            row,
475            version,
476            &options.browser,
477            &options.platform,
478            profile_path,
479            options.refresh,
480            &mut state,
481        ) {
482            Ok(cookie) => cookies.push(cookie),
483            Err(_) if options.ignore_decryption_errors => {}
484            Err(error) => {
485                return Err(anyhow!(
486                    "Could not decrypt cookie {name} for {host}: {error}"
487                ))
488            }
489        }
490    }
491    Ok(cookies)
492}
493
494/// Read cookies from an installed Chrome, Edge, Brave, Chromium, or Firefox profile.
495pub fn read_browser_cookies(mut options: BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> {
496    let browser = resolve_source_browser(
497        &options.browser,
498        &options.platform,
499        &options.environment,
500        options.run_command.as_ref(),
501    )?;
502    options.browser = browser.to_string();
503    // A caller that already resolved the profile directory (for example a
504    // migration honouring a custom `userDataDir`) passes it as `profile_dir`,
505    // so the reader does not re-resolve the default profile location.
506    let profile_path = match &options.profile_dir {
507        Some(directory) => directory.clone(),
508        None => {
509            let mut discovery = BrowserProfileOptions::default()
510                .browser(browser)
511                .home_dir(&options.home_dir)
512                .platform(&options.platform)
513                .environment(options.environment.clone());
514            if let Some(run_command) = options.run_command.clone() {
515                discovery = discovery.run_command(run_command);
516            }
517            resolve_browser_profile(browser, options.profile.as_deref(), &discovery)?.path
518        }
519    };
520    let cookie_path = find_cookie_database(browser, &profile_path)
521        .ok_or_else(|| anyhow!("No cookie database exists in {}", profile_path.display()))?;
522    let cache = normalize_cookie_cache(
523        options.cache,
524        options.cache_dir.as_deref(),
525        &options.home_dir,
526        options.ttl_minutes,
527    )?;
528    let identity = serde_json::to_string(&json!({
529        "browser": options.browser,
530        "profile": profile_path,
531        "domainFilter": options.domain_filter,
532        "ignoreDecryptionErrors": options.ignore_decryption_errors,
533    }))?;
534    if let Some(values) = read_cookie_result_cache(&cache, &identity, options.refresh) {
535        return values
536            .into_iter()
537            .map(serde_json::from_value)
538            .collect::<serde_json::Result<Vec<_>>>()
539            .context("cached cookies have an invalid shape");
540    }
541
542    let cookies = if browser_family(browser)? == "safari" {
543        super::safari_cookies::parse_safari_cookies(
544            &super::safari_cookies::read_safari_cookie_file(&cookie_path, &options.environment)?,
545            options.domain_filter.as_deref(),
546        )?
547    } else {
548        let database = open_cookie_database(&cookie_path)?;
549        if browser_family(browser)? == "firefox" {
550            read_firefox_cookies(&database, options.domain_filter.as_deref())?
551        } else {
552            read_chromium_cookies(&database, &profile_path, &options, &cache)?
553        }
554    };
555    let serialized = cookies
556        .iter()
557        .map(serde_json::to_value)
558        .collect::<serde_json::Result<Vec<_>>>()?;
559    write_cookie_result_cache(&cache, &identity, &serialized)?;
560    Ok(cookies)
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use crate::browser::browser_cookie_sources::tests::{
567        write_firefox_cookies, FirefoxCookie, TempDir,
568    };
569    use std::fs;
570
571    #[test]
572    fn honours_an_explicit_profile_dir_over_the_default_profile_root() {
573        let temp = TempDir::new("bc-profiledir-");
574        // A custom userDataDir that is NOT under the default profile root.
575        let custom_profile = temp.path().join("custom").join("profile");
576        write_firefox_cookies(
577            &custom_profile,
578            &[FirefoxCookie {
579                name: "sid",
580                value: "abc",
581                host: ".example.com",
582            }],
583        );
584        // An empty home ensures a reader that ignored profile_dir finds nothing.
585        let empty_home = temp.path().join("empty-home");
586        fs::create_dir_all(&empty_home).expect("empty home");
587
588        let cookies = read_browser_cookies(
589            BrowserCookieReadOptions::new("firefox")
590                .profile_dir(&custom_profile)
591                .platform("linux")
592                .home_dir(&empty_home)
593                .cache(false),
594        )
595        .unwrap();
596        assert_eq!(cookies.len(), 1);
597        assert_eq!(cookies[0].name, "sid");
598        assert_eq!(cookies[0].domain, ".example.com");
599    }
600
601    #[test]
602    fn operation_key_cache_reads_a_refreshed_credential_once() -> Result<()> {
603        let mut keys = OperationKeyCache::default();
604        let mut calls = 0;
605        assert_eq!(
606            keys.get_or_try_create("safe-storage", || {
607                calls += 1;
608                Ok(vec![7_u8; 16])
609            })?,
610            vec![7_u8; 16]
611        );
612        assert_eq!(
613            keys.get_or_try_create("safe-storage", || {
614                calls += 1;
615                Ok(vec![8_u8; 16])
616            })?,
617            vec![7_u8; 16]
618        );
619        assert_eq!(calls, 1);
620        Ok(())
621    }
622}