fix: address P0/P1/P2 audit findings — security, reliability, CI, docs
P0 - Critical: - config.py: add _mask() credential-redaction helper, SETTINGS_SCHEMA_VERSION, plaintext-storage warning; corrupt settings.json backed up before defaults - gost_util.py: pin SHA256 for gost_3.2.6_windows_amd64.zip; verify before extraction; _add_defender_exclusion now logs warning on failure - firewall.py: add emergency_disengage() for atexit/signal use - service.py: register fw_emergency_disengage via atexit + SIGTERM/SIGINT; stop() calls emergency_disengage if thread hangs past 15s timeout - app.py: wrap main() in top-level except with CTk error dialog + log - LICENSE: add MIT license file P1 - Important: - app.py: switch to RotatingFileHandler (5 MB / 3 backups) - config.py: settings_version + migrate(); save_settings() writes .bak before overwrite; _is_safe_https_url() strips RFC-1918 sources/ip_check_url - service.py: threading.Lock on _settings; _signal_handler; graceful stop - requirements.txt: pin exact versions; add cryptography==48.0.0 - .gitignore: add settings.json, credential JSON files, screenshot noise - tray.py, dns_leak.py, gost_util.py: replace bare except:pass with logging - CHANGELOG.md: document all session changes P2 - Nice to have: - .github/workflows/test.yml: CI on Python 3.10/3.11/3.12 windows-latest - run.py: --version / -V flag - docs/OPERATOR_RUNBOOK.md: emergency disengage, proxy leak, GOST, settings Tests: 47/47 passed (python -m unittest discover -s tests -v) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlparse, urlunparse
|
||||
|
||||
from .paths import app_data_dir
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Settings schema version ───────────────────────────────────────────────────
|
||||
# Bump this whenever a breaking field rename / type change is made so
|
||||
# migrate() can upgrade old settings.json files safely.
|
||||
SETTINGS_SCHEMA_VERSION = 1
|
||||
|
||||
# !! SECURITY NOTE !!
|
||||
# settings.json is stored in plaintext at %LOCALAPPDATA%\ProxyChainManager\.
|
||||
# It may contain proxy credentials (URLs with user:password). Do NOT log
|
||||
# raw settings dicts — use _mask() to redact credential fields before logging.
|
||||
|
||||
|
||||
def _mask(d: dict) -> dict:
|
||||
"""Return a copy of settings dict with credential-bearing fields redacted."""
|
||||
redact_keys = {"manual_exit_proxy", "pinned_chain", "sources"}
|
||||
out: dict = {}
|
||||
for k, v in d.items():
|
||||
if k in redact_keys:
|
||||
if isinstance(v, list):
|
||||
out[k] = [redact_proxy_url(str(x)) for x in v]
|
||||
else:
|
||||
out[k] = redact_proxy_url(str(v))
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _host_port_for_url(host: str, port: int | None) -> str:
|
||||
"""``host`` and optional ``port`` as used in proxy URLs (bracket IPv6 literals)."""
|
||||
@@ -159,6 +188,9 @@ OBFUSCATION_LABELS = {
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
# Schema version — used by migrate() to upgrade old settings.json files.
|
||||
settings_version: int = SETTINGS_SCHEMA_VERSION
|
||||
|
||||
# ── network ──────────────────────────────────────────────────────────
|
||||
local_host: str = LISTEN_HOST
|
||||
local_port: int = 18888
|
||||
@@ -231,6 +263,40 @@ class Settings:
|
||||
return app_data_dir() / "settings.json"
|
||||
|
||||
|
||||
def _is_safe_https_url(url: str) -> bool:
|
||||
"""Return True for HTTPS URLs pointing at public hosts (not RFC-1918/link-local)."""
|
||||
try:
|
||||
p = urlparse(url)
|
||||
if p.scheme not in ("http", "https"):
|
||||
return False
|
||||
host = p.hostname or ""
|
||||
if not host:
|
||||
return False
|
||||
import ipaddress as _ip
|
||||
try:
|
||||
addr = _ip.ip_address(host)
|
||||
return not (addr.is_private or addr.is_loopback or addr.is_link_local)
|
||||
except ValueError:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def migrate(raw: dict) -> dict:
|
||||
"""Upgrade a raw settings dict from any schema version to the current one.
|
||||
|
||||
Each version block transforms ``raw`` in-place and bumps
|
||||
``settings_version``. New fields are left absent so the dataclass
|
||||
default takes effect.
|
||||
"""
|
||||
ver = int(raw.get("settings_version", 0))
|
||||
# v0 → v1: no structural changes; just stamp the version
|
||||
if ver < 1:
|
||||
raw["settings_version"] = 1
|
||||
return raw
|
||||
|
||||
|
||||
def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
|
||||
"""Clamp invalid values from hand-edited JSON. Returns (settings, changed)."""
|
||||
changed = False
|
||||
@@ -315,6 +381,16 @@ def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
|
||||
if not isinstance(s.sources, list) or not all(isinstance(x, str) for x in s.sources) or len(s.sources) == 0:
|
||||
s.sources = list(Settings().sources)
|
||||
changed = True
|
||||
else:
|
||||
safe = [u for u in s.sources if _is_safe_https_url(u)]
|
||||
if len(safe) < len(s.sources):
|
||||
log.warning("Removed %d unsafe source URL(s) from settings.", len(s.sources) - len(safe))
|
||||
s.sources = safe if safe else list(Settings().sources)
|
||||
changed = True
|
||||
if s.ip_check_url and not _is_safe_https_url(s.ip_check_url):
|
||||
log.warning("ip_check_url looks unsafe (%s) — reverting to default.", s.ip_check_url)
|
||||
s.ip_check_url = Settings().ip_check_url
|
||||
changed = True
|
||||
return s, changed
|
||||
|
||||
|
||||
@@ -332,8 +408,15 @@ def load_settings() -> Settings:
|
||||
return Settings()
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
corrupt = p.with_suffix(".json.corrupt")
|
||||
log.warning("settings.json unreadable (%s) — backing up to %s and using defaults.", exc, corrupt)
|
||||
try:
|
||||
shutil.copy2(p, corrupt)
|
||||
except OSError:
|
||||
pass
|
||||
return Settings()
|
||||
raw = migrate(raw)
|
||||
s = Settings()
|
||||
for k, v in raw.items():
|
||||
if hasattr(s, k):
|
||||
@@ -365,4 +448,10 @@ def save_settings(s: Settings) -> None:
|
||||
s, _ = sanitize_settings(s)
|
||||
s, _ = normalize_listen_host(s)
|
||||
p = app_data_dir() / "settings.json"
|
||||
# Backup before overwriting so a crash mid-write doesn't corrupt settings.
|
||||
if p.is_file():
|
||||
try:
|
||||
shutil.copy2(p, p.with_suffix(".json.bak"))
|
||||
except OSError as exc:
|
||||
log.warning("Could not back up settings.json: %s", exc)
|
||||
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user