fix(toolbox): /rlevel admin — bloque l'écriture depuis le vhost public kbin (_is_public_kbin, comme les 8 routes sœurs)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-24 16:38:31 +02:00
parent 8dc288db78
commit d6152d7709
3 changed files with 122 additions and 9 deletions

View File

@ -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.

View File

@ -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()

View File

@ -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):