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    // Firefox schema 16 changed Unix expiry seconds to milliseconds. Normalize
302    // in SQLite so installed reading and migration keep the same seconds shape.
303    let version = database.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
304    let expiry = if version >= 16 {
305        "expiry / 1000 AS expiry"
306    } else {
307        "expiry"
308    };
309    let where_clause = domain_filter.map(|_| " WHERE host LIKE ?1").unwrap_or("");
310    let query = format!(
311        "SELECT name, value, host, path, {expiry}, isSecure, isHttpOnly, sameSite \
312         FROM moz_cookies{where_clause} ORDER BY host, name, path"
313    );
314    let mut statement = database.prepare(&query)?;
315    let pattern = domain_pattern(domain_filter);
316    let mapper = |row: &rusqlite::Row<'_>| {
317        let expires = row.get::<_, i64>(4)?;
318        Ok(BrowserCookie {
319            name: row.get(0)?,
320            value: row.get(1)?,
321            domain: row.get(2)?,
322            path: row.get::<_, String>(3).map(|path| {
323                if path.is_empty() {
324                    "/".to_string()
325                } else {
326                    path
327                }
328            })?,
329            expires: if expires > 0 { expires } else { -1 },
330            secure: row.get::<_, i64>(5)? != 0,
331            http_only: row.get::<_, i64>(6)? != 0,
332            same_site: firefox_same_site(row.get(7)?).to_string(),
333        })
334    };
335    let rows = if let Some(pattern) = pattern.as_deref() {
336        statement.query_map(params![pattern], mapper)?
337    } else {
338        statement.query_map([], mapper)?
339    };
340    rows.collect::<rusqlite::Result<Vec<_>>>()
341        .map_err(Into::into)
342}
343
344fn credential_metadata(browser: &str, platform: &str, source: &str) -> Map<String, Value> {
345    let mut metadata = Map::new();
346    metadata.insert("browser".into(), json!(browser));
347    metadata.insert("platform".into(), json!(platform));
348    metadata.insert("source".into(), json!(source));
349    metadata
350}
351
352fn chromium_key_for_prefix(
353    prefix: &[u8],
354    browser: &str,
355    platform: &str,
356    profile_path: &Path,
357    refresh: bool,
358    state: &mut CookieDecryptionState<'_>,
359) -> Result<Vec<u8>> {
360    if platform == "linux" && prefix == b"v10" {
361        // Chromium's legacy Linux v10 format uses this public fallback secret;
362        // a different value would make existing source cookies unreadable.
363        return derive_chromium_cookie_key("peanuts", "linux");
364    }
365    if platform == "linux" || platform == "darwin" {
366        let identity = format!("{browser}:{platform}:safe-storage");
367        return state.operation_keys.get_or_try_create(&identity, || {
368            get_cached_credential(
369                state.cache,
370                &identity,
371                refresh,
372                credential_metadata(browser, platform, "safe-storage"),
373                || {
374                    derive_chromium_cookie_key(
375                        &read_safe_storage_password(browser, platform)?,
376                        platform,
377                    )
378                },
379            )
380        });
381    }
382    if platform == "win32" {
383        let identity = format!("{browser}:win32:legacy-aes-key");
384        return state.operation_keys.get_or_try_create(&identity, || {
385            get_cached_credential(
386                state.cache,
387                &identity,
388                refresh,
389                credential_metadata(browser, platform, "dpapi"),
390                || {
391                    read_windows_encryption_key(
392                        &super::browser_profile_files::local_state_path_for_profile(profile_path),
393                    )
394                },
395            )
396        });
397    }
398    Err(anyhow!(
399        "Chromium cookie decryption is unsupported on {platform}"
400    ))
401}
402
403fn chromium_expires(value: i64) -> i64 {
404    if value == 0 {
405        -1
406    } else {
407        value / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS
408    }
409}
410
411fn decrypt_chromium_row(
412    row: ChromiumRow,
413    database_version: i64,
414    browser: &str,
415    platform: &str,
416    profile_path: &Path,
417    refresh: bool,
418    state: &mut CookieDecryptionState<'_>,
419) -> Result<BrowserCookie> {
420    let value = if !row.value.is_empty() {
421        row.value
422    } else if row.encrypted_value.is_empty() {
423        String::new()
424    } else {
425        let prefix = row.encrypted_value.get(..3).unwrap_or_default();
426        if platform == "win32" && prefix != b"v10" && prefix != b"v11" {
427            if prefix == b"v20" {
428                return Err(app_bound_cookie_error());
429            }
430            decode_chromium_plaintext(
431                &decrypt_windows_dpapi(&row.encrypted_value)?,
432                &row.host,
433                database_version,
434            )?
435        } else {
436            let key =
437                chromium_key_for_prefix(prefix, browser, platform, profile_path, refresh, state)?;
438            decrypt_chromium_cookie(
439                &row.encrypted_value,
440                &row.host,
441                database_version,
442                platform,
443                &key,
444            )?
445        }
446    };
447    Ok(BrowserCookie {
448        name: row.name,
449        value,
450        domain: row.host,
451        path: if row.path.is_empty() {
452            "/".into()
453        } else {
454            row.path
455        },
456        expires: chromium_expires(row.expires),
457        http_only: row.http_only,
458        secure: row.secure,
459        same_site: chromium_same_site(row.same_site).to_string(),
460    })
461}
462
463fn read_chromium_cookies(
464    database: &Connection,
465    profile_path: &Path,
466    options: &BrowserCookieReadOptions,
467    cache: &NormalizedCookieCache,
468) -> Result<Vec<BrowserCookie>> {
469    let version = read_database_version(database);
470    let mut cookies = Vec::new();
471    let mut state = CookieDecryptionState {
472        cache,
473        operation_keys: OperationKeyCache::default(),
474    };
475    for row in read_chromium_rows(database, options.domain_filter.as_deref())? {
476        let name = row.name.clone();
477        let host = row.host.clone();
478        match decrypt_chromium_row(
479            row,
480            version,
481            &options.browser,
482            &options.platform,
483            profile_path,
484            options.refresh,
485            &mut state,
486        ) {
487            Ok(cookie) => cookies.push(cookie),
488            Err(_) if options.ignore_decryption_errors => {}
489            Err(error) => {
490                return Err(anyhow!(
491                    "Could not decrypt cookie {name} for {host}: {error}"
492                ))
493            }
494        }
495    }
496    Ok(cookies)
497}
498
499/// Read cookies from an installed Chrome, Edge, Brave, Chromium, or Firefox profile.
500pub fn read_browser_cookies(mut options: BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> {
501    let browser = resolve_source_browser(
502        &options.browser,
503        &options.platform,
504        &options.environment,
505        options.run_command.as_ref(),
506    )?;
507    options.browser = browser.to_string();
508    // A caller that already resolved the profile directory (for example a
509    // migration honouring a custom `userDataDir`) passes it as `profile_dir`,
510    // so the reader does not re-resolve the default profile location.
511    let profile_path = match &options.profile_dir {
512        Some(directory) => directory.clone(),
513        None => {
514            let mut discovery = BrowserProfileOptions::default()
515                .browser(browser)
516                .home_dir(&options.home_dir)
517                .platform(&options.platform)
518                .environment(options.environment.clone());
519            if let Some(run_command) = options.run_command.clone() {
520                discovery = discovery.run_command(run_command);
521            }
522            resolve_browser_profile(browser, options.profile.as_deref(), &discovery)?.path
523        }
524    };
525    let cookie_path = find_cookie_database(browser, &profile_path)
526        .ok_or_else(|| anyhow!("No cookie database exists in {}", profile_path.display()))?;
527    let cache = normalize_cookie_cache(
528        options.cache,
529        options.cache_dir.as_deref(),
530        &options.home_dir,
531        options.ttl_minutes,
532    )?;
533    let identity = serde_json::to_string(&json!({
534        "browser": options.browser,
535        "profile": profile_path,
536        "domainFilter": options.domain_filter,
537        "ignoreDecryptionErrors": options.ignore_decryption_errors,
538    }))?;
539    if let Some(values) = read_cookie_result_cache(&cache, &identity, options.refresh) {
540        return values
541            .into_iter()
542            .map(serde_json::from_value)
543            .collect::<serde_json::Result<Vec<_>>>()
544            .context("cached cookies have an invalid shape");
545    }
546
547    let cookies = if browser_family(browser)? == "safari" {
548        super::safari_cookies::parse_safari_cookies(
549            &super::safari_cookies::read_safari_cookie_file(&cookie_path, &options.environment)?,
550            options.domain_filter.as_deref(),
551        )?
552    } else {
553        let database = open_cookie_database(&cookie_path)?;
554        if browser_family(browser)? == "firefox" {
555            read_firefox_cookies(&database, options.domain_filter.as_deref())?
556        } else {
557            read_chromium_cookies(&database, &profile_path, &options, &cache)?
558        }
559    };
560    let serialized = cookies
561        .iter()
562        .map(serde_json::to_value)
563        .collect::<serde_json::Result<Vec<_>>>()?;
564    write_cookie_result_cache(&cache, &identity, &serialized)?;
565    Ok(cookies)
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::browser::browser_cookie_sources::tests::{
572        write_firefox_cookies, FirefoxCookie, TempDir,
573    };
574    use std::fs;
575
576    #[test]
577    fn honours_an_explicit_profile_dir_over_the_default_profile_root() {
578        let temp = TempDir::new("bc-profiledir-");
579        // A custom userDataDir that is NOT under the default profile root.
580        let custom_profile = temp.path().join("custom").join("profile");
581        write_firefox_cookies(
582            &custom_profile,
583            &[FirefoxCookie {
584                name: "sid",
585                value: "abc",
586                host: ".example.com",
587            }],
588        );
589        // An empty home ensures a reader that ignored profile_dir finds nothing.
590        let empty_home = temp.path().join("empty-home");
591        fs::create_dir_all(&empty_home).expect("empty home");
592
593        let cookies = read_browser_cookies(
594            BrowserCookieReadOptions::new("firefox")
595                .profile_dir(&custom_profile)
596                .platform("linux")
597                .home_dir(&empty_home)
598                .cache(false),
599        )
600        .unwrap();
601        assert_eq!(cookies.len(), 1);
602        assert_eq!(cookies[0].name, "sid");
603        assert_eq!(cookies[0].domain, ".example.com");
604    }
605
606    #[test]
607    fn operation_key_cache_reads_a_refreshed_credential_once() -> Result<()> {
608        let mut keys = OperationKeyCache::default();
609        let mut calls = 0;
610        assert_eq!(
611            keys.get_or_try_create("safe-storage", || {
612                calls += 1;
613                Ok(vec![7_u8; 16])
614            })?,
615            vec![7_u8; 16]
616        );
617        assert_eq!(
618            keys.get_or_try_create("safe-storage", || {
619                calls += 1;
620                Ok(vec![8_u8; 16])
621            })?,
622            vec![7_u8; 16]
623        );
624        assert_eq!(calls, 1);
625        Ok(())
626    }
627}