From cc478872ae926efb91ff63ef6707e3c40ae80df2 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Wed, 24 Jun 2026 07:50:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(toolbox):=20in-banner=20R0..R3=20level=20s?= =?UTF-8?q?witch=20=E2=80=94=20show=20real=20state=20+=20change=20it=20(cl?= =?UTF-8?q?oses=20#724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The injected transparency banner showed the client level as static text. It's now an interactive R0/R1/R2/R3 switch: the current tier is highlighted and a tap changes it. - bundle.py: lvlSwitch() renders the 4 buttons (current highlighted); wireLevels() fires GET /__toolbox/set-level?mh=&level= then reloads so the new tier applies. Dismiss-button selector tightened to button[aria-label=dismiss] (the level buttons are now first). invalidate() drops the cached bundle on change. - api.py: GET /__toolbox/set-level (unauth, like /bundle) → store.set_client_level by hash; validates level, gates r2 by config + r3 by wg server.pubkey; invalidates the bundle cache. - store.py: set_client_level(mac_hash, level) — by-hash setter (R3 wg peers have no captive MAC/ip, so /change-level's nft path doesn't apply). - sbxmitm/banner.go: intercept /__toolbox/set-level and reverse-proxy to the portal (same path as loader.js/bundle), so the banner's same-origin fetch works on any site. Verified live on gk2: set-level r2→bundle r2, switch back r1, bad level→400, and the worker reverse-proxies /__toolbox/set-level on a forged origin → {"ok":true}. --- .../secubox-toolbox-ng/cmd/sbxmitm/banner.go | 5 ++- packages/secubox-toolbox-ng/debian/changelog | 7 ++++ packages/secubox-toolbox/debian/changelog | 10 +++++ .../secubox-toolbox/secubox_toolbox/api.py | 33 +++++++++++++++ .../secubox-toolbox/secubox_toolbox/bundle.py | 42 ++++++++++++++++++- .../secubox-toolbox/secubox_toolbox/store.py | 21 ++++++++++ 6 files changed, 115 insertions(+), 3 deletions(-) diff --git a/packages/secubox-toolbox-ng/cmd/sbxmitm/banner.go b/packages/secubox-toolbox-ng/cmd/sbxmitm/banner.go index 93eedd8f..0027b63f 100644 --- a/packages/secubox-toolbox-ng/cmd/sbxmitm/banner.go +++ b/packages/secubox-toolbox-ng/cmd/sbxmitm/banner.go @@ -209,7 +209,10 @@ func injectInlineBanner(body []byte, scriptBody string) []byte { // match would never fire. Mirrors the Python request() p.startswith(...) checks. func isToolboxAssetPath(path string) bool { return strings.HasPrefix(path, "/__toolbox/loader.js") || - strings.HasPrefix(path, "/__toolbox/bundle") + strings.HasPrefix(path, "/__toolbox/bundle") || + // #724 — banner R0..R3 level switch: same-origin GET from the page, + // reverse-proxied to the portal /__toolbox/set-level. + strings.HasPrefix(path, "/__toolbox/set-level") } // portalTargetURL builds the absolute portal URL for an intercepted asset diff --git a/packages/secubox-toolbox-ng/debian/changelog b/packages/secubox-toolbox-ng/debian/changelog index d7e2c9f0..879e12ff 100644 --- a/packages/secubox-toolbox-ng/debian/changelog +++ b/packages/secubox-toolbox-ng/debian/changelog @@ -1,3 +1,10 @@ +secubox-toolbox-ng (0.1.15-1~bookworm1) bookworm; urgency=medium + + * #724 — intercept /__toolbox/set-level (banner level switch) and reverse-proxy + it to the portal, same as /__toolbox/loader.js + /__toolbox/bundle. + + -- Gerald KERMA Mon, 22 Jun 2026 15:10:00 +0000 + secubox-toolbox-ng (0.1.14-1~bookworm1) bookworm; urgency=medium * quic/banner: strip Alt-Svc response header so browsers stop learning/preferring diff --git a/packages/secubox-toolbox/debian/changelog b/packages/secubox-toolbox/debian/changelog index 8a0b6395..2d4f95e2 100644 --- a/packages/secubox-toolbox/debian/changelog +++ b/packages/secubox-toolbox/debian/changelog @@ -1,3 +1,13 @@ +secubox-toolbox (2.7.17-1~bookworm1) bookworm; urgency=medium + + * #724 banner: in-banner R0..R3 level switch — the injected transparency + banner now shows the real client level AND lets the client change it inline + (GET /__toolbox/set-level, reverse-proxied by sbxmitm like /__toolbox/bundle; + store.set_client_level by hash; bundle cache invalidated so it reflects at + once). r2 gated by config, r3 by wg server.pubkey. + + -- Gerald KERMA Mon, 22 Jun 2026 15:10:00 +0000 + secubox-toolbox (2.7.16-1~bookworm1) bookworm; urgency=medium * fix: restore banner on heavy sites (leparisien.fr). The #685 stream_large_bodies=1m diff --git a/packages/secubox-toolbox/secubox_toolbox/api.py b/packages/secubox-toolbox/secubox_toolbox/api.py index 6afc241f..6c4a7e64 100644 --- a/packages/secubox-toolbox/secubox_toolbox/api.py +++ b/packages/secubox-toolbox/secubox_toolbox/api.py @@ -78,6 +78,39 @@ async def toolbox_bundle(mh: str = Query(default=""), wg: int = Query(default=0) ) +@router.get("/__toolbox/set-level") +async def toolbox_set_level(mh: str = Query(default=""), level: str = Query(default="")) -> JSONResponse: + """#724 — banner self-service level switch. Reverse-proxied to the portal by + sbxmitm (same-origin from the page) like /__toolbox/bundle, so it carries no + cookies/CSRF and is identified by the client's baked ``mh`` hash. Persists the + analysis tier for that hash (R3 wg peers have no captive MAC, so this is the + by-hash setter, not the nft-based /change-level).""" + mh = (mh or "").strip().lower() + level = (level or "").strip().lower() + if not (mh and all(c in "0123456789abcdef" for c in mh) and 8 <= len(mh) <= 64): + return JSONResponse({"ok": False, "error": "bad mh"}, status_code=400, + headers={"Cache-Control": "no-store"}) + if level not in ("r0", "r1", "r2", "r3"): + return JSONResponse({"ok": False, "error": "bad level"}, status_code=400, + headers={"Cache-Control": "no-store"}) + # honour the same gates as /change-level + try: + cfg = _get_cfg() + if level == "r2" and not cfg.r2.enabled: + level = "r1" + except Exception: + pass + if level == "r3" and not Path("/etc/secubox/toolbox/wg/server.pubkey").exists(): + level = "r1" + try: + store.set_client_level(mh, level) + bundlemod.invalidate(mh) # drop cached bundle so the new level shows at once + except Exception as e: # pragma: no cover + return JSONResponse({"ok": False, "error": str(e)}, status_code=500, + headers={"Cache-Control": "no-store"}) + return JSONResponse({"ok": True, "level": level}, headers={"Cache-Control": "no-store"}) + + @router.get("/__toolbox/inline") async def toolbox_inline( mh: str = Query(default=""), diff --git a/packages/secubox-toolbox/secubox_toolbox/bundle.py b/packages/secubox-toolbox/secubox_toolbox/bundle.py index 21dc0b5a..ce6dd4df 100644 --- a/packages/secubox-toolbox/secubox_toolbox/bundle.py +++ b/packages/secubox-toolbox/secubox_toolbox/bundle.py @@ -95,6 +95,14 @@ def build_bundle(client_id: str, is_wg: bool = False) -> dict: } +def invalidate(client_id: str) -> None: + """#724 — drop a client's cached bundle (both wg variants) so a level switch + is reflected on the next banner render without waiting for the TTL.""" + cid = client_id or "" + for k in ((cid, True), (cid, False)): + _cache.pop(k, None) + + def get_bundle(client_id: str, is_wg: bool = False) -> dict: """Return the cached bundle for a client, rebuilding past the TTL. Fail-open.""" try: @@ -163,6 +171,35 @@ _BANNER_CORE = r""" var c = document.getElementById("sbx-ck"); if (c) c.textContent = "🍪 " + countCookies() + " cookies"; } + // #724 — inline R0..R3 level switch. Shows the real current level (highlighted) + // and lets the client change it: GET /__toolbox/set-level (same-origin, the Go + // engine reverse-proxies it to the portal), then reload so the new tier applies. + function lvlSwitch(b){ + var cur = String(b.level || "r1").toLowerCase(); + var lv = ["r0","r1","r2","r3"], out = ""; + for (var i=0;i" + lv[i].toUpperCase() + ""; + } + return out + ""; + } + function wireLevels(bar, b){ + var els = bar.querySelectorAll(".sbx-lvl"); + for (var i=0;iSecuBox" + cspProof + tor - + "" + esc((b.level || "r1").toUpperCase()) + "" + + lvlSwitch(b) + "🛰️ " + trk + " trackers" + "🍪 " + ck + " cookies" + pin @@ -192,7 +229,8 @@ _BANNER_CORE = r""" + ""; document.body.appendChild(bar); try { document.body.style.paddingTop = (bar.offsetHeight || 34) + "px"; } catch (_) {} - var btn = bar.querySelector("button"); + wireLevels(bar, b); + var btn = bar.querySelector("button[aria-label=\"dismiss\"]"); if (btn) btn.onclick = function(){ dismissed = true; try { document.body.style.paddingTop = ""; } catch (_) {} bar.remove(); }; } // ensure(): (re)render the banner if it's absent and the bundle is loaded and diff --git a/packages/secubox-toolbox/secubox_toolbox/store.py b/packages/secubox-toolbox/secubox_toolbox/store.py index 1b30a676..a634777b 100644 --- a/packages/secubox-toolbox/secubox_toolbox/store.py +++ b/packages/secubox-toolbox/secubox_toolbox/store.py @@ -251,6 +251,27 @@ def upsert_client(mac_hash: str, ip: str, level: str = "r1") -> None: ) +def set_client_level(mac_hash: str, level: str) -> None: + """#724 — set a client's level by hash only (no ip needed), for the banner + self-service switch on R3 wg peers (which have no captive MAC/ip). Updates the + existing row; inserts a minimal row if the client is unknown.""" + now = int(time.time()) + with _conn() as c: + try: + c.execute("ALTER TABLE clients ADD COLUMN level TEXT NOT NULL DEFAULT 'r1'") + except sqlite3.OperationalError: + pass + cur = c.execute("UPDATE clients SET level=?, last_seen=? WHERE mac_hash=?", + (level, now, mac_hash)) + if cur.rowcount == 0: + c.execute( + "INSERT INTO clients(mac_hash, ip, level, first_seen, last_seen) " + "VALUES (?,?,?,?,?) ON CONFLICT(mac_hash) DO UPDATE SET " + "level=excluded.level, last_seen=excluded.last_seen", + (mac_hash, "?", level, now, now), + ) + + def get_client_level(mac_hash: str) -> str: """Returns 'r0' | 'r1' | 'r2'. Default 'r1' if not found.""" try: