fix: audit round 2 - DPAPI secrets, pinned hop probe, gost exe hash, admin guard, PID-scoped browser tracking, emergency disengage button, build sidecar
Some checks failed
CI / Test Python 3.10 (push) Has been cancelled
CI / Test Python 3.11 (push) Has been cancelled
CI / Test Python 3.12 (push) Has been cancelled

This commit is contained in:
Dr Jones
2026-05-22 18:07:07 -07:00
parent 04d486a335
commit ad56f75e8a
12 changed files with 502 additions and 104 deletions

View File

@@ -168,7 +168,8 @@ def _main_inner() -> None:
ctk.set_default_color_theme("dark-blue")
root = ctk.CTk()
root.title("Proxy God v2")
from . import __version__ as _APP_VERSION
root.title(f"Proxy God v{_APP_VERSION}")
root.geometry("1080x860")
root.minsize(900, 720)
root.configure(fg_color=BG)
@@ -225,6 +226,7 @@ def _main_inner() -> None:
browser = BrowserSession()
browser_should_run = [False]
browser_last_launch_ts = [0.0]
browser_relaunch_count = [0]
service_running = [False]
# ── tray ─────────────────────────────────────────────────────────────────
@@ -305,6 +307,26 @@ def _main_inner() -> None:
def _start() -> None:
_save_settings()
# Hard-stop: if the kill-switch is enabled but we are not admin, the
# firewall lockdown is silently skipped — which contradicts the
# README's "fail closed" guarantee. Refuse to start in that mode.
try:
from .firewall import is_admin as _is_admin
except Exception:
_is_admin = lambda: True # noqa: E731
if svc.settings.kill_switch_enabled and not _is_admin():
from tkinter import messagebox
if messagebox.askyesno(
"Kill-switch requires Admin",
"Kill-switch is enabled in Settings but this process is not "
"running as Administrator.\n\n"
"Continuing now will start the chain WITHOUT the firewall "
"lockdown — your traffic will not 'fail closed' if the chain "
"drops.\n\n"
"Start anyway?",
):
svc.start()
return
svc.start()
start_btn = _btn(topbar, "▶ Start", _start, w=86)
@@ -1442,12 +1464,14 @@ def _main_inner() -> None:
_save_settings()
cfg = _browser_cfg()
browser_should_run[0] = True
browser_relaunch_count[0] = 0 # user-initiated launch resets the cap
if _launch_browser_with_cfg(cfg):
browser_last_launch_ts[0] = time.monotonic()
def _stop_browser() -> None:
cfg = _browser_cfg()
browser_should_run[0] = False
browser_relaunch_count[0] = 0
ok, msg = browser.stop(dispose=cfg.disposable_profile)
_log(msg)
_refresh_browser_status()
@@ -2637,6 +2661,34 @@ def _main_inner() -> None:
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
).pack(side="left")
# Emergency disengage — for crashes where the kill-switch lingers and
# the operator needs internet back fast without restarting the app.
def _emergency_disengage_now() -> None:
from tkinter import messagebox
from .firewall import emergency_disengage as _ed, is_engaged as _ie
if not _ie():
messagebox.showinfo("Kill-switch", "No Proxy God firewall rules detected — nothing to disengage.")
return
if not messagebox.askyesno(
"Emergency disengage",
"Remove ALL Proxy God firewall rules and restore normal outbound traffic?\n\n"
"Use this only when the chain has dropped and the kill-switch is "
"blocking everything.",
):
return
_ed()
messagebox.showinfo("Kill-switch", "Firewall rules removed.")
_log("Emergency disengage: firewall rules removed by operator.")
ks_btn_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
ks_btn_row.pack(fill="x", padx=12, pady=(0, 8))
_btn(
ks_btn_row, "⚠ Emergency disengage firewall now",
_emergency_disengage_now,
w=320, h=28,
fg_color="#7f1d1d", hover_color="#991b1b",
).pack(side="left")
def _apply_point_and_shoot() -> None:
"""Simple safe defaults: secure + low-friction launch profile."""
use_manual_var.set(True)
@@ -2921,9 +2973,18 @@ def _main_inner() -> None:
if browser_should_run[0] and not browser.is_running():
cfg = _browser_cfg()
now = time.monotonic()
max_retries = max(0, int(svc.settings.max_browser_relaunches))
if cfg.auto_relaunch and (now - browser_last_launch_ts[0]) >= 3.0:
if _launch_browser_with_cfg(cfg):
if browser_relaunch_count[0] >= max_retries:
browser_should_run[0] = False
_log(
f"Browser auto-relaunch disabled after "
f"{browser_relaunch_count[0]} attempts — fix the profile "
"or restart manually."
)
elif _launch_browser_with_cfg(cfg):
browser_last_launch_ts[0] = now
browser_relaunch_count[0] += 1
root.after(80, _pump)
# ─────────────────────────────────────────────────────────────────────────

