f092c9f5ba
* fix(cookie_extract): close world-readable window on temp cookie copy _query_cookies_db copies the browser cookie DB (live X auth_token/ct0 session secrets) into a system temp file, then chmods it 0600. But shutil.copy2 copies the source file's mode onto the destination: Firefox cookies.sqlite is commonly 0644 (looser on WSL /mnt/c mounts), so between the copy and the chmod the decrypted secrets sat world-readable in shared /tmp, race-readable by another local user. Use shutil.copyfile, which writes content only and leaves the 0600 perms that mkstemp created intact, so the copy is never readable by others. The existing chmod is kept as defense-in-depth. Adds a regression test asserting the temp copy is 0600 the instant it exists, before the lock chmod runs. * test(cookie_extract): skip world-readable test on Windows mkstemp creates 0o666 (not 0o600) on Windows and the POSIX permission exposure does not apply there (_lock_temp_cookie_copy already no-ops on nt), so the 0o600 assertion would fail spuriously on a Windows CI runner. Matches the skipif guard on the sibling test_temp_cookie_db_copy_is_owner_only.
500 lines
18 KiB
Python
500 lines
18 KiB
Python
"""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.
|
|
Note: Chrome/Brave extraction is macOS-only; Windows Chrome/Edge use
|
|
DPAPI-encrypted stores that are not yet supported.
|
|
|
|
Only uses Python stdlib — no external dependencies.
|
|
"""
|
|
|
|
import configparser
|
|
import functools
|
|
import logging
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import sqlite3
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _lock_temp_cookie_copy(path: str) -> None:
|
|
"""Restrict copied cookie DB temp files to the current user on POSIX."""
|
|
if os.name == "nt":
|
|
return
|
|
Path(path).chmod(0o600)
|
|
|
|
|
|
@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")
|
|
# mkstemp creates the file 0600. copy2 would copy the source's mode
|
|
# (Firefox cookies.sqlite is commonly 0644, looser on WSL /mnt/c) onto
|
|
# the temp file, leaving live session secrets world-readable in shared
|
|
# /tmp until the chmod below runs. copyfile writes content only and
|
|
# leaves the 0600 perms intact, closing that window.
|
|
shutil.copyfile(str(db_path), tmp_path)
|
|
_lock_temp_cookie_copy(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.
|
|
|
|
Tries the default profile first, then falls back to scanning all
|
|
profiles for matching cookies. This handles multi-profile setups
|
|
where the user is logged into x.com on a non-default profile.
|
|
"""
|
|
default_profile = _find_default_profile(profiles_dir)
|
|
profiles_tried = 0
|
|
if default_profile is not None:
|
|
result = _query_cookies_db(default_profile / "cookies.sqlite", domain, cookie_names)
|
|
if result is not None:
|
|
return result
|
|
profiles_tried = 1
|
|
# Fallback: scan every profile directory for matching cookies
|
|
try:
|
|
for child in sorted(profiles_dir.iterdir()):
|
|
if not child.is_dir():
|
|
continue
|
|
if default_profile is not None and child == default_profile:
|
|
continue
|
|
db = child / "cookies.sqlite"
|
|
if db.is_file():
|
|
result = _query_cookies_db(db, domain, cookie_names)
|
|
if result is not None:
|
|
return result
|
|
profiles_tried += 1
|
|
except OSError:
|
|
pass
|
|
logger.debug("No matching cookies found in %d Firefox profile(s)", profiles_tried)
|
|
return None
|
|
|
|
|
|
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_chromium_family_cookies(
|
|
browser: str, domain: str, cookie_names: List[str]
|
|
) -> Optional[Dict[str, str]]:
|
|
"""Extract cookies from a non-Chrome/Brave Chromium browser on macOS.
|
|
|
|
macOS only — Edge, Vivaldi, Opera, Arc, and Chromium all reuse Chrome's
|
|
v10 AES-128-CBC encryption, with their own profile path and Keychain
|
|
service name (see chrome_cookies.CHROMIUM_BROWSER_PROFILES).
|
|
"""
|
|
if platform.system() != "Darwin":
|
|
logger.debug("%s cookie extraction only supported on macOS", browser)
|
|
return None
|
|
try:
|
|
from .chrome_cookies import extract_chromium_browser_cookies_macos
|
|
return extract_chromium_browser_cookies_macos(browser, domain, cookie_names)
|
|
except Exception as exc:
|
|
logger.debug("%s cookie extraction failed: %s", browser, exc)
|
|
return None
|
|
|
|
|
|
def extract_edge_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
|
|
"""Extract cookies from Microsoft Edge for the given domain (macOS only)."""
|
|
return _extract_chromium_family_cookies("edge", domain, cookie_names)
|
|
|
|
|
|
def extract_vivaldi_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
|
|
"""Extract cookies from Vivaldi for the given domain (macOS only)."""
|
|
return _extract_chromium_family_cookies("vivaldi", domain, cookie_names)
|
|
|
|
|
|
def extract_opera_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
|
|
"""Extract cookies from Opera for the given domain (macOS only)."""
|
|
return _extract_chromium_family_cookies("opera", domain, cookie_names)
|
|
|
|
|
|
def extract_arc_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
|
|
"""Extract cookies from Arc for the given domain (macOS only)."""
|
|
return _extract_chromium_family_cookies("arc", domain, cookie_names)
|
|
|
|
|
|
def extract_chromium_cookies(domain: str, cookie_names: List[str]) -> Optional[Dict[str, str]]:
|
|
"""Extract cookies from open-source Chromium for the given domain (macOS only)."""
|
|
return _extract_chromium_family_cookies("chromium", domain, cookie_names)
|
|
|
|
|
|
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', 'edge', 'vivaldi',
|
|
'opera', 'arc', 'chromium', 'safari', or 'auto'.
|
|
'auto' tries browsers in platform-appropriate order:
|
|
- macOS: Chrome -> Brave -> Edge -> Vivaldi -> Opera -> Arc -> Chromium -> 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', 'edge', 'vivaldi',
|
|
'opera', 'arc', 'chromium', '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,
|
|
"edge": extract_edge_cookies,
|
|
"vivaldi": extract_vivaldi_cookies,
|
|
"opera": extract_opera_cookies,
|
|
"arc": extract_arc_cookies,
|
|
"chromium": extract_chromium_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.
|
|
# Note: the skill's own entry point (env.extract_browser_credentials) builds
|
|
# its own list that tries the SILENT browsers (Firefox, Safari) first to
|
|
# avoid macOS Keychain prompts. This standalone "auto" is Chromium-first; the
|
|
# two orderings are intentional for their respective callers.
|
|
system = platform.system()
|
|
if system == "Darwin":
|
|
order = ["chrome", "brave", "edge", "vivaldi", "opera", "arc", "chromium", "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
|