From 252c8222f18842c8cb594ecbbe1ccbad1331b0c3 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 9 Apr 2026 18:33:03 -0400 Subject: [PATCH] feat: add WSL2 Windows Firefox cookie extraction for X auth On WSL2, native Linux Firefox typically has no x.com cookies since users browse in Windows. Chromium browsers (Edge, Chrome, Brave) encrypt cookies with DPAPI/app-bound encryption, making them inaccessible without admin privileges. Windows Firefox stores cookies unencrypted in SQLite, readable directly through the /mnt/c mount. The cookie extractor now detects WSL2 via /proc/version, locates Windows Firefox profiles under /mnt/c/Users/*/AppData/Roaming/Mozilla/Firefox, and falls back to them when Linux Firefox yields no results. Reports source as "firefox-wsl" to distinguish from native. Also fixes profile resolution priority: Install* sections (Firefox >= 67) now take precedence over the legacy Default=1 flag, which could select a stale profile on multi-profile installations. --- scripts/lib/cookie_extract.py | 115 ++++++++++++++++++++++++++++------ tests/test_cookie_extract.py | 9 +++ 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/scripts/lib/cookie_extract.py b/scripts/lib/cookie_extract.py index 9b88e05..1f2efbf 100644 --- a/scripts/lib/cookie_extract.py +++ b/scripts/lib/cookie_extract.py @@ -18,6 +18,36 @@ from typing import Dict, List, Optional logger = logging.getLogger(__name__) +def _is_wsl() -> bool: + """Detect if running under Windows Subsystem for Linux.""" + 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() @@ -45,12 +75,7 @@ def _find_default_profile(profiles_dir: Path) -> Optional[Path]: config = configparser.ConfigParser() config.read(str(ini_path), encoding="utf-8") - # First pass: look for Default=1 - for section in config.sections(): - if config.has_option(section, "Default") and config.get(section, "Default") == "1": - return _resolve_profile_path(profiles_dir, config, section) - - # Second pass: first Install* section with Default key (Firefox >= 67 format) + # 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") @@ -58,6 +83,11 @@ def _find_default_profile(profiles_dir: Path) -> Optional[Path]: 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"): @@ -153,6 +183,15 @@ def _query_cookies_db( 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]]: @@ -161,6 +200,10 @@ def extract_firefox_cookies( 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"]). @@ -169,17 +212,20 @@ def extract_firefox_cookies( 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 - - profile_path = _find_default_profile(profiles_dir) - if profile_path is None: - logger.debug("No Firefox profile found in %s", profiles_dir) - return None - - db_path = profile_path / "cookies.sqlite" - return _query_cookies_db(db_path, domain, cookie_names) + return None def extract_chrome_cookies( @@ -248,6 +294,31 @@ def extract_cookies( 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]]: @@ -263,6 +334,7 @@ def extract_cookies_with_source( 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, @@ -271,6 +343,8 @@ def extract_cookies_with_source( } 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) @@ -288,8 +362,13 @@ def extract_cookies_with_source( order = ["firefox"] for name in order: - result = extractors[name](domain, cookie_names) - if result is not None: - return (result, name) + 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 diff --git a/tests/test_cookie_extract.py b/tests/test_cookie_extract.py index 8a9d36a..ac7663c 100644 --- a/tests/test_cookie_extract.py +++ b/tests/test_cookie_extract.py @@ -153,6 +153,9 @@ class TestExtractFirefoxCookies: with patch( "scripts.lib.cookie_extract._get_firefox_profiles_dir", return_value=None, + ), patch( + "scripts.lib.cookie_extract._is_wsl", + return_value=False, ): result = extract_firefox_cookies(".x.com", ["auth_token"]) @@ -167,6 +170,9 @@ class TestExtractFirefoxCookies: with patch( "scripts.lib.cookie_extract._get_firefox_profiles_dir", return_value=profiles_dir, + ), patch( + "scripts.lib.cookie_extract._is_wsl", + return_value=False, ): result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"]) @@ -185,6 +191,9 @@ class TestExtractFirefoxCookies: with patch( "scripts.lib.cookie_extract._get_firefox_profiles_dir", return_value=profiles_dir, + ), patch( + "scripts.lib.cookie_extract._is_wsl", + return_value=False, ): result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])