Compare commits

..

6 Commits

Author SHA1 Message Date
72e8cbd2db release(toolbox-ng): 0.1.23 — rebuild master (#751 nonce-CSP) + SBX_DEBUG_CSP
Some checks are pending
License Headers / check (push) Waiting to run
2026-06-27 08:02:45 +02:00
827165e6fd release(toolbox): 2.7.21 — bundle.py banner reconciliation (#754) 2026-06-27 07:57:09 +02:00
0d906b1471 Merge branch 'fix/754-bundle-reconcile' — bundle.py to #740 DOM-API banner + R4 + #752 guard (ref #754)
Brings master's bundle.py up to the working board version: the #740 mk() DOM-API
banner (Trusted-Types-proof — why x.com/news render), the R4 analyst tier (#736)
folded into its level switch, and the #752 top-frame guard (no banner in 3rd-party
iframes). Folds in fix/752. Reviewed APPROVED (6/6 named risks clean, 179 tests).
2026-06-27 07:54:25 +02:00
d1607328fd fix(toolbox): reconcile bundle.py to master — #740 DOM-API banner + R4 tier + #752 top-frame guard (ref #754)
The board ran the #740 DOM-API (mk(), Trusted-Types-proof) banner from the
unmerged feature/740 branch; master had diverged with the R4 tier (#736) on an
innerHTML banner. This brings master's bundle.py up to the working board version
(mk() rendering — TT/strict-CSP-proof, why x.com/news render), adds the R4 tier
into the #740 level switch, and folds in the #752 top-frame guard (no banner in
3rd-party iframes). Updated 2 stale inline tests: the #653 'no fetch at load'
assertion is now '/__toolbox/bundle' not fetched — #740's toggle handlers fetch
/set-* on user click, which is not the SW-hijackable load-time bundle fetch.
2026-06-27 07:52:01 +02:00
aae47c6e2e Merge branch 'fix/751-sbxmitm-csp-debug' — SBX_DEBUG_CSP banner/CSP diagnostic (ref #751)
Root cause of the x.com/news banner failures was a STALE board sbxmitm binary
lagging master's #728 nonce-borrow; redeploying a master build fixed them.
This adds a permanent opt-in CSP diagnostic (SBX_DEBUG_CSP) to pinpoint why a
banner does/doesn't render on a given site.
2026-06-27 07:21:29 +02:00
4329ab2d7b feat(sbxmitm): SBX_DEBUG_CSP diagnostic log for banner/CSP visibility (ref #751) 2026-06-27 07:15:30 +02:00
5 changed files with 130 additions and 30 deletions

View File

@ -29,6 +29,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -471,6 +472,26 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
if px.cspDemo { if px.cspDemo {
cspNonce, cspBypassed = relaxCSPForLoader(resp.Header) cspNonce, cspBypassed = relaxCSPForLoader(resp.Header)
} }
// CSP diagnostic (#751) — opt-in via SBX_DEBUG_CSP, off by default (zero cost
// when unset). For every injected HTML response it logs what relaxCSPForLoader
// actually saw — the proto, the count of CSP / CSP-Report-Only headers visible
// in resp.Header, the borrowed nonce and the bypass decision. Kept as a
// permanent operator tool: it pinpoints why a banner does/doesn't render on a
// given site (header present? nonce-source? hash-only? strict-dynamic?), the
// class of problem that took an x.com-shaped CSP to surface.
if os.Getenv("SBX_DEBUG_CSP") != "" {
csps := resp.Header.Values("Content-Security-Policy")
cspRO := resp.Header.Values("Content-Security-Policy-Report-Only")
head := ""
if len(csps) > 0 {
head = csps[0]
if len(head) > 220 {
head = head[:220]
}
}
log.Printf("[csp-debug] host=%s proto=%s status=%d cspHdrs=%d cspRO=%d nonce=%q bypassed=%v head=%q",
host, resp.Proto, resp.StatusCode, len(csps), len(cspRO), cspNonce, cspBypassed, head)
}
// #662 — INLINE the banner (supersedes the <script src="/__toolbox/loader.js"> // #662 — INLINE the banner (supersedes the <script src="/__toolbox/loader.js">
// tag): sites with a SERVICE WORKER hijack the same-origin src before it // tag): sites with a SERVICE WORKER hijack the same-origin src before it
// reaches this engine. We fetch the COMPLETE script body from the portal // reaches this engine. We fetch the COMPLETE script body from the portal

View File

@ -1,3 +1,14 @@
secubox-toolbox-ng (0.1.23-1~bookworm1) bookworm; urgency=medium
* #751 rebuild from master: the deployed 0.1.22 binary was STALE and lacked
the working #728 nonce-CSP borrow, so the transparency banner was blocked on
nonce-CSP sites (x.com) — the inline <script> got no nonce. A fresh master
build restores the nonce-borrow (banner renders on x.com + news). Adds the
SBX_DEBUG_CSP opt-in diagnostic (logs per-injected-response CSP visibility +
borrow decision; off by default, zero cost when unset).
-- Gerald KERMA <devel@cybermind.fr> Sat, 27 Jun 2026 06:30:00 +0000
secubox-toolbox-ng (0.1.22-1~bookworm1) bookworm; urgency=medium secubox-toolbox-ng (0.1.22-1~bookworm1) bookworm; urgency=medium
* media catcher: ship a tmpfiles.d entry so /run/secubox/media-catch.jsonl is * media catcher: ship a tmpfiles.d entry so /run/secubox/media-catch.jsonl is

View File

@ -1,3 +1,13 @@
secubox-toolbox (2.7.21-1~bookworm1) bookworm; urgency=medium
* #754 reconcile bundle.py to the working #740 DOM-API banner (mk() builder,
Trusted-Types-proof — renders on x.com/news/strict-CSP) + the R4 tier folded
into its level switch + the #752 top-frame guard (banner never renders in a
3rd-party iframe, e.g. the Dailymotion player on leparisien). Closes the
deployed-but-unmerged feature/740 banner drift. Reviewed; 179 toolbox tests.
-- Gerald KERMA <devel@cybermind.fr> Sat, 27 Jun 2026 06:00:00 +0000
secubox-toolbox (2.7.20-1~bookworm1) bookworm; urgency=medium secubox-toolbox (2.7.20-1~bookworm1) bookworm; urgency=medium
* R4 analyst tier (#736): add R4 to the banner topbar level switch * R4 analyst tier (#736): add R4 to the banner topbar level switch

View File

@ -81,6 +81,15 @@ def _tor_mode() -> bool:
return False return False
def _ad_guard() -> bool:
"""Master ad-block switch on? (#740) Read from filters; default on."""
try:
from .filters import get_filters
return bool(get_filters().get("ad_guard", True))
except Exception:
return True
def build_bundle(client_id: str, is_wg: bool = False) -> dict: def build_bundle(client_id: str, is_wg: bool = False) -> dict:
"""Build the per-client cosmetic decision bundle (pure given inputs + pin file).""" """Build the per-client cosmetic decision bundle (pure given inputs + pin file)."""
return { return {
@ -91,6 +100,7 @@ def build_bundle(client_id: str, is_wg: bool = False) -> dict:
"report_url": _report_url(client_id, is_wg), "report_url": _report_url(client_id, is_wg),
"tracker_patterns": TRACKER_PATTERNS, "tracker_patterns": TRACKER_PATTERNS,
"tor_mode": _tor_mode(), "tor_mode": _tor_mode(),
"ad_guard": _ad_guard(),
"ts": int(time.time()), "ts": int(time.time()),
} }
@ -103,6 +113,12 @@ def invalidate(client_id: str) -> None:
_cache.pop(k, None) _cache.pop(k, None)
def invalidate_all() -> None:
"""#740 — drop ALL cached bundles after a GLOBAL filter change (ad_guard /
tor_mode toggled from the banner) so every client picks up the new state."""
_cache.clear()
def get_bundle(client_id: str, is_wg: bool = False) -> dict: def get_bundle(client_id: str, is_wg: bool = False) -> dict:
"""Return the cached bundle for a client, rebuilding past the TTL. Fail-open.""" """Return the cached bundle for a client, rebuilding past the TTL. Fail-open."""
try: try:
@ -146,6 +162,13 @@ def get_bundle(client_id: str, is_wg: bool = False) -> dict:
# + 2s poll; the prelude calls ensure() (inline) or sets `bundle` then ensure()s # + 2s poll; the prelude calls ensure() (inline) or sets `bundle` then ensure()s
# (src-loader). # (src-loader).
_BANNER_CORE = r""" _BANNER_CORE = r"""
// #752 — top-frame ONLY: never render inside an embedded sub-frame (3rd-party
// video players e.g. geo.dailymotion.com, ad/consent iframes). A same-origin
// iframe trips window.top !== window.self; a cross-origin one throws on the
// access both bail. The transparency banner belongs on the top document,
// once. Returns from the IIFE before any function runs (ensure() is appended
// after this block by both the inline and src-loader preludes).
try { if (window.top !== window.self) return; } catch (_) { return; }
function ready(fn){ if (document.body) { fn(); } else { setTimeout(function(){ready(fn);}, 30); } } function ready(fn){ if (document.body) { fn(); } else { setTimeout(function(){ready(fn);}, 30); } }
function esc(t){ return String(t).replace(/[&<>"]/g, function(c){ function esc(t){ return String(t).replace(/[&<>"]/g, function(c){
return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]; }); } return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]; }); }
@ -174,19 +197,34 @@ _BANNER_CORE = r"""
// #724 — inline R0..R3 level switch. Shows the real current level (highlighted) // #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 // 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. // engine reverse-proxies it to the portal), then reload so the new tier applies.
// #740 — mk(): build an element via DOM API only. textContent / setAttribute are
// NOT Trusted Types sinks (unlike innerHTML), so the banner renders on EVERY site
// incl. strict-CSP/Trusted-Types ones (franceinfo, leparisien, 20minutes, cnn,
// x/twitter) with ZERO CSP/TT bypass. This is the robust, permanent fix.
function mk(tag, opts){
var e = document.createElement(tag);
opts = opts || {};
if (opts.id) e.id = opts.id;
if (opts.cls) e.className = opts.cls;
if (opts.title) e.title = opts.title;
if (opts.text != null) e.textContent = opts.text;
if (opts.style) e.setAttribute("style", opts.style);
if (opts.attrs) for (var k in opts.attrs) if (Object.prototype.hasOwnProperty.call(opts.attrs, k)) e.setAttribute(k, opts.attrs[k]);
return e;
}
var BTN = "border-radius:3px;padding:0 5px;margin:0 2px;font:inherit;font-size:11px;cursor:pointer";
function lvlSwitch(b){ function lvlSwitch(b){
var cur = String(b.level || "r1").toLowerCase(); var cur = String(b.level || "r1").toLowerCase();
// #736 — R4 = analyst / reverse-catcher tier (deepest): everything is MITM'd // #736 — R4 = analyst / reverse-catcher tier (deepest): everything MITM'd +
// and media URLs are caught for cloning. Selectable here; functionally the // media URLs caught for cloning. Reconciled into the #740 DOM-API switch.
// box already runs MITM-everything by default. var lv = ["r0","r1","r2","r3","r4"];
var lv = ["r0","r1","r2","r3","r4"], out = "<span id=\"sbx-lvl\" title=\"Niveau d'analyse — R4 = analyste/capteur média, clique pour changer\">"; var span = mk("span", {id:"sbx-lvl", title:"Niveau d'analyse — R4 = analyste/capteur média, clique pour changer"});
for (var i=0;i<lv.length;i++){ var on = lv[i]===cur; for (var i=0;i<lv.length;i++){ var on = lv[i]===cur;
out += "<button data-lvl=\"" + lv[i] + "\" class=\"sbx-lvl\" style=\"background:" span.appendChild(mk("button", {cls:"sbx-lvl", text:lv[i].toUpperCase(), attrs:{"data-lvl":lv[i]},
+ (on?"#148C66":"transparent") + ";color:" + (on?"#0A0E14":"#8A9AA8") style:"background:"+(on?"#148C66":"transparent")+";color:"+(on?"#0A0E14":"#8A9AA8")
+ ";border:1px solid #148C66;border-radius:3px;padding:0 5px;margin:0 1px;" +";border:1px solid #148C66;"+BTN}));
+ "font:inherit;font-size:11px;cursor:pointer\">" + lv[i].toUpperCase() + "</button>";
} }
return out + "</span>"; return span;
} }
function wireLevels(bar, b){ function wireLevels(bar, b){
var els = bar.querySelectorAll(".sbx-lvl"); var els = bar.querySelectorAll(".sbx-lvl");
@ -210,29 +248,46 @@ _BANNER_CORE = r"""
var ck = countCookies(); var ck = countCookies();
var bar = document.createElement("div"); var bar = document.createElement("div");
bar.id = "sbx-banner"; bar.id = "sbx-banner";
bar.setAttribute("style", "position:fixed;left:0;right:0;top:0;z-index:2147483647;" // #740 — !important on the visibility-critical props makes the banner IMMUNE
// to any stylesheet cosmetic hide (inline !important outranks author
// stylesheet !important): it stays ABOVE everything and visible, always.
bar.setAttribute("style", "position:fixed!important;left:0;right:0;top:0;z-index:2147483647!important;"
+ "display:flex!important;visibility:visible!important;opacity:1!important;"
+ "font:12px/1.4 system-ui,-apple-system,sans-serif;background:#0A0E14;color:#E8E6E0;" + "font:12px/1.4 system-ui,-apple-system,sans-serif;background:#0A0E14;color:#E8E6E0;"
+ "border-bottom:2px solid #148C66;padding:6px 12px;display:flex;gap:14px;align-items:center;" + "border-bottom:2px solid #148C66;padding:6px 12px;gap:14px;align-items:center;"
+ "box-shadow:0 2px 12px rgba(0,0,0,.4)"); + "box-shadow:0 2px 12px rgba(0,0,0,.4)");
var pin = b.pin ? "<span title=\"pinned\">📌 " + esc(b.pin) + "</span>" : ""; // #740 — built entirely with DOM API (no innerHTML → not a Trusted Types
// #662 — 🔓 proof: the engine relaxed this page's CSP to inject this banner. // sink), so the banner renders identically on every site, strict-CSP/TT
var cspProof = (csp === "1") // included, without touching the page's CSP.
? "<span title=\"CSP contourné par SecuBox (démonstration)\">🔓</span>" : ""; bar.appendChild(mk("b", {text:"SecuBox", style:"color:#148C66"}));
// #683 — 🧅 kbin Tor mode: this session's exit is anonymised via Tor. if (csp === "1") bar.appendChild(mk("span", {text:"🔓", title:"CSP contourné par SecuBox (démonstration)"}));
var tor = b.tor_mode bar.appendChild(mk("button", {id:"sbx-tor", text:"🧅 " + (b.tor_mode?"ON":"OFF"),
? "<span title=\"Sortie anonymisée via Tor\" style=\"color:#9E76FF;font-weight:bold\">🧅 Tor</span>" : ""; title:"Tor du tunnel toolbox — clique pour basculer",
bar.innerHTML = "<b style=\"color:#148C66\">SecuBox</b>" style:"background:"+(b.tor_mode?"#3D2A6B":"transparent")+";color:"+(b.tor_mode?"#C9B8FF":"#8A9AA8")+";border:1px solid #6E40C9;"+BTN}));
+ cspProof bar.appendChild(lvlSwitch(b));
+ tor bar.appendChild(mk("button", {id:"sbx-adg", text:"🛡️ " + (b.ad_guard===false?"OFF":"ON"),
+ lvlSwitch(b) title:"Ad-Guard (blocage pub) — clique pour basculer",
+ "<span id=\"sbx-trk\">🛰️ " + trk + " trackers</span>" style:"background:"+(b.ad_guard===false?"transparent":"#148C66")+";color:"+(b.ad_guard===false?"#8A9AA8":"#0A0E14")+";border:1px solid #148C66;"+BTN}));
+ "<span id=\"sbx-ck\">🍪 " + ck + " cookies</span>" bar.appendChild(mk("span", {id:"sbx-trk", text:"🛰️ " + trk + " trackers"}));
+ pin bar.appendChild(mk("span", {id:"sbx-ck", text:"🍪 " + ck + " cookies"}));
+ "<a href=\"" + esc(b.report_url || "#") + "\" style=\"margin-left:auto;color:#2C70C0;text-decoration:none\">report ▸</a>" if (b.pin) bar.appendChild(mk("span", {text:"📌 " + b.pin, title:"pinned"}));
+ "<button aria-label=\"dismiss\" style=\"background:none;border:0;color:#8A9AA8;cursor:pointer;font-size:14px\">✕</button>"; bar.appendChild(mk("a", {text:"report ▸", style:"margin-left:auto;color:#2C70C0;text-decoration:none", attrs:{href: b.report_url || "#"}}));
bar.appendChild(mk("button", {text:"", title:"dismiss",
style:"background:none;border:0;color:#8A9AA8;cursor:pointer;font-size:14px", attrs:{"aria-label":"dismiss"}}));
document.body.appendChild(bar); document.body.appendChild(bar);
try { document.body.style.paddingTop = (bar.offsetHeight || 34) + "px"; } catch (_) {} try { document.body.style.paddingTop = (bar.offsetHeight || 34) + "px"; } catch (_) {}
wireLevels(bar, b); wireLevels(bar, b);
// #740 — 🛡️ Ad-Guard + 🧅 Tor quick-toggles (mirror the level switch wiring).
var adg = bar.querySelector("#sbx-adg");
if (adg) adg.onclick = function(){ var on = b.ad_guard!==false; adg.textContent="";
fetch("/__toolbox/set-adguard?on=" + (on?"0":"1"), {credentials:"omit",cache:"no-store"})
.then(function(r){ if(r&&r.ok) location.reload(); else adg.textContent="🛡️ "+(on?"ON":"OFF"); })
.catch(function(){ adg.textContent="🛡️ "+(on?"ON":"OFF"); }); };
var tg = bar.querySelector("#sbx-tor");
if (tg) tg.onclick = function(){ var on = !!b.tor_mode; tg.textContent="";
fetch("/__toolbox/set-tor?on=" + (on?"0":"1"), {credentials:"omit",cache:"no-store"})
.then(function(r){ if(r&&r.ok) location.reload(); else tg.textContent="🧅 "+(on?"ON":"OFF"); })
.catch(function(){ tg.textContent="🧅 "+(on?"ON":"OFF"); }); };
var btn = bar.querySelector("button[aria-label=\"dismiss\"]"); var btn = bar.querySelector("button[aria-label=\"dismiss\"]");
if (btn) btn.onclick = function(){ dismissed = true; try { document.body.style.paddingTop = ""; } catch (_) {} bar.remove(); }; if (btn) btn.onclick = function(){ dismissed = true; try { document.body.style.paddingTop = ""; } catch (_) {} bar.remove(); };
} }

