"""Browser cookie extraction for last30days. Extracts cookies from local browser databases (Firefox, Chrome, Brave, Safari) to enable zero-config authentication for services like X/Twitter. Only uses Python stdlib — no external dependencies. """ import configparser import functools import logging import platform import shutil import sqlite3 import tempfile from pathlib import Path from typing import Dict, List, Optional logger = logging.getLogger(__name__) @functools.lru_cache(maxsize=1) def _is_wsl() -> bool: """Detect if running under Windows Subsystem for Linux. Cached after the first call since /proc/version doesn't change at runtime. """ try: return "microsoft" in Path("/proc/version").read_text().lower() except OSError: return False def _get_wsl_firefox_profiles_dir() -> Optional[Path]: """Find Firefox profiles directory on the Windows host from WSL. Scans /mnt/c/Users/*/AppData/Roaming/Mozilla/Firefox for real user directories (skips Public, Default, etc.). """ mnt_users = Path("/mnt/c/Users") if not mnt_users.is_dir(): return None skip = {"Public", "Default", "Default User", "All Users"} try: for user_dir in sorted(mnt_users.iterdir()): if user_dir.name in skip or not user_dir.is_dir(): continue ff_dir = user_dir / "AppData" / "Roaming" / "Mozilla" / "Firefox" if ff_dir.is_dir(): return ff_dir except OSError: pass return None def _get_firefox_profiles_dir() -> Optional[Path]: """Return the Firefox profiles directory for the current platform, or None.""" system = platform.system() if system == "Darwin": path = Path.home() / "Library" / "Application Support" / "Firefox" elif system == "Linux": path = Path.home() / ".mozilla" / "firefox" else: # Windows: %APPDATA%\Mozilla\Firefox — best-effort appdata = Path.home() / "AppData" / "Roaming" / "Mozilla" / "Firefox" path = appdata return path if path.is_dir() else None def _find_default_profile(profiles_dir: Path) -> Optional[Path]: """Parse profiles.ini to find the default profile directory. Looks for a section with Default=1. Falls back to the first profile directory found on disk if profiles.ini is missing or malformed. """ ini_path = profiles_dir / "profiles.ini" if ini_path.is_file(): try: config = configparser.ConfigParser() config.read(str(ini_path), encoding="utf-8") # First pass: Install* section (Firefox >= 67 format, takes priority) for section in config.sections(): if section.startswith("Install") and config.has_option(section, "Default"): raw = config.get(section, "Default") candidate = profiles_dir / raw if candidate.is_dir(): return candidate # Second pass: Profile section with Default=1 for section in config.sections(): if section.startswith("Profile") and config.has_option(section, "Default") and config.get(section, "Default") == "1": return _resolve_profile_path(profiles_dir, config, section) # Third pass: first Profile section that exists on disk for section in config.sections(): if section.startswith("Profile"): resolved = _resolve_profile_path(profiles_dir, config, section) if resolved and resolved.is_dir(): return resolved except (configparser.Error, OSError) as exc: logger.debug("Failed to parse profiles.ini: %s", exc) # Fallback: scan directory for anything that looks like a profile return _fallback_find_profile(profiles_dir) def _resolve_profile_path( profiles_dir: Path, config: configparser.ConfigParser, section: str ) -> Optional[Path]: """Resolve a profile path from a ConfigParser section.""" if not config.has_option(section, "Path"): return None raw_path = config.get(section, "Path") is_relative = config.has_option(section, "IsRelative") and config.get(section, "IsRelative") == "1" if is_relative: candidate = profiles_dir / raw_path else: candidate = Path(raw_path) return candidate if candidate.is_dir() else None def _fallback_find_profile(profiles_dir: Path) -> Optional[Path]: """Find the first directory that contains cookies.sqlite.""" try: for child in sorted(profiles_dir.iterdir()): if child.is_dir() and (child / "cookies.sqlite").is_file(): return child except OSError: pass return None def _query_cookies_db( db_path: Path, domain: str, cookie_names: List[str] ) -> Optional[Dict[str, str]]: """Copy the cookies database to a temp file and query it. Firefox locks cookies.sqlite while running, so we copy first. Returns {name: value} dict or None if no matching cookies found. """ if not db_path.is_file(): return None tmp_fd = None tmp_path = None try: tmp_fd, tmp_path = tempfile.mkstemp(suffix=".sqlite") shutil.copy2(str(db_path), tmp_path) conn = sqlite3.connect(tmp_path) try: # Build parameterized query — SQLite doesn't support array params, # so we build the IN clause with individual placeholders. placeholders = ",".join("?" for _ in cookie_names) query = ( f"SELECT name, value FROM moz_cookies " f"WHERE host LIKE ? AND name IN ({placeholders})" ) # domain pattern: match .x.com, x.com, etc. domain_pattern = f"%{domain}" params = [domain_pattern] + list(cookie_names) cursor = conn.execute(query, params) rows = cursor.fetchall() finally: conn.close() if not rows: return None return {name: value for name, value in rows} except (sqlite3.Error, OSError) as exc: logger.debug("Failed to query cookies database %s: %s", db_path, exc) return None finally: if tmp_path: try: Path(tmp_path).unlink(missing_ok=True) except OSError: pass if tmp_fd is not None: try: import os os.close(tmp_fd) except OSError: pass def _try_firefox_dir(profiles_dir: Path, domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]: """Try to extract cookies from a Firefox profiles directory.""" profile_path = _find_default_profile(profiles_dir) if profile_path is None: logger.debug("No Firefox profile found in %s", profiles_dir) return None return _query_cookies_db(profile_path / "cookies.sqlite", domain, cookie_names) def extract_firefox_cookies( domain: str, cookie_names: List[str] ) -> Optional[Dict[str, str]]: """Extract cookies from Firefox for the given domain and cookie names. Finds the default Firefox profile, copies cookies.sqlite to a temp file (to avoid lock conflicts), and queries for the requested cookies. On WSL2, falls back to Windows Firefox if native Linux Firefox has no matching cookies. Windows Firefox cookies are unencrypted, so this works without DPAPI or any Windows-side helpers. Args: domain: The cookie domain to match (e.g. ".x.com"). Matched with LIKE %domain. cookie_names: List of cookie names to extract (e.g. ["auth_token", "ct0"]). Returns: Dict of {cookie_name: cookie_value} or None if extraction fails. """ profiles_dir = _get_firefox_profiles_dir() if profiles_dir is not None: result = _try_firefox_dir(profiles_dir, domain, cookie_names) if result is not None: return result if platform.system() == "Linux" and _is_wsl(): wsl_dir = _get_wsl_firefox_profiles_dir() if wsl_dir is not None: logger.debug("Trying Windows Firefox via WSL: %s", wsl_dir) return _try_firefox_dir(wsl_dir, domain, cookie_names) if profiles_dir is None: logger.debug("Firefox profiles directory not found") return None def extract_chrome_cookies( domain: str, cookie_names: List[str] ) -> Optional[Dict[str, str]]: """Extract cookies from Chrome for the given domain and cookie names. macOS only — uses Keychain + system openssl for AES-128-CBC decryption. Linux/Windows not supported (Chrome uses platform-specific encryption). Returns: Dict of {cookie_name: cookie_value} or None if extraction fails. """ if platform.system() != "Darwin": logger.debug("Chrome cookie extraction only supported on macOS") return None try: from .chrome_cookies import extract_chrome_cookies_macos return extract_chrome_cookies_macos(domain, cookie_names) except Exception as exc: logger.debug("Chrome cookie extraction failed: %s", exc) return None def extract_brave_cookies( domain: str, cookie_names: List[str] ) -> Optional[Dict[str, str]]: """Extract cookies from Brave for the given domain and cookie names. macOS only — Brave uses the same v10 AES-128-CBC encryption as Chrome, with a different DB path and Keychain service name ("Brave Safe Storage"). Tries the Default profile first, then scans numbered Profile directories. Returns: Dict of {cookie_name: cookie_value} or None if extraction fails. """ if platform.system() != "Darwin": logger.debug("Brave cookie extraction only supported on macOS") return None try: from .chrome_cookies import extract_brave_cookies_macos return extract_brave_cookies_macos(domain, cookie_names) except Exception as exc: logger.debug("Brave cookie extraction failed: %s", exc) return None def extract_safari_cookies( domain: str, cookie_names: List[str] ) -> Optional[Dict[str, str]]: """Extract cookies from Safari for the given domain and cookie names. macOS only — parses the unencrypted binary cookie file. Returns: Dict of {cookie_name: cookie_value} or None if extraction fails. """ if platform.system() != "Darwin": logger.debug("Safari cookie extraction only supported on macOS") return None try: from .safari_cookies import extract_safari_cookies_macos return extract_safari_cookies_macos(domain, cookie_names) except Exception as exc: logger.debug("Safari cookie extraction failed: %s", exc) return None def extract_cookies( browser: str, domain: str, cookie_names: list[str] ) -> Optional[dict[str, str]]: """Extract cookies from the specified browser. Args: browser: One of 'firefox', 'chrome', 'brave', 'safari', or 'auto'. 'auto' tries browsers in platform-appropriate order: - macOS: Chrome -> Brave -> Firefox -> Safari - Linux: Firefox only domain: The cookie domain to match (e.g. ".x.com"). cookie_names: List of cookie names to extract. Returns: Dict of {cookie_name: cookie_value} or None if extraction fails. """ result = extract_cookies_with_source(browser, domain, cookie_names) if result is None: return None cookies, _browser_name = result return cookies def _extract_firefox_with_source( domain: str, cookie_names: List[str] ) -> Optional[tuple[Dict[str, str], str]]: """Extract Firefox cookies and report whether they came from native or WSL. Returns (cookies, "firefox") for native Linux/macOS Firefox, or (cookies, "firefox-wsl") for Windows Firefox accessed via WSL2. """ profiles_dir = _get_firefox_profiles_dir() if profiles_dir is not None: result = _try_firefox_dir(profiles_dir, domain, cookie_names) if result is not None: return (result, "firefox") if platform.system() == "Linux" and _is_wsl(): wsl_dir = _get_wsl_firefox_profiles_dir() if wsl_dir is not None: logger.debug("Trying Windows Firefox via WSL: %s", wsl_dir) result = _try_firefox_dir(wsl_dir, domain, cookie_names) if result is not None: return (result, "firefox-wsl") return None def extract_cookies_with_source( browser: str, domain: str, cookie_names: list[str] ) -> Optional[tuple[dict[str, str], str]]: """Extract cookies and report which browser they came from. Same as extract_cookies() but returns a (cookies, browser_name) tuple so callers can track the source. Args: browser: One of 'firefox', 'chrome', 'brave', 'safari', or 'auto'. domain: The cookie domain to match (e.g. ".x.com"). cookie_names: List of cookie names to extract. Returns: Tuple of ({cookie_name: cookie_value}, browser_name) or None. browser_name is "firefox-wsl" when cookies came from Windows Firefox via WSL2. """ extractors = { "firefox": extract_firefox_cookies, "chrome": extract_chrome_cookies, "brave": extract_brave_cookies, "safari": extract_safari_cookies, } if browser != "auto": if browser == "firefox": return _extract_firefox_with_source(domain, cookie_names) extractor = extractors.get(browser) if extractor is None: logger.warning("Unknown browser: %s", browser) return None result = extractor(domain, cookie_names) return (result, browser) if result is not None else None # Auto mode: try browsers in platform-appropriate order system = platform.system() if system == "Darwin": order = ["chrome", "brave", "firefox", "safari"] elif system == "Linux": order = ["firefox"] else: order = ["firefox"] for name in order: if name == "firefox": result = _extract_firefox_with_source(domain, cookie_names) if result is not None: return result else: result = extractors[name](domain, cookie_names) if result is not None: return (result, name) return None