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:
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user