View File

@ -66,10 +66,13 @@ def test_inline_csp_literal_and_proof_logic():
def test_inline_has_no_currentscript_no_fetch(): def test_inline_has_no_currentscript_no_fetch():
# #653 root cause: document.currentScript is null in an async context. The # #653 root cause: document.currentScript is null in an async context. The
# inline script MUST NOT read it, and MUST NOT fetch() (SW would hijack it). # inline script MUST NOT read it, and MUST NOT fetch the BUNDLE at load time
# (a SW would hijack a same-origin /__toolbox/bundle fetch → no banner). The
# bundle is baked inline instead. (#740 toggle handlers DO fetch /set-level
# etc., but only on a user click — not a load-time SW-hijackable resource.)
s = bundle.inline_script("x", wg=True, csp=True) s = bundle.inline_script("x", wg=True, csp=True)
assert "currentScript" not in s assert "currentScript" not in s
assert "fetch(" not in s assert "/__toolbox/bundle" not in s
def test_inline_keeps_guards_and_spa_hooks(): def test_inline_keeps_guards_and_spa_hooks():
@ -133,6 +136,6 @@ def test_inline_route_returns_javascript_body():
body = resp.body.decode("utf-8") body = resp.body.decode("utf-8")
assert "window.__SBX_LOADER__" in body assert "window.__SBX_LOADER__" in body
assert "currentScript" not in body assert "currentScript" not in body
assert "fetch(" not in body assert "/__toolbox/bundle" not in body # bundle baked inline, not fetched (#740 toggles fetch /set-* on click only)
assert 'var mh = "abc";' in body assert 'var mh = "abc";' in body
assert 'var csp = "1";' in body assert 'var csp = "1";' in body