Skip to main content

seher/browser/
cookie_reader.rs

1use super::types::{Cookie, Profile};
2use crate::crypto;
3use rusqlite::Connection;
4use std::path::Path;
5use thiserror::Error;
6
7#[derive(Error, Debug)]
8pub enum CookieReaderError {
9    #[error("Database error: {0}")]
10    DatabaseError(#[from] rusqlite::Error),
11
12    #[error("IO error: {0}")]
13    IoError(#[from] std::io::Error),
14
15    #[error("Decryption error: {0}")]
16    DecryptionError(#[from] crypto::CryptoError),
17
18    #[error("No cookies found for domain: {0}")]
19    NoCookiesFound(String),
20}
21
22pub type Result<T> = std::result::Result<T, CookieReaderError>;
23
24/// Clamp a finite `f64` to a safe subrange of `i64`, then convert via string.
25///
26/// Safari cookie expiry values are seconds since 2001-01-01, on the order of
27/// 1e9. We cap at +/-1e15 (well within `i64`) and convert via string parse to
28/// avoid any integer-cast lints.
29fn f64_to_i64_saturating(v: f64) -> i64 {
30    const MAX: f64 = 1_000_000_000_000_000_f64; // 1e15, well within i64 and f64 precision
31    const MIN: f64 = -1_000_000_000_000_000_f64;
32    // Round and clamp to an integer-valued f64 that is exactly representable.
33    let clamped = v.round().clamp(MIN, MAX);
34    // Convert via string representation to avoid cast lints.
35    // The value is a whole number in [-1e15, 1e15], so parsing always succeeds.
36    clamped.to_string().parse().unwrap_or(0)
37}
38
39pub struct CookieReader;
40
41impl CookieReader {
42    /// # Errors
43    ///
44    /// Returns an error if the cookies file is not found, cannot be read, or decryption fails.
45    pub fn read_cookies(profile: &Profile, domain: &str) -> Result<Vec<Cookie>> {
46        let cookies_path = profile.cookies_path();
47
48        if !cookies_path.exists() {
49            return Err(CookieReaderError::NoCookiesFound(format!(
50                "Cookies file not found: {}",
51                cookies_path.display()
52            )));
53        }
54
55        let temp_dir = std::env::temp_dir();
56        let temp_cookies = temp_dir.join(format!("cookies_{}.db", std::process::id()));
57        std::fs::copy(&cookies_path, &temp_cookies)?;
58
59        let result = if profile.browser_type.is_chromium_based() {
60            Self::read_chromium_cookies(&temp_cookies, domain, profile)
61        } else if profile.browser_type == super::types::BrowserType::Firefox {
62            Self::read_firefox_cookies(&temp_cookies, domain)
63        } else if profile.browser_type == super::types::BrowserType::Safari {
64            Self::read_safari_cookies(&cookies_path, domain)
65        } else {
66            Err(CookieReaderError::NoCookiesFound(format!(
67                "Unsupported browser type: {:?}",
68                profile.browser_type
69            )))
70        };
71
72        let _ = std::fs::remove_file(&temp_cookies);
73
74        result
75    }
76
77    fn read_chromium_cookies(
78        db_path: &Path,
79        domain: &str,
80        _profile: &Profile,
81    ) -> Result<Vec<Cookie>> {
82        let conn = Connection::open(db_path)?;
83
84        let mut stmt = conn.prepare(
85            "SELECT name, encrypted_value, host_key, path, expires_utc, is_secure, is_httponly, samesite
86             FROM cookies
87             WHERE host_key LIKE ?1 OR host_key LIKE ?2
88             ORDER BY creation_utc DESC",
89        )?;
90
91        let domain_pattern = format!("%{domain}");
92        let dot_domain_pattern = format!("%.{domain}");
93
94        let cookie_iter = stmt.query_map(
95            rusqlite::params![&domain_pattern, &dot_domain_pattern],
96            |row| {
97                let name: String = row.get(0)?;
98                let encrypted_value: Vec<u8> = row.get(1)?;
99                let host_key: String = row.get(2)?;
100                let path: String = row.get(3)?;
101                let expires_utc: i64 = row.get(4)?;
102                let is_secure: bool = row.get(5)?;
103                let is_httponly: bool = row.get(6)?;
104                let same_site: i32 = row.get(7)?;
105
106                Ok((
107                    name,
108                    encrypted_value,
109                    host_key,
110                    path,
111                    expires_utc,
112                    is_secure,
113                    is_httponly,
114                    same_site,
115                ))
116            },
117        )?;
118
119        let mut cookies = Vec::new();
120
121        for cookie_result in cookie_iter {
122            let (
123                name,
124                encrypted_value,
125                host_key,
126                path,
127                expires_utc,
128                is_secure,
129                is_httponly,
130                same_site,
131            ) = cookie_result?;
132
133            match crypto::decrypt_cookie_value(&encrypted_value) {
134                Ok(value) => {
135                    cookies.push(Cookie {
136                        name,
137                        value,
138                        domain: host_key,
139                        path,
140                        expires_utc,
141                        is_secure,
142                        is_httponly,
143                        same_site,
144                    });
145                }
146                Err(e) => {
147                    eprintln!("  [warn] Failed to decrypt cookie '{name}': {e}");
148                }
149            }
150        }
151
152        if cookies.is_empty() {
153            return Err(CookieReaderError::NoCookiesFound(domain.to_string()));
154        }
155
156        Ok(cookies)
157    }
158
159    fn read_firefox_cookies(db_path: &Path, domain: &str) -> Result<Vec<Cookie>> {
160        let conn = Connection::open(db_path)?;
161
162        let mut stmt = conn.prepare(
163            "SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite
164             FROM moz_cookies
165             WHERE host LIKE ?1 OR host LIKE ?2
166             ORDER BY creationTime DESC",
167        )?;
168
169        let domain_pattern = format!("%{domain}");
170        let dot_domain_pattern = format!("%.{domain}");
171
172        let cookie_iter = stmt.query_map(
173            rusqlite::params![&domain_pattern, &dot_domain_pattern],
174            |row| {
175                let name: String = row.get(0)?;
176                let value: String = row.get(1)?;
177                let host: String = row.get(2)?;
178                let path: String = row.get(3)?;
179                let expiry: i64 = row.get(4)?;
180                let is_secure: i32 = row.get(5)?;
181                let is_httponly: i32 = row.get(6)?;
182                let same_site: i32 = row.get(7)?;
183
184                Ok((
185                    name,
186                    value,
187                    host,
188                    path,
189                    expiry,
190                    is_secure,
191                    is_httponly,
192                    same_site,
193                ))
194            },
195        )?;
196
197        let mut cookies = Vec::new();
198
199        for cookie_result in cookie_iter {
200            let (name, value, host, path, expiry, is_secure, is_httponly, same_site) =
201                cookie_result?;
202
203            let cookie = Cookie {
204                name,
205                value,
206                domain: host,
207                path,
208                expires_utc: expiry * 1_000_000 + 11_644_473_600_000_000,
209                is_secure: is_secure != 0,
210                is_httponly: is_httponly != 0,
211                same_site,
212            };
213
214            cookies.push(cookie);
215        }
216
217        if cookies.is_empty() {
218            return Err(CookieReaderError::NoCookiesFound(domain.to_string()));
219        }
220
221        Ok(cookies)
222    }
223
224    fn read_safari_cookies(cookies_path: &Path, domain: &str) -> Result<Vec<Cookie>> {
225        let data = std::fs::read(cookies_path)?;
226
227        if data.len() < 8 {
228            return Err(CookieReaderError::NoCookiesFound(
229                "Invalid Safari cookies file".to_string(),
230            ));
231        }
232
233        let mut pos = 4;
234        let num_pages =
235            u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
236        pos += 4;
237
238        let mut cookies = Vec::new();
239
240        for _ in 0..num_pages {
241            if pos + 4 > data.len() {
242                break;
243            }
244
245            let page_offset =
246                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
247                    as usize;
248            pos += 4;
249
250            if page_offset >= data.len() {
251                continue;
252            }
253
254            let page_cookies = Self::parse_safari_page(&data, page_offset, domain);
255            cookies.extend(page_cookies);
256        }
257
258        if cookies.is_empty() {
259            return Err(CookieReaderError::NoCookiesFound(domain.to_string()));
260        }
261
262        Ok(cookies)
263    }
264
265    fn parse_safari_page(data: &[u8], offset: usize, domain: &str) -> Vec<Cookie> {
266        let mut pos = offset + 4;
267
268        if pos + 4 > data.len() {
269            return Vec::new();
270        }
271
272        let num_cookies =
273            u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
274        pos += 4;
275
276        let mut cookie_offsets = Vec::new();
277        for _ in 0..num_cookies {
278            if pos + 4 > data.len() {
279                break;
280            }
281            let cookie_offset =
282                u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
283                    as usize;
284            cookie_offsets.push(offset + cookie_offset);
285            pos += 4;
286        }
287
288        let mut cookies = Vec::new();
289        for cookie_offset in cookie_offsets {
290            if let Ok(Some(cookie)) = Self::parse_safari_cookie(data, cookie_offset, domain) {
291                cookies.push(cookie);
292            }
293        }
294
295        cookies
296    }
297
298    fn parse_safari_cookie(
299        data: &[u8],
300        offset: usize,
301        filter_domain: &str,
302    ) -> Result<Option<Cookie>> {
303        let mut pos = offset;
304
305        if pos + 4 > data.len() {
306            return Ok(None);
307        }
308
309        let _cookie_size =
310            u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
311        pos += 4;
312
313        if pos + 8 > data.len() {
314            return Ok(None);
315        }
316
317        pos += 8;
318
319        if pos + 16 > data.len() {
320            return Ok(None);
321        }
322
323        let flags = u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
324        pos += 4;
325
326        pos += 4;
327
328        let url_offset =
329            u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
330        pos += 4;
331        let name_offset =
332            u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
333        pos += 4;
334        let path_offset =
335            u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
336        pos += 4;
337        let value_offset =
338            u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
339        pos += 4;
340
341        if pos + 8 > data.len() {
342            return Ok(None);
343        }
344
345        let expiry_bytes = [
346            data[pos],
347            data[pos + 1],
348            data[pos + 2],
349            data[pos + 3],
350            data[pos + 4],
351            data[pos + 5],
352            data[pos + 6],
353            data[pos + 7],
354        ];
355        let expiry = f64::from_le_bytes(expiry_bytes);
356
357        let domain = Self::read_safari_string(data, offset + url_offset)?;
358        let name = Self::read_safari_string(data, offset + name_offset)?;
359        let path = Self::read_safari_string(data, offset + path_offset)?;
360        let value = Self::read_safari_string(data, offset + value_offset)?;
361
362        if !domain.contains(filter_domain) {
363            return Ok(None);
364        }
365
366        // Safari stores expiry as seconds since Mac OS X epoch (2001-01-01).
367        // Convert to Windows epoch microseconds (1601-01-01).
368        let expiry_mac_secs = f64_to_i64_saturating((expiry + 978_307_200.0).round());
369        let expires_utc = expiry_mac_secs
370            .saturating_mul(1_000_000)
371            .saturating_add(11_644_473_600_000_000);
372
373        Ok(Some(Cookie {
374            name,
375            value,
376            domain,
377            path,
378            expires_utc,
379            is_secure: (flags & 0x1) != 0,
380            is_httponly: (flags & 0x4) != 0,
381            same_site: 0,
382        }))
383    }
384
385    fn read_safari_string(data: &[u8], offset: usize) -> Result<String> {
386        let mut pos = offset;
387        let mut bytes = Vec::new();
388
389        while pos < data.len() && data[pos] != 0 {
390            bytes.push(data[pos]);
391            pos += 1;
392        }
393
394        String::from_utf8(bytes)
395            .map_err(|_| CookieReaderError::NoCookiesFound("Invalid UTF-8 in cookie".to_string()))
396    }
397}