Compare commits

..

2 Commits

Author SHA1 Message Date
CyberMind
3fcdb8bd9a
Merge pull request #725 from CyberMind-FR/feature/724-toolbox-banner-in-banner-r0-r3-level-swi
Some checks are pending
License Headers / check (push) Waiting to run
toolbox banner: in-banner R0..R3 level switch (closes #724)
2026-06-24 07:51:05 +02:00
cc478872ae feat(toolbox): in-banner R0..R3 level switch — show real state + change it (closes #724)
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}.
2026-06-24 07:50:58 +02:00
6 changed files with 115 additions and 3 deletions

View File

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

View File

@ -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 <devel@cybermind.fr> 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

View File

@ -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 <devel@cybermind.fr> 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

View File

@ -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=""),

View File

@ -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 = "<span id=\"sbx-lvl\" title=\"Niveau d'analyse — clique pour changer\">";
for (var i=0;i<lv.length;i++){ var on = lv[i]===cur;
out += "<button data-lvl=\"" + lv[i] + "\" class=\"sbx-lvl\" style=\"background:"
+ (on?"#148C66":"transparent") + ";color:" + (on?"#0A0E14":"#8A9AA8")
+ ";border:1px solid #148C66;border-radius:3px;padding:0 5px;margin:0 1px;"
+ "font:inherit;font-size:11px;cursor:pointer\">" + lv[i].toUpperCase() + "</button>";
}
return out + "</span>";
}
function wireLevels(bar, b){
var els = bar.querySelectorAll(".sbx-lvl");
for (var i=0;i<els.length;i++){ (function(el){
el.onclick = function(){
var lvl = el.getAttribute("data-lvl");
var who = (typeof mh !== "undefined" && mh) ? mh : (b.client_id || "");
if (!who) return;
el.textContent = "";
fetch("/__toolbox/set-level?mh=" + encodeURIComponent(who) + "&level=" + lvl,
{credentials:"omit", cache:"no-store"})
.then(function(r){ if (r && r.ok) { location.reload(); } else { el.textContent = lvl.toUpperCase(); } })
.catch(function(){ el.textContent = lvl.toUpperCase(); });
};
})(els[i]); }
}
function render(b){
if (dismissed) return;
if (document.getElementById("sbx-banner")) return;
@ -184,7 +221,7 @@ _BANNER_CORE = r"""
bar.innerHTML = "<b style=\"color:#148C66\">SecuBox</b>"
+ cspProof
+ tor
+ "<span>" + esc((b.level || "r1").toUpperCase()) + "</span>"
+ lvlSwitch(b)
+ "<span id=\"sbx-trk\">🛰️ " + trk + " trackers</span>"
+ "<span id=\"sbx-ck\">🍪 " + ck + " cookies</span>"
+ pin
@ -192,7 +229,8 @@ _BANNER_CORE = r"""
+ "<button aria-label=\"dismiss\" style=\"background:none;border:0;color:#8A9AA8;cursor:pointer;font-size:14px\">✕</button>";
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

View File

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