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.
This commit is contained in:
Ilia Alshanetsky
2026-04-09 18:33:03 -04:00
parent 45f596ca0c
commit 252c8222f1
2 changed files with 106 additions and 18 deletions
+97 -18
View File
@@ -18,6 +18,36 @@ from typing import Dict, List, Optional
logger = logging.getLogger(__name__) 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]: def _get_firefox_profiles_dir() -> Optional[Path]:
"""Return the Firefox profiles directory for the current platform, or None.""" """Return the Firefox profiles directory for the current platform, or None."""
system = platform.system() system = platform.system()
@@ -45,12 +75,7 @@ def _find_default_profile(profiles_dir: Path) -> Optional[Path]:
config = configparser.ConfigParser() config = configparser.ConfigParser()
config.read(str(ini_path), encoding="utf-8") config.read(str(ini_path), encoding="utf-8")
# First pass: look for Default=1 # First pass: Install* section (Firefox >= 67 format, takes priority)
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)
for section in config.sections(): for section in config.sections():
if section.startswith("Install") and config.has_option(section, "Default"): if section.startswith("Install") and config.has_option(section, "Default"):
raw = config.get(section, "Default") raw = config.get(section, "Default")
@@ -58,6 +83,11 @@ def _find_default_profile(profiles_dir: Path) -> Optional[Path]:
if candidate.is_dir(): if candidate.is_dir():
return candidate 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 # Third pass: first Profile section that exists on disk
for section in config.sections(): for section in config.sections():
if section.startswith("Profile"): if section.startswith("Profile"):
@@ -153,6 +183,15 @@ def _query_cookies_db(
pass 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( def extract_firefox_cookies(
domain: str, cookie_names: List[str] domain: str, cookie_names: List[str]
) -> Optional[Dict[str, 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 Finds the default Firefox profile, copies cookies.sqlite to a temp file
(to avoid lock conflicts), and queries for the requested cookies. (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: Args:
domain: The cookie domain to match (e.g. ".x.com"). Matched with LIKE %domain. 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"]). 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. Dict of {cookie_name: cookie_value} or None if extraction fails.
""" """
profiles_dir = _get_firefox_profiles_dir() 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: if profiles_dir is None:
logger.debug("Firefox profiles directory not found") logger.debug("Firefox profiles directory not found")
return None 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)
def extract_chrome_cookies( def extract_chrome_cookies(
@@ -248,6 +294,31 @@ def extract_cookies(
return 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( def extract_cookies_with_source(
browser: str, domain: str, cookie_names: list[str] browser: str, domain: str, cookie_names: list[str]
) -> Optional[tuple[dict[str, str], str]]: ) -> Optional[tuple[dict[str, str], str]]:
@@ -263,6 +334,7 @@ def extract_cookies_with_source(
Returns: Returns:
Tuple of ({cookie_name: cookie_value}, browser_name) or None. Tuple of ({cookie_name: cookie_value}, browser_name) or None.
browser_name is "firefox-wsl" when cookies came from Windows Firefox via WSL2.
""" """
extractors = { extractors = {
"firefox": extract_firefox_cookies, "firefox": extract_firefox_cookies,
@@ -271,6 +343,8 @@ def extract_cookies_with_source(
} }
if browser != "auto": if browser != "auto":
if browser == "firefox":
return _extract_firefox_with_source(domain, cookie_names)
extractor = extractors.get(browser) extractor = extractors.get(browser)
if extractor is None: if extractor is None:
logger.warning("Unknown browser: %s", browser) logger.warning("Unknown browser: %s", browser)
@@ -288,8 +362,13 @@ def extract_cookies_with_source(
order = ["firefox"] order = ["firefox"]
for name in order: for name in order:
result = extractors[name](domain, cookie_names) if name == "firefox":
if result is not None: result = _extract_firefox_with_source(domain, cookie_names)
return (result, name) if result is not None:
return result
else:
result = extractors[name](domain, cookie_names)
if result is not None:
return (result, name)
return None return None
+9
View File
@@ -153,6 +153,9 @@ class TestExtractFirefoxCookies:
with patch( with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir", "scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=None, return_value=None,
), patch(
"scripts.lib.cookie_extract._is_wsl",
return_value=False,
): ):
result = extract_firefox_cookies(".x.com", ["auth_token"]) result = extract_firefox_cookies(".x.com", ["auth_token"])
@@ -167,6 +170,9 @@ class TestExtractFirefoxCookies:
with patch( with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir", "scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=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"]) result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])
@@ -185,6 +191,9 @@ class TestExtractFirefoxCookies:
with patch( with patch(
"scripts.lib.cookie_extract._get_firefox_profiles_dir", "scripts.lib.cookie_extract._get_firefox_profiles_dir",
return_value=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"]) result = extract_firefox_cookies(".x.com", ["auth_token", "ct0"])