View File

@@ -161,66 +161,10 @@ def _dns_resolve_via_system(hostname: str, timeout: float = 5.0) -> str | None:
return None
@dataclass(frozen=True)
class DnsLeakResult:
resolver: str
ip_via_proxy: str | None
ip_direct: str | None
leaked: bool
detail: str
def check_dns_leak(
proxy_url: str,
timeout_seconds: float = 8.0,
) -> list[DnsLeakResult]:
"""
Detect DNS leaks: compare hostname resolution seen through proxy vs direct.
A mismatch means DNS is escaping the tunnel.
"""
test_hosts = [
("Cloudflare (1.1.1.1)", "one.one.one.one"),
("Google (8.8.8.8)", "dns.google"),
("OpenDNS", "resolver1.opendns.com"),
]
results: list[DnsLeakResult] = []
timeout = httpx.Timeout(timeout_seconds, connect=min(6.0, timeout_seconds))
for label, host in test_hosts:
# Get IP via direct system DNS
direct_ip = _dns_resolve_via_system(host)
# Get IP as seen from the proxy path (via http://dns-endpoint)
proxy_ip: str | None = None
try:
with httpx.Client(proxy=proxy_url, timeout=timeout, verify=False, follow_redirects=True) as c:
r = c.get(f"https://{host}/")
proxy_ip = str(r.headers.get("x-real-ip") or "")
if not proxy_ip:
# fall back: grab connected IP from response
proxy_ip = None
except Exception:
pass
# Simple leak heuristic: if direct resolution works but proxy connection fails, possible leak path
leaked = bool(direct_ip and not proxy_ip)
if leaked:
detail = f"DNS resolved directly to {direct_ip} but proxy could not reach it — possible bypass"
elif not direct_ip:
detail = "Could not resolve directly"
leaked = False
else:
detail = f"Direct: {direct_ip}"
results.append(DnsLeakResult(
resolver=label,
ip_via_proxy=proxy_ip,
ip_direct=direct_ip,
leaked=leaked,
detail=detail,
))
return results
# NOTE: removed legacy ``check_dns_leak`` / ``DnsLeakResult`` — they relied on
# the ``x-real-ip`` HTTP response header which proxy targets do not set, so
# results were meaningless. Use :func:`proxy_chain_manager.dns_leak.run_dns_leak_test`
# instead.
def run_ban_tests(

View File

@@ -135,6 +135,59 @@ def _firefox_is_running_on_system() -> bool:
return False
def _has_descendants(root_pid: int) -> bool:
"""True iff *root_pid* or any descendant process is currently alive.
Walks the process tree from root_pid using WMIC ProcessId/ParentProcessId
(no admin required). Falls back to checking the root PID's existence via
tasklist if WMIC is missing on the host.
"""
if not root_pid:
return False
# Fast path: is the root PID itself still alive?
try:
r = subprocess.run(
["tasklist", "/FI", f"PID eq {root_pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=6,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
if str(root_pid) in (r.stdout or ""):
return True
except Exception: # noqa: BLE001
pass
# Walk descendants via WMIC. Builds {parent: [child, ...]} then BFS.
try:
w = subprocess.run(
["wmic", "process", "get", "ProcessId,ParentProcessId", "/FORMAT:CSV"],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
children: dict[int, list[int]] = {}
for line in (w.stdout or "").splitlines():
parts = [p.strip() for p in line.split(",")]
# CSV header: Node,ParentProcessId,ProcessId
if len(parts) < 3 or not parts[1].isdigit() or not parts[2].isdigit():
continue
ppid, pid = int(parts[1]), int(parts[2])
children.setdefault(ppid, []).append(pid)
stack = [root_pid]
seen = {root_pid}
while stack:
cur = stack.pop()
kids = children.get(cur, [])
for k in kids:
if k in seen:
continue
seen.add(k)
# Any live descendant = our session is still alive.
return True
return False
except Exception: # noqa: BLE001
# WMIC missing (newer Windows) — be conservative and return False
# rather than the old "any firefox.exe = mine" heuristic.
return False
class BrowserSession:
def __init__(self) -> None:
self._proc: subprocess.Popen[str] | None = None
@@ -145,16 +198,15 @@ class BrowserSession:
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".
# browser child processes carry on. Poll the parent process first.
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()
# Parent has exited — check whether any process in our spawned tree
# (the original PID's children) is still alive. Using a PID-scoped
# WMIC query avoids the old failure mode of treating any unrelated
# firefox.exe on the machine as ours.
launched_pid = self._proc.pid
return _has_descendants(launched_pid)
def pid(self) -> int | None:
if self._proc is None:

View File

@@ -200,6 +200,8 @@ class Settings:
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
validate_pinned_on_start: bool = True # quick TCP probe of each pinned hop before chaining
max_browser_relaunches: int = 5 # cap auto-relaunch attempts so a broken profile doesn't loop
# Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins)
manual_exit_proxy: str = ""

View File

@@ -24,6 +24,11 @@ GOST_RELEASE_ZIP = (
# Verified 2026-05-21 against https://github.com/go-gost/gost/releases/download/v3.2.6/
GOST_RELEASE_ZIP_SHA256 = "32f4edf3d94b622e67f1979f6f5de82dac62abc0977772cf96215dd199ef7e7b"
# Pinned SHA256 of the extracted ``gost.exe`` binary inside the v3.2.6 zip.
# Recorded the first time the zip is unpacked and verified on every reuse, so
# a tampered or partially-overwritten exe on disk forces a clean re-download.
GOST_EXE_SHA256_FILE = "gost.exe.sha256"
def _add_defender_exclusion(path: Path) -> None:
@@ -58,10 +63,55 @@ def _verify_zip_sha256(data: bytes, expected: str) -> None:
log.info("GOST zip SHA256 OK: %s", actual)
def _hash_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(64 * 1024), b""):
h.update(chunk)
return h.hexdigest().lower()
def _read_pinned_exe_hash(exe: Path) -> str | None:
sidecar = exe.with_suffix(exe.suffix + ".sha256")
try:
return sidecar.read_text(encoding="utf-8").strip().lower() or None
except OSError:
return None
def _write_pinned_exe_hash(exe: Path, digest: str) -> None:
sidecar = exe.with_suffix(exe.suffix + ".sha256")
try:
sidecar.write_text(digest, encoding="utf-8")
except OSError as exc:
log.warning("Could not write gost.exe hash sidecar: %s", exc)
def ensure_gost(target: Path | None = None) -> Path:
exe = target or gost_exe_path()
if exe.is_file() and exe.stat().st_size > 10_000:
return exe
# Re-verify the on-disk binary against the sidecar pin so a tampered
# exe cannot persist across launches.
pinned = _read_pinned_exe_hash(exe)
if pinned:
actual = _hash_file(exe)
if actual == pinned:
return exe
log.warning(
"gost.exe SHA256 mismatch on reuse — re-downloading.\n"
" pinned: %s\n actual: %s",
pinned, actual,
)
try:
exe.unlink()
except OSError as exc:
log.warning("Could not remove tampered gost.exe: %s", exc)
else:
# First reuse after older versions: record the current hash so the
# next run can verify. Mark as trusted-on-first-use only when zip
# SHA verification has already happened earlier in the session.
_write_pinned_exe_hash(exe, _hash_file(exe))
return exe
exe.parent.mkdir(parents=True, exist_ok=True)
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
_add_defender_exclusion(exe)
@@ -82,6 +132,8 @@ def ensure_gost(target: Path | None = None) -> Path:
shutil.copyfileobj(src, dst)
# Add exclusion again after write in case Defender scanned during extraction
_add_defender_exclusion(exe)
# Pin the freshly-extracted exe so subsequent launches can re-verify it.
_write_pinned_exe_hash(exe, _hash_file(exe))
log.info("GOST installed at %s", exe)
return exe

View File

@@ -0,0 +1,160 @@
"""Windows DPAPI-based secret storage.
Provides ``encrypt(plaintext: str) -> str`` and ``decrypt(token: str) -> str``
backed by ``CryptProtectData`` / ``CryptUnprotectData``.
Why DPAPI:
- Built into Windows; no third-party crypto dependency.
- Key material is tied to the *current user account* (or the machine, when
``CRYPTPROTECT_LOCAL_MACHINE`` is used) — no passphrase to remember.
- Data encrypted on disk cannot be decrypted by another user / machine.
Token format: ``DPAPI:v1:<base64-blob>``
Plain strings that don't start with ``DPAPI:`` are returned unchanged by
``decrypt()`` so the loader can safely walk old plaintext files and re-encrypt
them on the next save (migration is free).
Falls back gracefully on non-Windows or when DPAPI is unavailable: ``encrypt``
returns the original string verbatim (logged once).
"""
from __future__ import annotations
import base64
import ctypes
import logging
import sys
from ctypes import wintypes
log = logging.getLogger(__name__)
_TOKEN_PREFIX = "DPAPI:v1:"
# ── Windows API binding ──────────────────────────────────────────────────────
class _DATA_BLOB(ctypes.Structure):
_fields_ = [
("cbData", wintypes.DWORD),
("pbData", ctypes.POINTER(ctypes.c_char)),
]
def _blob(data: bytes) -> _DATA_BLOB:
buf = ctypes.create_string_buffer(data, len(data))
return _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
def _is_windows() -> bool:
return sys.platform == "win32"
_warned_unavailable = False
def _warn_unavailable(reason: str) -> None:
global _warned_unavailable
if not _warned_unavailable:
log.warning("DPAPI secret store unavailable: %s — secrets will be stored in plaintext.", reason)
_warned_unavailable = True
# ── Public API ───────────────────────────────────────────────────────────────
def is_available() -> bool:
"""Return True if the current process can use DPAPI."""
if not _is_windows():
return False
try:
ctypes.windll.crypt32 # noqa: B018 — just confirm the DLL is importable
return True
except Exception: # noqa: BLE001
return False
def encrypt(plaintext: str) -> str:
"""Encrypt *plaintext* with current-user DPAPI.
Returns ``DPAPI:v1:<base64>`` on success. Returns the original plaintext
on any failure (callers can therefore always trust the return value as the
string to persist).
"""
if not plaintext:
return ""
# Already encrypted — pass through.
if plaintext.startswith(_TOKEN_PREFIX):
return plaintext
if not is_available():
_warn_unavailable("not running on Windows")
return plaintext
try:
data = plaintext.encode("utf-8")
in_blob = _blob(data)
out_blob = _DATA_BLOB()
ok = ctypes.windll.crypt32.CryptProtectData(
ctypes.byref(in_blob),
"ProxyGodSecret", # description (ignored by us)
None, # optional entropy
None, # reserved
None, # prompt struct
0, # flags
ctypes.byref(out_blob),
)
if not ok:
err = ctypes.get_last_error()
_warn_unavailable(f"CryptProtectData failed (err={err})")
return plaintext
try:
enc = ctypes.string_at(out_blob.pbData, out_blob.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out_blob.pbData)
return _TOKEN_PREFIX + base64.b64encode(enc).decode("ascii")
except Exception as exc: # noqa: BLE001
_warn_unavailable(f"unexpected error: {exc}")
return plaintext
def decrypt(token: str) -> str:
"""Decrypt a token produced by :func:`encrypt`.
Plain strings (not starting with the ``DPAPI:`` prefix) are returned
verbatim — this is intentional, it lets the loader transparently read
legacy plaintext files and re-encrypt on save.
"""
if not token:
return ""
if not token.startswith(_TOKEN_PREFIX):
return token # legacy plaintext
if not is_available():
_warn_unavailable("not running on Windows — cannot decrypt")
return ""
try:
b64 = token[len(_TOKEN_PREFIX):]
data = base64.b64decode(b64.encode("ascii"))
in_blob = _blob(data)
out_blob = _DATA_BLOB()
ok = ctypes.windll.crypt32.CryptUnprotectData(
ctypes.byref(in_blob),
None, # ppszDataDescr
None, # entropy
None, # reserved
None, # prompt struct
0, # flags
ctypes.byref(out_blob),
)
if not ok:
err = ctypes.get_last_error()
log.warning("CryptUnprotectData failed (err=%s) — secret stayed encrypted.", err)
return ""
try:
plain = ctypes.string_at(out_blob.pbData, out_blob.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out_blob.pbData)
return plain.decode("utf-8", errors="replace")
except Exception as exc: # noqa: BLE001
log.warning("DPAPI decrypt error: %s", exc)
return ""
def is_encrypted(token: str) -> bool:
return bool(token) and token.startswith(_TOKEN_PREFIX)

View File

@@ -70,6 +70,22 @@ _FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
from .leak_detect import is_same_subnet as _is_same_network # noqa: F401
async def _probe_tcp(host: str, port: int, timeout: float = 6.0) -> bool:
"""Open and close a TCP socket; True if the handshake completes within *timeout*."""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=timeout,
)
writer.close()
try:
await writer.wait_closed()
except Exception: # noqa: BLE001
pass
return True
except Exception: # noqa: BLE001
return False
class ChainService:
"""Background rotating proxy chain using GOST."""
@@ -400,6 +416,26 @@ class ChainService:
})
if self._settings.use_pinned_chain and self._settings.pinned_chain:
chain = list(self._settings.pinned_chain)
# Optional per-hop TCP probe so a known-dead pinned chain
# doesn't loop forever on the 10 s retry below.
if self._settings.validate_pinned_on_start:
from urllib.parse import urlparse as _urlparse
dead = []
for hop in chain:
try:
p = _urlparse(hop if "://" in hop else "http://" + hop)
if p.hostname and p.port:
if not await _probe_tcp(p.hostname, int(p.port), 6.0):
dead.append(hop)
except Exception: # noqa: BLE001
dead.append(hop)
if dead:
self._notify({"type": "log", "text": (
f"Pinned chain: {len(dead)}/{len(chain)} hop(s) failed TCP "
f"probe — proceeding anyway (will retry on failure)."
)})
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
log.info("Pinned chain run: %d hops", len(chain))

View File

@@ -282,21 +282,30 @@ def resolve_signup_url(draft: SignupDraft) -> str:
def load_draft() -> SignupDraft:
from . import secrets_store as _ss
p = draft_path()
if not p.is_file():
return SignupDraft()
try:
raw = json.loads(p.read_text(encoding="utf-8"))
return SignupDraft(**{k: raw.get(k, "") for k in SignupDraft.__dataclass_fields__})
kwargs = {k: raw.get(k, "") for k in SignupDraft.__dataclass_fields__}
if kwargs.get("password"):
kwargs["password"] = _ss.decrypt(kwargs["password"])
return SignupDraft(**kwargs)
except (OSError, json.JSONDecodeError, TypeError):
return SignupDraft()
def save_draft(draft: SignupDraft) -> None:
draft_path().write_text(json.dumps(asdict(draft), indent=2), encoding="utf-8")
from . import secrets_store as _ss
d = asdict(draft)
if d.get("password"):
d["password"] = _ss.encrypt(d["password"])
draft_path().write_text(json.dumps(d, indent=2), encoding="utf-8")
def load_accounts() -> list[AccountRecord]:
from . import secrets_store as _ss
p = accounts_path()
if not p.is_file():
return []
@@ -312,6 +321,9 @@ def load_accounts() -> list[AccountRecord]:
kwargs = {k: item.get(k, "") for k in fields}
if not kwargs.get("id"):
kwargs["id"] = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
# Transparent DPAPI decrypt; plaintext passes through.
if kwargs.get("password"):
kwargs["password"] = _ss.decrypt(kwargs["password"])
out.append(AccountRecord(**kwargs))
return out
except (OSError, json.JSONDecodeError, TypeError):
@@ -319,8 +331,17 @@ def load_accounts() -> list[AccountRecord]:
def save_accounts(rows: list[AccountRecord]) -> None:
from . import secrets_store as _ss
serialized = []
for r in rows:
d = asdict(r)
# Encrypt sensitive fields at rest using Windows DPAPI when available.
# Legacy plaintext entries are migrated transparently on first save.
if d.get("password"):
d["password"] = _ss.encrypt(d["password"])
serialized.append(d)
accounts_path().write_text(
json.dumps([asdict(r) for r in rows], indent=2),
json.dumps(serialized, indent=2),
encoding="utf-8",
)