chore(release): v0.7.18
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<interface>.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
|
## [0.7.17] — 2026-09-05
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
## ──────────────────────────────────────────────
|
## ──────────────────────────────────────────────
|
||||||
|
|
||||||
APP := wgsecure
|
APP := wgsecure
|
||||||
VERSION := 0.7.17
|
VERSION := 0.7.18
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
PYTHON := $(VENV)/bin/python3
|
PYTHON := $(VENV)/bin/python3
|
||||||
PIP := $(VENV)/bin/pip
|
PIP := $(VENV)/bin/pip
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 🛡️ WGSecure (WGS)
|
# 🛡️ 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).
|
**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
|
## 👤 Auteur
|
||||||
|
|
||||||
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
|
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
|
||||||
Version :  — Septembre 2026
|
Version :  — Septembre 2026
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
__version__ = "0.7.17"
|
__version__ = "0.7.18"
|
||||||
APP_NAME = "WGSecure"
|
APP_NAME = "WGSecure"
|
||||||
APP_SHORT = "WGS"
|
APP_SHORT = "WGS"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import win32service
|
|||||||
import win32serviceutil
|
import win32serviceutil
|
||||||
|
|
||||||
from app.core.dns import _nrpt_add_split_dns_ps, _nrpt_remove_split_dns_ps
|
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"
|
_PIPE_NAME = r"\\.\pipe\WGSecureHelper"
|
||||||
# SYSTEM + Administrateurs : contrôle total. Utilisateurs interactifs
|
# SYSTEM + Administrateurs : contrôle total. Utilisateurs interactifs
|
||||||
@@ -107,19 +107,53 @@ def _validate_server(server) -> str:
|
|||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
def _validate_config_path(config_path, name: str) -> str:
|
def _validate_config_path(config_path, name: str, handle) -> str:
|
||||||
"""Le fichier doit résider exactement dans le dossier de configuration
|
"""Valide le .conf que l'appelant demande d'installer.
|
||||||
WireGuard et s'appeler <name>.conf — jamais un chemin arbitraire. Le
|
|
||||||
service ne lit ni n'écrit ce fichier ; il vérifie seulement qu'il
|
Le chemin ne peut pas être comparé à un dossier fixe : l'app écrit sa
|
||||||
pointe là où l'app (non privilégiée) est censée l'avoir déjà écrit
|
config dans le `%APPDATA%` de *l'utilisateur* (`get_client_config_path`
|
||||||
avant de demander à wireguard.exe de l'installer.
|
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 `<name>.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")
|
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)):
|
path = os.path.abspath(config_path)
|
||||||
raise ValidationError(f"Chemin de configuration hors du dossier attendu : {config_path!r}")
|
if ".." in path.replace("/", "\\").split("\\"):
|
||||||
return config_path
|
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]:
|
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)
|
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"))
|
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)
|
code, msg = _run([wireguard_exe(), "/installtunnelservice", config_path], timeout=90)
|
||||||
return {"ok": code == 0, "message": msg}
|
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"))
|
name = _validate_interface_name(req.get("name"))
|
||||||
code, msg = _run([wireguard_exe(), "/uninstalltunnelservice", name], timeout=60)
|
code, msg = _run([wireguard_exe(), "/uninstalltunnelservice", name], timeout=60)
|
||||||
return {"ok": code == 0, "message": msg}
|
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"))
|
server = _validate_server(req.get("server"))
|
||||||
domains = _validate_domains(req.get("domains"))
|
domains = _validate_domains(req.get("domains"))
|
||||||
rule_name = _validate_rule_name(req.get("rule_name"))
|
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}
|
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"))
|
rule_name = _validate_rule_name(req.get("rule_name"))
|
||||||
script = _nrpt_remove_split_dns_ps(rule_name)
|
script = _nrpt_remove_split_dns_ps(rule_name)
|
||||||
code, msg = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], timeout=20)
|
code, msg = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], timeout=20)
|
||||||
return {"ok": code == 0, "message": msg}
|
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
|
"""Lecture seule : "message" porte la sortie de `wg show`, pas une
|
||||||
erreur — "ok" reflète juste si la commande a pu s'exécuter.
|
erreur — "ok" reflète juste si la commande a pu s'exécuter.
|
||||||
`latest_handshakes` : ajoute le sous-argument `latest-handshakes`
|
`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}"}
|
resp = {"ok": False, "message": f"Commande inconnue : {cmd!r}"}
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
resp = handler(req)
|
resp = handler(req, handle)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
resp = {"ok": False, "message": str(e)}
|
resp = {"ok": False, "message": str(e)}
|
||||||
logger.info("%s -> ok=%s", cmd, resp.get("ok"))
|
logger.info("%s -> ok=%s", cmd, resp.get("ok"))
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
; ──────────────────────────────────────────────
|
; ──────────────────────────────────────────────
|
||||||
|
|
||||||
#define MyAppName "WGSecure"
|
#define MyAppName "WGSecure"
|
||||||
#define MyAppVersion "0.7.17"
|
#define MyAppVersion "0.7.18"
|
||||||
#define MyAppPublisher "WGSecure"
|
#define MyAppPublisher "WGSecure"
|
||||||
#define MyAppExeName "wgsecure.exe"
|
#define MyAppExeName "wgsecure.exe"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user