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