mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
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:
parent
8dc288db78
commit
d6152d7709
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user