From e96f829f8b1f136e3d951677fce5230e90ee10c7 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sat, 5 Sep 2026 08:17:59 +0200 Subject: [PATCH] chore(release): v0.7.18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige le refus "chemin de configuration hors du dossier attendu" : le service exigeait %ProgramData%\WireGuard\ alors que l'app écrit sa config dans %APPDATA%\WGSecure\ (seul dossier inscriptible par un utilisateur non-admin). Ce chemin n'étant pas recalculable depuis LocalSystem, la validation repose désormais sur l'usurpation d'identité du client du pipe : le fichier doit être ouvrable par l'appelant lui-même, plus des contrôles structurels sur le nom et le dossier. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 8 ++++ Makefile | 2 +- README.md | 4 +- app/__init__.py | 2 +- app/service/helper_service.py | 72 ++++++++++++++++++++++++++--------- wgsecure.iss | 2 +- 6 files changed, 66 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61037c8..ce25781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). --- +## [0.7.18] — 2026-09-05 + +### Corrigé +- **« Chemin de configuration hors du dossier attendu » à l'installation du tunnel par le service.** Le service exigeait que le `.conf` se trouve dans `%ProgramData%\WireGuard\`, alors que l'application l'écrit sous Windows dans `%APPDATA%\WGSecure\` (`get_client_config_path`) — le seul endroit où un utilisateur non-admin peut écrire. Ce dossier ne peut pas être recalculé par le service, qui tourne en LocalSystem et verrait le `%APPDATA%` de SYSTEM : la validation repose désormais sur l'usurpation d'identité du client du pipe (`ImpersonateNamedPipeClient`), le fichier devant être ouvrable **par l'appelant lui-même** — un utilisateur ne peut donc jamais faire lire à SYSTEM un fichier auquel il n'a pas déjà accès. S'y ajoutent des contrôles structurels (chemin absolu, sans remontée de répertoire, nommé exactement `.conf` dans un dossier `WGSecure`). +- Les messages de refus indiquent maintenant ce qui a été reçu **et** ce qui était attendu, au lieu du seul chemin rejeté. + +--- + ## [0.7.17] — 2026-09-05 ### Corrigé diff --git a/Makefile b/Makefile index 666d6ed..c7d8994 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.7.17 +VERSION := 0.7.18 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip diff --git a/README.md b/README.md index 741342a..cb67ce6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ WGSecure (WGS) -🚀 v0.7.17 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard +🚀 v0.7.18 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard **WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard avec une surcouche MFA (authentification à deux facteurs TOTP). @@ -210,4 +210,4 @@ WGSecure/ ## 👤 Auteur Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) -Version : ![v0.7.17](https://img.shields.io/badge/v0.7.17-septembre%202026-2980b9?style=flat-square) — Septembre 2026 +Version : ![v0.7.18](https://img.shields.io/badge/v0.7.18-septembre%202026-2980b9?style=flat-square) — Septembre 2026 diff --git a/app/__init__.py b/app/__init__.py index b84afa4..5490bac 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.7.17" +__version__ = "0.7.18" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/service/helper_service.py b/app/service/helper_service.py index 224dbff..534b996 100644 --- a/app/service/helper_service.py +++ b/app/service/helper_service.py @@ -36,7 +36,7 @@ import win32service import win32serviceutil from app.core.dns import _nrpt_add_split_dns_ps, _nrpt_remove_split_dns_ps -from app.utils.platform_utils import get_wg_config_dir, wg_exe, wireguard_exe +from app.utils.platform_utils import wg_exe, wireguard_exe _PIPE_NAME = r"\\.\pipe\WGSecureHelper" # SYSTEM + Administrateurs : contrôle total. Utilisateurs interactifs @@ -107,19 +107,53 @@ def _validate_server(server) -> str: return server -def _validate_config_path(config_path, name: str) -> str: - """Le fichier doit résider exactement dans le dossier de configuration - WireGuard et s'appeler .conf — jamais un chemin arbitraire. Le - service ne lit ni n'écrit ce fichier ; il vérifie seulement qu'il - pointe là où l'app (non privilégiée) est censée l'avoir déjà écrit - avant de demander à wireguard.exe de l'installer. +def _validate_config_path(config_path, name: str, handle) -> str: + """Valide le .conf que l'appelant demande d'installer. + + Le chemin ne peut pas être comparé à un dossier fixe : l'app écrit sa + config dans le `%APPDATA%` de *l'utilisateur* (`get_client_config_path` + dans app/core/wireguard.py), or ce service tourne en LocalSystem — son + propre `%APPDATA%` est celui de SYSTEM, pas celui de l'appelant, et il + n'a aucun moyen fiable de recalculer celui-ci. + + Le contrôle réel est donc l'usurpation d'identité du client du pipe : + le fichier doit être ouvrable *par l'appelant lui-même*. Un utilisateur + ne peut ainsi jamais faire lire à SYSTEM un fichier auquel il n'a pas + déjà accès. S'y ajoutent des contrôles structurels bon marché : chemin + absolu, sans remontée de répertoire, nommé exactement `.conf` + dans un dossier `WGSecure`. """ - if not isinstance(config_path, str): + if not isinstance(config_path, str) or not config_path: raise ValidationError("Chemin de configuration invalide") - expected = os.path.join(get_wg_config_dir(), f"{name}.conf") - if os.path.normcase(os.path.abspath(config_path)) != os.path.normcase(os.path.abspath(expected)): - raise ValidationError(f"Chemin de configuration hors du dossier attendu : {config_path!r}") - return config_path + + path = os.path.abspath(config_path) + if ".." in path.replace("/", "\\").split("\\"): + raise ValidationError(f"Chemin de configuration suspect : {config_path!r}") + if os.path.basename(path).lower() != f"{name}.conf".lower(): + raise ValidationError( + f"Le fichier doit s'appeler {name}.conf, reçu : {os.path.basename(path)!r}") + if os.path.basename(os.path.dirname(path)).lower() != "wgsecure": + raise ValidationError( + "Le fichier doit se trouver dans un dossier WGSecure, reçu : " + f"{os.path.dirname(path)!r}") + + # Ouverture sous l'identité de l'appelant : échoue si le fichier ne lui + # appartient pas / ne lui est pas lisible. Fail closed — si + # l'usurpation elle-même échoue, on refuse. + try: + win32pipe.ImpersonateNamedPipeClient(handle) + except pywintypes.error as e: + raise ValidationError(f"Identité de l'appelant non vérifiable : {e}") from None + try: + with open(path, "rb"): + pass + except OSError as e: + raise ValidationError( + f"Fichier illisible pour l'appelant : {config_path!r} ({e})") from None + finally: + win32security.RevertToSelf() + + return path def _run(cmd: list[str], timeout: int = 30) -> tuple[int, str]: @@ -136,20 +170,20 @@ def _run(cmd: list[str], timeout: int = 30) -> tuple[int, str]: return -1, str(e) -def _handle_install_tunnel(req: dict) -> dict: +def _handle_install_tunnel(req: dict, handle) -> dict: name = _validate_interface_name(req.get("name")) - config_path = _validate_config_path(req.get("config_path"), name) + config_path = _validate_config_path(req.get("config_path"), name, handle) code, msg = _run([wireguard_exe(), "/installtunnelservice", config_path], timeout=90) return {"ok": code == 0, "message": msg} -def _handle_uninstall_tunnel(req: dict) -> dict: +def _handle_uninstall_tunnel(req: dict, handle) -> dict: name = _validate_interface_name(req.get("name")) code, msg = _run([wireguard_exe(), "/uninstalltunnelservice", name], timeout=60) return {"ok": code == 0, "message": msg} -def _handle_set_split_dns(req: dict) -> dict: +def _handle_set_split_dns(req: dict, handle) -> dict: server = _validate_server(req.get("server")) domains = _validate_domains(req.get("domains")) rule_name = _validate_rule_name(req.get("rule_name")) @@ -158,14 +192,14 @@ def _handle_set_split_dns(req: dict) -> dict: return {"ok": code == 0, "message": msg} -def _handle_clear_split_dns(req: dict) -> dict: +def _handle_clear_split_dns(req: dict, handle) -> dict: rule_name = _validate_rule_name(req.get("rule_name")) script = _nrpt_remove_split_dns_ps(rule_name) code, msg = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], timeout=20) return {"ok": code == 0, "message": msg} -def _handle_get_status(req: dict) -> dict: +def _handle_get_status(req: dict, handle) -> dict: """Lecture seule : "message" porte la sortie de `wg show`, pas une erreur — "ok" reflète juste si la commande a pu s'exécuter. `latest_handshakes` : ajoute le sous-argument `latest-handshakes` @@ -223,7 +257,7 @@ def _handle_connection(handle, logger: logging.Logger) -> None: resp = {"ok": False, "message": f"Commande inconnue : {cmd!r}"} else: try: - resp = handler(req) + resp = handler(req, handle) except ValidationError as e: resp = {"ok": False, "message": str(e)} logger.info("%s -> ok=%s", cmd, resp.get("ok")) diff --git a/wgsecure.iss b/wgsecure.iss index 5c0c211..a608119 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -5,7 +5,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.7.17" +#define MyAppVersion "0.7.18" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe"