From d6152d77091997aee6faee10942ea3e6dcede8ce Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 24 Jul 2026 16:38:31 +0200 Subject: [PATCH] =?UTF-8?q?fix(toolbox):=20/rlevel=20admin=20=E2=80=94=20b?= =?UTF-8?q?loque=20l'=C3=A9criture=20depuis=20le=20vhost=20public=20kbin?= =?UTF-8?q?=20(=5Fis=5Fpublic=5Fkbin,=20comme=20les=208=20routes=20s=C5=93?= =?UTF-8?q?urs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Gerald KERMA --- .superpowers/sdd/task-8-report.md | 74 +++++++++++++++++++ .../secubox-toolbox/secubox_toolbox/api.py | 4 + .../secubox-toolbox/tests/test_rlevel_api.py | 53 ++++++++++--- 3 files changed, 122 insertions(+), 9 deletions(-) diff --git a/.superpowers/sdd/task-8-report.md b/.superpowers/sdd/task-8-report.md index 4b7c75ad..51c46742 100644 --- a/.superpowers/sdd/task-8-report.md +++ b/.superpowers/sdd/task-8-report.md @@ -147,3 +147,77 @@ Aucun `.deb`, `.buildinfo`, `.changes`, `debian/secubox-toolbox*/` de staging, `sudo -n` avant chaque appel ctl, vérification bypass nft "off") reste à exécuter manuellement sur le board — hors périmètre de cette tâche de packaging (cf. section « Recette de déploiement » du brief). + +## Fix CRITIQUE final — kbin public guard + +**Statut :** Terminé. + +**Finding** : le vhost public `kbin.gk2.secubox.in` est routé par HAProxy +directement vers uvicorn, sans SSO (`app.py:31-34`). Les deux routes admin +`GET /rlevel/peers` et `POST /rlevel/peer` (`secubox_toolbox/api.py`) ne +posaient que `_require_admin_source(request)` — un garde qui rejette +uniquement les sources qui SONT des peers tunnel `wg-toolbox` (10.99.1.x). +Un client public via kbin n'est ni un peer tunnel ni bloqué par ce garde : +il passait donc directement l'écriture admin (`POST /rlevel/peer +{pubkey, forced:"off"}` pour bypasser l'inspection MITM d'un peer, ou +`forced:"reel"` pour forcer le déchiffrement d'un peer). Les 8 autres +routes d'écriture admin du même fichier (filter-control add/remove/ +toggle/delete, tor on/off/newnym/leak-check, clients/reset-all) posent +toutes `if _is_public_kbin(request): raise HTTPException(403, ...)` en +tête — les deux routes rlevel avaient été oubliées lors de leur ajout +(task 6). + +**Fix** — `packages/secubox-toolbox/secubox_toolbox/api.py` : +ajout, en tête de `rlevel_peers` (GET /rlevel/peers, ~L4133) et +`rlevel_peer_set` (POST /rlevel/peer, ~L4159), AVANT `_require_admin_source`, +du même garde que les 8 routes sœurs : +```python +if _is_public_kbin(request): + raise HTTPException(status_code=403, detail="rlevel admin disabled on public vhost — use admin.gk2.secubox.in/toolbox/") +``` +`_require_admin_source` reste juste après (défense en profondeur : public +kbin bloqué par le kbin-guard, peer tunnel bloqué par admin-source ; seul +l'admin via vhost admin — source non-kbin, non-peer — passe). Les routes +peer self-service `/rlevel/me` (GET/POST) sont inchangées : leur auth reste +l'identité tunnel + les bornes floor/forced, elles DOIVENT rester +atteignables par un peer. + +**Tests** (`tests/test_rlevel_api.py`) — TDD red→green : +- Ajout de `test_public_kbin_cannot_list_admin_peers` et + `test_public_kbin_cannot_mutate_admin_peer` (header `host: + kbin.gk2.secubox.in` via `TestClient`, comme lu par `_is_public_kbin`) → + **403** attendu. + - **RED** confirmé : `git stash` du fix seul (api.py), les deux + nouveaux tests échouent avec `200 == 403` (l'écriture publique passe). + - **GREEN** : fix restauré, les deux tests passent. +- `test_admin_source_not_a_peer_can_list_and_mutate` adapté : passe + désormais explicitement `headers={"host": "admin.gk2.secubox.in"}` sur + les deux appels (au lieu de laisser le host `TestClient` par défaut + passer implicitement `_is_public_kbin` à False) — la source simulée est + maintenant explicitement admin (non-kbin, non-peer), pas seulement + « non-peer », donc le test n'entérine plus le trou. +- Docstring du module mise à jour pour documenter le double-gate + (`_is_public_kbin` puis `_require_admin_source`). +- Tous les autres tests rlevel (self-service borné, escalade tunnel + bloquée, pubkey en body, etc.) restent verts, inchangés. + +**Vérification** : +``` +cd packages/secubox-toolbox && python3 -m pytest tests/test_rlevel_api.py -q +# 25 passed +``` +Non-régression vérifiée : `python3 -m pytest tests/test_tor_switch.py -q -k kbin` +→ 1 passed (garde `_is_public_kbin` sœur toujours correcte). Suite complète +`pytest tests/` → 337 passed, 3 failed — les 3 échecs sont les mêmes +préexistants déjà documentés plus haut (`test_bypass_sources.py` + +`test_media_stats.py` ×2, `ModuleNotFoundError: secubox_core`), confirmés +indépendants de ce fix (mêmes échecs avec le fix stashé). + +**Commit** : `fix(toolbox): /rlevel admin — bloque l'écriture depuis le +vhost public kbin (_is_public_kbin, comme les 8 routes sœurs)`. + +**Préoccupations** : +- Aucune action root in-process ajoutée ou modifiée (le fix n'ajoute que + des `raise HTTPException` avant les gardes/appels existants). +- Les 3 échecs pytest préexistants (hors scope) restent à corriger + séparément, cf. section précédente. diff --git a/packages/secubox-toolbox/secubox_toolbox/api.py b/packages/secubox-toolbox/secubox_toolbox/api.py index 41b45ad5..81691559 100644 --- a/packages/secubox-toolbox/secubox_toolbox/api.py +++ b/packages/secubox-toolbox/secubox_toolbox/api.py @@ -4135,6 +4135,8 @@ async def rlevel_peers(request: Request) -> dict: """Admin — every wg-toolbox peer's rlevel status (chosen/forced/floor/ effective + best-effort live handshake). Read-only: sourced from wg-peers.json (identity) + `sbxmitm-policyctl list` (policy).""" + if _is_public_kbin(request): + raise HTTPException(status_code=403, detail="rlevel admin disabled on public vhost — use admin.gk2.secubox.in/toolbox/") _require_admin_source(request) wg_peers = _rlevel_load_wg_peers() doc = _rlevel_load_policy() @@ -4164,6 +4166,8 @@ async def rlevel_peer_set(request: Request) -> dict: pubkeys are standard base64 and routinely contain '/' (and '+'), which Starlette rejects in a path segment (even %2F-encoded) — a path param made roughly half of real pubkeys unaddressable.""" + if _is_public_kbin(request): + raise HTTPException(status_code=403, detail="rlevel admin disabled on public vhost — use admin.gk2.secubox.in/toolbox/") _require_admin_source(request) try: body = await request.json() diff --git a/packages/secubox-toolbox/tests/test_rlevel_api.py b/packages/secubox-toolbox/tests/test_rlevel_api.py index 414968c3..ccde8615 100644 --- a/packages/secubox-toolbox/tests/test_rlevel_api.py +++ b/packages/secubox-toolbox/tests/test_rlevel_api.py @@ -8,12 +8,16 @@ to `sudo -n sbxmitm-policyctl` (task 4). Tests stub `_rlevel_ctl` directly for behavioral assertions, and stub `subprocess.run` once to prove the real delegation command line is well-formed. -Admin routes (GET /rlevel/peers, POST /rlevel/peer) require the caller's -source IP to NOT be a known wg-toolbox tunnel peer (`_require_admin_source`) — -the nft DNAT forwards a peer's tunnel traffic straight to uvicorn at L3/L4, -bypassing nginx/SSO, so without this gate a peer could call the admin routes -directly. The pubkey for POST /rlevel/peer travels in the JSON body (not the -URL path) because WireGuard base64 pubkeys routinely contain '/'. +Admin routes (GET /rlevel/peers, POST /rlevel/peer) are double-gated: +1. `_is_public_kbin` — refused on the public kbin.gk2.secubox.in vhost + (HAProxy routes it straight to uvicorn, no SSO), same as the 8 sibling + admin-write routes in this file. +2. `_require_admin_source` — the caller's source IP must NOT be a known + wg-toolbox tunnel peer; the nft DNAT forwards a peer's tunnel traffic + straight to uvicorn at L3/L4, bypassing nginx/SSO, so without this gate + a peer could call the admin routes directly. +The pubkey for POST /rlevel/peer travels in the JSON body (not the URL +path) because WireGuard base64 pubkeys routinely contain '/'. """ import json @@ -84,19 +88,50 @@ def test_peer_source_cannot_mutate_admin_peer(tmp_path, monkeypatch): def test_admin_source_not_a_peer_can_list_and_mutate(tmp_path, monkeypatch): """Sanity: a caller whose source IP is NOT in wg-peers.json (loopback via - the admin vhost) is unaffected by the gate.""" + the admin vhost, NOT kbin) is unaffected by the gates.""" _write_wg_peers(tmp_path, monkeypatch, {PK1: {"ip": "10.99.1.5", "label": "phone-A"}}) calls = _stub_ctl(monkeypatch, doc={ "defaults": {"mode": "passive", "floor": "passive"}, "peers": {PK1: {"chosen": "passive", "forced": None, "floor": "passive"}}, }) - r = client.get("/rlevel/peers") # no X-R3-Peer header -> not a tunnel peer + # Explicit admin vhost host header — not kbin, not a tunnel peer. + admin_headers = {"host": "admin.gk2.secubox.in"} + r = client.get("/rlevel/peers", headers=admin_headers) # no X-R3-Peer -> not a tunnel peer assert r.status_code == 200 - r2 = client.post("/rlevel/peer", json={"pubkey": PK1, "floor": "active"}) + r2 = client.post("/rlevel/peer", headers=admin_headers, json={"pubkey": PK1, "floor": "active"}) assert r2.status_code == 200, r2.text assert ["set-floor", PK1, "active"] in calls +# ── CRITICAL — public kbin vhost must NOT reach admin rlevel writes ───── +# HAProxy routes the public kbin.gk2.secubox.in vhost straight to uvicorn +# with no SSO in front of it. `_require_admin_source` only rejects known +# wg-toolbox tunnel peers — a public kbin visitor is neither, so without a +# dedicated kbin guard it sailed straight through to the admin routes +# (bypass inspection on a peer, or force a peer into cleartext). Every +# other admin-write route in this file guards with `_is_public_kbin` first; +# these two are the ones that had been missed. + +def test_public_kbin_cannot_list_admin_peers(tmp_path, monkeypatch): + _write_wg_peers(tmp_path, monkeypatch, { + PK1: {"ip": "10.99.1.5", "label": "phone-A"}, + }) + _stub_ctl(monkeypatch) + r = client.get("/rlevel/peers", headers={"host": "kbin.gk2.secubox.in"}) + assert r.status_code == 403 + + +def test_public_kbin_cannot_mutate_admin_peer(tmp_path, monkeypatch): + _write_wg_peers(tmp_path, monkeypatch, { + PK1: {"ip": "10.99.1.5", "label": "phone-A"}, + }) + calls = _stub_ctl(monkeypatch) + r = client.post("/rlevel/peer", headers={"host": "kbin.gk2.secubox.in"}, + json={"pubkey": PK1, "forced": "off"}) + assert r.status_code == 403 + assert not calls # never reached the ctl at all + + # ── admin: GET /rlevel/peers ──────────────────────────────────────────── def test_admin_list_peers_computes_effective(tmp_path, monkeypatch):