feat: exhaustive feature expansion — cookie modes, DNS/WebRTC testers, map, Firefox fix
Cookie system: - Expand from 5 to 12 fully-specified CookiePolicy modes in browser_identity.py - Add CookiePolicy dataclass: behavior, lifetime, TCP partitioning, clearOnShutdown.* - browser_profile.py emits full cookie pref set from policy object - UI dropdown widened to 680px with live description label per mode Firefox launch fix: - Detect Firefox via Windows registry, AppData, and shutil.which - Use DETACHED_PROCESS|CREATE_NO_WINDOW|CREATE_NEW_PROCESS_GROUP flags - Wait up to 3s for firefox.exe in tasklist instead of polling parent pid - taskkill on stop() to terminate all firefox.exe processes DNS leak tester: - dns_leak.py: FullDnsLeakReport dataclass, run_dns_leak_test comparing proxy DoH resolution vs direct system DNS WebRTC tester: - webrtc_check.py: STUN UDP probe, registry policy check, user.js pref check Ban tester: - Added SITES_SHOPPING, SITES_CRYPTO, SITES_DNS categories - Parallel execution via ThreadPoolExecutor - Expanded banned-text hint keywords Fingerprint audit: - OS identity checks: hostname, MAC, GUID, OS version, timezone, screen res - Browser consistency analysis of user.js Neon world map: - world_map.png bundled; chain_map.py renders hop arcs over it with glow effect Signup prep: - Auto-save account on Open & Autofill; Copy Email / Copy Pass buttons - Auto-fill custom URL when preset site selected - PyInstaller-safe path resolution for signup_extension Spec: - Bundle signup_extension dir and world_map.png as PyInstaller data files Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import winreg
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -14,6 +15,14 @@ from .signup_prep import SignupDraft, install_signup_extension
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# CREATE_NO_WINDOW suppresses the console; DETACHED_PROCESS fully separates
|
||||
# the child from Python's job object so Firefox lives past Python's exit.
|
||||
_CREATE_FLAGS = (
|
||||
getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserConfig:
|
||||
@@ -35,11 +44,74 @@ class BrowserConfig:
|
||||
cookie_mode: str = "block_third_party"
|
||||
|
||||
|
||||
def _find_firefox_via_registry() -> str:
|
||||
"""Look up Firefox install path in the Windows registry."""
|
||||
hives = [
|
||||
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Mozilla\Mozilla Firefox"),
|
||||
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Mozilla\Mozilla Firefox"),
|
||||
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Mozilla\Mozilla Firefox"),
|
||||
]
|
||||
for hive, key_path in hives:
|
||||
try:
|
||||
with winreg.OpenKey(hive, key_path) as root:
|
||||
version = winreg.QueryValue(root, None)
|
||||
for sub in (f"{version}\\Main", "bin"):
|
||||
try:
|
||||
with winreg.OpenKey(root, sub) as k:
|
||||
path = winreg.QueryValueEx(k, "PathToExe")[0]
|
||||
if path and Path(path).is_file():
|
||||
return str(path)
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Also try the "Uninstall" key
|
||||
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||||
for root_path in (
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
||||
r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
|
||||
):
|
||||
try:
|
||||
with winreg.OpenKey(hive, root_path) as base:
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
sub = winreg.EnumKey(base, i)
|
||||
i += 1
|
||||
if "firefox" not in sub.lower():
|
||||
continue
|
||||
with winreg.OpenKey(base, sub) as k:
|
||||
loc = winreg.QueryValueEx(k, "InstallLocation")[0]
|
||||
candidate = Path(loc) / "firefox.exe"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
except OSError:
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def default_firefox_path() -> str:
|
||||
candidates = [
|
||||
"""Find Firefox.exe — registry first, then well-known paths."""
|
||||
# 1) Registry
|
||||
reg = _find_firefox_via_registry()
|
||||
if reg:
|
||||
return reg
|
||||
|
||||
# 2) Standard install locations
|
||||
candidates: list[Path] = [
|
||||
Path(r"C:\Program Files\Mozilla Firefox\firefox.exe"),
|
||||
Path(r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe"),
|
||||
Path(os.path.expandvars(r"%LOCALAPPDATA%\Mozilla Firefox\firefox.exe")),
|
||||
Path(os.path.expandvars(r"%APPDATA%\Mozilla Firefox\firefox.exe")),
|
||||
]
|
||||
# 3) Scoop / winget / portable in PATH
|
||||
which = shutil.which("firefox") or shutil.which("firefox.exe")
|
||||
if which:
|
||||
candidates.insert(0, Path(which))
|
||||
|
||||
for c in candidates:
|
||||
if c.is_file():
|
||||
return str(c)
|
||||
@@ -50,13 +122,39 @@ def default_profile_dir() -> str:
|
||||
return str(app_data_dir() / "browser_profiles" / "firefox_hardened")
|
||||
|
||||
|
||||
def _firefox_is_running_on_system() -> bool:
|
||||
"""Check if any firefox.exe process is alive (tasklist, no admin needed)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["tasklist", "/FI", "IMAGENAME eq firefox.exe", "/NH", "/FO", "CSV"],
|
||||
capture_output=True, text=True, timeout=6,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
|
||||
)
|
||||
return "firefox.exe" in (r.stdout or "").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class BrowserSession:
|
||||
def __init__(self) -> None:
|
||||
self._proc: subprocess.Popen[str] | None = None
|
||||
self._profile_path: Path | None = None
|
||||
self._launched_at: float = 0.0
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
if self._proc is None:
|
||||
return False
|
||||
# Firefox's initial launcher process exits quickly (code 0) while
|
||||
# browser child processes carry on. Poll the parent process but also
|
||||
# fall back to checking the system process list so the GUI doesn't
|
||||
# falsely report "stopped".
|
||||
if self._proc.poll() is None:
|
||||
return True
|
||||
# Parent has exited — check if any firefox.exe is still alive AND we
|
||||
# launched within the last 5 minutes (to avoid false positives from
|
||||
# unrelated browser sessions).
|
||||
age = time.monotonic() - self._launched_at
|
||||
return age < 300 and _firefox_is_running_on_system()
|
||||
|
||||
def pid(self) -> int | None:
|
||||
if self._proc is None:
|
||||
@@ -72,11 +170,22 @@ class BrowserSession:
|
||||
signup_draft: SignupDraft | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
raw_exe = (cfg.firefox_path or default_firefox_path()).strip().strip('"').strip("'")
|
||||
if not raw_exe:
|
||||
return (
|
||||
False,
|
||||
"Firefox not found. Install Firefox then set the path in the Browser tab "
|
||||
"(or browse to it with the Browse button).",
|
||||
)
|
||||
exe = Path(raw_exe)
|
||||
if not exe.is_file():
|
||||
return False, f"Firefox executable not found: {raw_exe or '(empty path)'}"
|
||||
return False, (
|
||||
f"Firefox executable not found: {raw_exe} "
|
||||
"Use the Browse button on the Browser tab to locate firefox.exe."
|
||||
)
|
||||
|
||||
raw_profile = (cfg.profile_dir or default_profile_dir()).strip().strip('"').strip("'")
|
||||
profile_dir = Path(raw_profile)
|
||||
|
||||
hard = FirefoxHardening(
|
||||
force_proxy=cfg.force_proxy,
|
||||
disable_webrtc=cfg.disable_webrtc,
|
||||
@@ -89,46 +198,88 @@ class BrowserSession:
|
||||
persona_key=cfg.persona,
|
||||
cookie_mode=cfg.cookie_mode,
|
||||
)
|
||||
if cfg.lock_managed_profile:
|
||||
ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard)
|
||||
if signup_draft is not None:
|
||||
install_signup_extension(profile_dir, signup_draft)
|
||||
|
||||
try:
|
||||
if cfg.lock_managed_profile:
|
||||
ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard)
|
||||
if signup_draft is not None:
|
||||
install_signup_extension(profile_dir, signup_draft)
|
||||
except Exception as e:
|
||||
return False, f"Profile setup failed: {e!s}"
|
||||
|
||||
if self.is_running():
|
||||
self.stop()
|
||||
|
||||
if cfg.lock_managed_profile:
|
||||
args = [str(exe), "-no-remote", "-profile", str(profile_dir)]
|
||||
else:
|
||||
args = [str(exe)]
|
||||
|
||||
url = (start_url or "").strip()
|
||||
if url:
|
||||
args.append(url)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(persona_env(hard))
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
args,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
# DETACHED_PROCESS ensures Firefox outlives Python and is not
|
||||
# killed when Python's job object exits. CREATE_NO_WINDOW
|
||||
# suppresses any console window. CREATE_NEW_PROCESS_GROUP
|
||||
# isolates Ctrl-C handling.
|
||||
creationflags=_CREATE_FLAGS,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
# Smoke-check: catch immediate startup crashes and report clearly.
|
||||
time.sleep(0.4)
|
||||
rc = self._proc.poll()
|
||||
if rc is not None:
|
||||
self._proc = None
|
||||
return False, f"Firefox exited immediately (code {rc}). Check path/profile."
|
||||
self._profile_path = profile_dir if cfg.lock_managed_profile else None
|
||||
log.info("Launched hardened Firefox pid=%s profile=%s", self._proc.pid, profile_dir)
|
||||
if cfg.lock_managed_profile:
|
||||
return True, f"Hardened Firefox started (pid {self._proc.pid})."
|
||||
return True, f"Firefox started (unlocked mode, pid {self._proc.pid})."
|
||||
except Exception as e:
|
||||
self._proc = None
|
||||
return False, f"Launch failed: {e!s}"
|
||||
|
||||
self._launched_at = time.monotonic()
|
||||
|
||||
# Firefox's multi-process launcher exits (code 0) within ~0.5 s while
|
||||
# the actual browser window loads in child processes. We therefore do
|
||||
# NOT treat a quick exit of the parent as an error — we just verify
|
||||
# that *some* firefox.exe appears in the process list within 3 s.
|
||||
deadline = time.monotonic() + 3.0
|
||||
found = False
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(0.3)
|
||||
rc = self._proc.poll()
|
||||
# If it already exited with an error code, report it.
|
||||
if rc is not None and rc not in (0, None):
|
||||
self._proc = None
|
||||
return False, f"Firefox exited with error code {rc}."
|
||||
if _firefox_is_running_on_system():
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
# One last check — maybe the profile write was the only action needed
|
||||
# and Firefox opened fast; accept success if parent is still alive.
|
||||
if self._proc.poll() is None:
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
self._proc = None
|
||||
return False, (
|
||||
"Firefox did not appear in the process list after 3 s. "
|
||||
"Check that the path is correct and Firefox is not already blocked by another -profile lock."
|
||||
)
|
||||
|
||||
self._profile_path = profile_dir if cfg.lock_managed_profile else None
|
||||
pid = self._proc.pid
|
||||
log.info("Launched hardened Firefox pid=%s profile=%s", pid, profile_dir)
|
||||
mode = "hardened profile" if cfg.lock_managed_profile else "unlocked mode"
|
||||
return True, f"Firefox started ({mode}, pid {pid})."
|
||||
|
||||
def stop(self, dispose: bool = False) -> tuple[bool, str]:
|
||||
# Terminate the Popen handle if still alive
|
||||
if self._proc and self._proc.poll() is None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
@@ -140,9 +291,24 @@ class BrowserSession:
|
||||
pass
|
||||
finally:
|
||||
self._proc = None
|
||||
|
||||
# Also kill any remaining firefox.exe processes (handles the
|
||||
# detached-child case where the parent already exited naturally)
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/IM", "firefox.exe"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._proc = None
|
||||
if dispose and self._profile_path and self._profile_path.exists():
|
||||
try:
|
||||
shutil.rmtree(self._profile_path, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
self._launched_at = 0.0
|
||||
return True, "Browser stopped."
|
||||
|
||||
Reference in New Issue
Block a user