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:
@@ -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 <name>.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 `<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")
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user