Compare commits

..

4 Commits

Author SHA1 Message Date
a48f43607b fix(toolbox): drop QUIC (UDP443) BEFORE the outbound accept — was after → never fired → HTTP/3 bypassed the whole MITM (no inject/adblock/metrics/social) (ref #662)
Some checks are pending
License Headers / check (push) Waiting to run
2026-06-19 11:10:29 +02:00
CyberMind
27ba48c1a1
Merge pull request #680 from CyberMind-FR/feat/662-inline-banner
feat(#662): inline the banner (SW-immune) — defeat site service workers
2026-06-19 11:02:52 +02:00
c04a9d0c1c chore: changelog 0.1.13 — inline SW-immune banner (ref #662) 2026-06-19 11:01:47 +02:00
3009ef93d9 fix(toolbox): inline transparency banner — survive sites with a service worker (ref #662)
Sites with a SERVICE WORKER (leparisien, cnn…) intercept every same-origin
request, so the legacy <script src="/__toolbox/loader.js"> + its
fetch("/__toolbox/bundle") were hijacked by the page SW (404 / app-shell)
before reaching the MITM engine → banner never appeared. Fix: INLINE the
banner — the engine fetches the complete script body server-side at inject time
and bakes a self-contained <script>…</script> with mh/wg/csp + the bundle as JS
literals. No same-origin fetch for the SW to touch.

Avoids the #653 failure: the inline script reads NO document.currentScript
(null in async) and does NO fetch() — everything is baked as literals.

Python portal:
- new GET /__toolbox/inline?mh=&wg=&csp= → complete inline banner script body.
- refactor bundle.py: extract shared render/SPA/dismiss/countTrackers/🔓 logic
  into _BANNER_CORE; inline_script(mh,wg,csp) bakes the bundle (get_bundle) as a
  JSON literal + mh/wg/csp string literals. Legacy LOADER_JS (src-loader) kept
  working off the same core. </script> breakout hardened (</ → <\/).

Go engine:
- fetchInlineBanner(): GET portal /__toolbox/inline via the short-timeout portal
  client; fail-open (ok=false → skip inject, page intact).
- injectInlineBanner(): idempotent (same bannerGuard), same placement as
  injectLoader, emits an inline <script> (not <script src>).
- live inject path uses the inline banner; injectLoader + /__toolbox/loader.js
  route kept. Cosmetic <style> (already inline, SW-immune) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:00:27 +02:00
13 changed files with 605 additions and 76 deletions

View File

@ -24,6 +24,7 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
) )
@ -111,6 +112,94 @@ func injectLoader(body []byte, clientHash string, wg, cspBypassed bool) []byte {
return body return body
} }
// ── inline banner (#662, supersedes injectLoader in the live path) ──────────
//
// Sites with a SERVICE WORKER (leparisien, cnn…) intercept EVERY same-origin
// request, so the legacy <script src="/__toolbox/loader.js"> tag and the
// fetch("/__toolbox/bundle") it makes are hijacked by the page's SW (404 /
// app-shell) BEFORE they reach this engine → the banner never appears. The fix
// is to INLINE the whole banner: the engine fetches the COMPLETE script body
// from the portal server-side (once per injected HTML response) and bakes it
// into a self-contained <script>…</script> with mh/wg/csp + the bundle as JS
// literals — so there is NOTHING same-origin for the SW to hijack.
//
// injectLoader + the /__toolbox/loader.js short-circuit are KEPT (not removed)
// for compatibility, but the live inject path now uses the inline banner.
// fetchInlineBanner fetches the COMPLETE inline banner script BODY from the
// portal's /__toolbox/inline endpoint (which bakes mh/wg/csp + the bundle as JS
// literals). Returns (body, true) on a 2xx; FAIL-OPEN (returns "", false) on any
// error — portal down, timeout, non-2xx, read failure — so the caller simply
// skips the inject and serves the page intact (no banner, like today's fail-open
// when the portal asset 204s). It NEVER breaks a navigation over a banner.
//
// wg → "1" else "0"; cspBypassed → csp=1 (the 🔓 proof) else 0; clientHash is
// ascii-sanitised exactly like the data-mh attribute was.
func fetchInlineBanner(portal, clientHash string, wg, cspBypassed bool) (string, bool) {
wgVal := "0"
if wg {
wgVal = "1"
}
cspVal := "0"
if cspBypassed {
cspVal = "1"
}
q := url.Values{}
q.Set("mh", asciiOnly(clientHash))
q.Set("wg", wgVal)
q.Set("csp", cspVal)
target := strings.TrimRight(portal, "/") + "/__toolbox/inline?" + q.Encode()
resp, err := portalClient.Get(target)
if err != nil {
log.Printf("inline banner fetch failed for %s: %v", target, err)
return "", false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Printf("inline banner fetch non-2xx (%d) for %s", resp.StatusCode, target)
return "", false
}
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if rerr != nil {
log.Printf("inline banner read failed for %s: %v", target, rerr)
return "", false
}
return string(body), true
}
// injectInlineBanner inserts a SELF-CONTAINED <script>scriptBody</script> into an
// HTML body once. It is idempotent via the SAME bannerGuard marker injectLoader
// uses (so a body already carrying either form is never double-injected), and it
// uses the SAME placement injectLoader did:
// - guard idempotency: body already contains bannerGuard → unchanged.
// - after the first (case-insensitive) "<head"'s closing '>'.
// - else right BEFORE the first "<body".
// - else return the body unchanged (no inject).
//
// scriptBody is the COMPLETE inline IIFE from fetchInlineBanner (NOT a src tag);
// an empty scriptBody is a no-op (returns the body unchanged) so a failed/skipped
// fetch is handled gracefully by the caller passing "".
func injectInlineBanner(body []byte, scriptBody string) []byte {
if scriptBody == "" {
return body
}
if bytes.Contains(body, []byte(bannerGuard)) {
return body
}
script := []byte("<!-- " + bannerGuard + " --><script>" + scriptBody + "</script>")
low := bytes.ToLower(body)
if h := bytes.Index(low, []byte("<head")); h >= 0 {
if j := bytes.IndexByte(body[h:], '>'); j >= 0 {
return spliceAt(body, script, h+j+1)
}
}
if b := bytes.Index(low, []byte("<body")); b >= 0 {
return spliceAt(body, script, b)
}
return body
}
// ── /__toolbox/* reverse-proxy to the portal ───────────────────────────────── // ── /__toolbox/* reverse-proxy to the portal ─────────────────────────────────
// isToolboxAssetPath reports whether a request path is one of the banner assets // isToolboxAssetPath reports whether a request path is one of the banner assets

View File

@ -10,10 +10,19 @@
package main package main
import ( import (
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
) )
// inlineTestScript is a stand-in for the COMPLETE inline banner body that
// fetchInlineBanner pulls from the portal. The Go engine treats it as an opaque
// string (the JS literal-baking is the portal's job, covered by the Python
// tests); these tests only assert placement / idempotency / fail-open. Shared
// across banner_test, gzip_test, compress_test, cosmetic_test.
const inlineTestScript = `(function(){window.__SBX_LOADER__=1;})();`
func TestInjectLoaderGuardIdempotent(t *testing.T) { func TestInjectLoaderGuardIdempotent(t *testing.T) {
// Body already carrying the guard → returned byte-for-byte unchanged. // Body already carrying the guard → returned byte-for-byte unchanged.
body := []byte("<html><head><!-- " + bannerGuard + " --><script></script></head><body>hi</body></html>") body := []byte("<html><head><!-- " + bannerGuard + " --><script></script></head><body>hi</body></html>")
@ -130,3 +139,141 @@ func TestPortalTargetURL(t *testing.T) {
} }
} }
} }
// ── #662 inline banner (SW-immune; supersedes injectLoader in the live path) ──
func TestInjectInlineBannerEmptyScriptNoop(t *testing.T) {
// scriptBody == "" (fetch failed/skipped) → no inject, body unchanged.
body := []byte(`<html><head></head><body>hi</body></html>`)
out := injectInlineBanner(body, "")
if string(out) != string(body) {
t.Fatalf("empty scriptBody must be a no-op.\n got: %s", out)
}
}
func TestInjectInlineBannerGuardIdempotent(t *testing.T) {
// Body already carrying the guard → returned byte-for-byte unchanged.
body := []byte("<html><head><!-- " + bannerGuard + " --><script></script></head><body>hi</body></html>")
out := injectInlineBanner(body, inlineTestScript)
if string(out) != string(body) {
t.Fatalf("guarded body must be unchanged.\n got: %s", out)
}
}
func TestInjectInlineBannerHeadInsertion(t *testing.T) {
body := []byte(`<html><head lang="en"><title>x</title></head><body>hi</body></html>`)
out := string(injectInlineBanner(body, inlineTestScript))
headOpen := `<head lang="en">`
idx := strings.Index(out, headOpen)
if idx < 0 {
t.Fatalf("head open lost: %s", out)
}
after := out[idx+len(headOpen):]
// An INLINE <script> (not <script src), carrying the body verbatim, right
// after the <head>'s '>'.
wantTag := `<!-- ` + bannerGuard + ` --><script>` + inlineTestScript + `</script>`
if !strings.HasPrefix(after, wantTag) {
t.Fatalf("inline tag not inserted right after <head>'s '>'.\n got: %s", after)
}
if strings.Contains(out, "<script src=") {
t.Fatalf("inline banner must NOT be a <script src> tag: %s", out)
}
if !strings.Contains(out, wantTag+`<title>x</title>`) {
t.Fatalf("original head content displaced: %s", out)
}
}
func TestInjectInlineBannerBodyFallback(t *testing.T) {
body := []byte(`<html><body class="x">hi</body></html>`)
out := string(injectInlineBanner(body, inlineTestScript))
wantTag := `<!-- ` + bannerGuard + ` --><script>` + inlineTestScript + `</script>`
if !strings.Contains(out, wantTag+`<body class="x">`) {
t.Fatalf("inline tag not inserted right before <body>.\n got: %s", out)
}
}
func TestInjectInlineBannerNeitherHeadNorBody(t *testing.T) {
body := []byte(`<p>just a fragment</p>`)
out := injectInlineBanner(body, inlineTestScript)
if string(out) != string(body) {
t.Fatalf("no head/body → must be unchanged.\n got: %s", out)
}
}
func TestInjectInlineBannerCaseInsensitiveHead(t *testing.T) {
body := []byte(`<HTML><HEAD></HEAD><BODY>hi</BODY></HTML>`)
out := string(injectInlineBanner(body, inlineTestScript))
if !strings.Contains(out, `<HEAD><!-- `+bannerGuard) {
t.Fatalf("case-insensitive <HEAD> match failed: %s", out)
}
}
func TestFetchInlineBannerOK(t *testing.T) {
// Portal returns a body + 200 → fetchInlineBanner returns (body, true) and
// echoes mh/wg/csp into the query.
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write([]byte(inlineTestScript))
}))
defer srv.Close()
body, ok := fetchInlineBanner(srv.URL, "deadbeef", true, true)
if !ok {
t.Fatal("fetchInlineBanner must report ok=true on a 200")
}
if body != inlineTestScript {
t.Fatalf("fetchInlineBanner body mismatch: %q", body)
}
for _, want := range []string{"mh=deadbeef", "wg=1", "csp=1"} {
if !strings.Contains(gotQuery, want) {
t.Fatalf("query %q missing %q", gotQuery, want)
}
}
}
func TestFetchInlineBannerWGCSPZero(t *testing.T) {
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = w.Write([]byte(inlineTestScript))
}))
defer srv.Close()
if _, ok := fetchInlineBanner(srv.URL, "x", false, false); !ok {
t.Fatal("ok=true expected")
}
for _, want := range []string{"wg=0", "csp=0"} {
if !strings.Contains(gotQuery, want) {
t.Fatalf("query %q missing %q", gotQuery, want)
}
}
}
func TestFetchInlineBannerFailOpenDeadPortal(t *testing.T) {
// A dead portal (closed listener) → fail-open: ("", false) → caller skips the
// inject and serves the page intact. No panic, no error surfaced.
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close() // close BEFORE the fetch → dial error
body, ok := fetchInlineBanner(url, "x", false, false)
if ok {
t.Fatal("dead portal must fail open (ok=false)")
}
if body != "" {
t.Fatalf("fail-open body must be empty, got %q", body)
}
}
func TestFetchInlineBannerNon2xxFailOpen(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
body, ok := fetchInlineBanner(srv.URL, "x", false, false)
if ok || body != "" {
t.Fatalf("non-2xx must fail open: ok=%v body=%q", ok, body)
}
}

View File

@ -90,7 +90,7 @@ func TestInjectIntoBodyBrotli(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
out, ok := injectIntoBody(enc, "br", "abc123", true, false) out, ok := injectIntoBody(enc, "br", inlineTestScript, true)
if !ok { if !ok {
t.Fatal("br inject must report ok=true") t.Fatal("br inject must report ok=true")
} }
@ -113,7 +113,7 @@ func TestInjectIntoBodyZstd(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
out, ok := injectIntoBody(enc, "zstd", "abc123", true, false) out, ok := injectIntoBody(enc, "zstd", inlineTestScript, true)
if !ok { if !ok {
t.Fatal("zstd inject must report ok=true") t.Fatal("zstd inject must report ok=true")
} }
@ -132,7 +132,7 @@ func TestInjectIntoBodyZstd(t *testing.T) {
func TestInjectIntoBodyBrotliCaseInsensitive(t *testing.T) { func TestInjectIntoBodyBrotliCaseInsensitive(t *testing.T) {
enc, _ := brotliBytes([]byte(`<head></head>`)) enc, _ := brotliBytes([]byte(`<head></head>`))
out, ok := injectIntoBody(enc, "BR", "z", false, false) out, ok := injectIntoBody(enc, "BR", inlineTestScript, false)
if !ok { if !ok {
t.Fatal("Content-Encoding BR (upper) must be recognised → ok=true") t.Fatal("Content-Encoding BR (upper) must be recognised → ok=true")
} }
@ -147,7 +147,7 @@ func TestInjectIntoBodyBrotliCaseInsensitive(t *testing.T) {
func TestInjectIntoBodyBrotliFailOpen(t *testing.T) { func TestInjectIntoBodyBrotliFailOpen(t *testing.T) {
bad := []byte("not brotli at all <head></head>") bad := []byte("not brotli at all <head></head>")
out, ok := injectIntoBody(bad, "br", "x", false, false) out, ok := injectIntoBody(bad, "br", inlineTestScript, false)
if ok { if ok {
t.Fatal("corrupt br body must fail open (ok=false)") t.Fatal("corrupt br body must fail open (ok=false)")
} }
@ -158,7 +158,7 @@ func TestInjectIntoBodyBrotliFailOpen(t *testing.T) {
func TestInjectIntoBodyZstdFailOpen(t *testing.T) { func TestInjectIntoBodyZstdFailOpen(t *testing.T) {
bad := []byte("not zstd at all <head></head>") bad := []byte("not zstd at all <head></head>")
out, ok := injectIntoBody(bad, "zstd", "x", false, false) out, ok := injectIntoBody(bad, "zstd", inlineTestScript, false)
if ok { if ok {
t.Fatal("corrupt zstd body must fail open (ok=false)") t.Fatal("corrupt zstd body must fail open (ok=false)")
} }
@ -177,7 +177,7 @@ func TestBrotliZstdBombGuard(t *testing.T) {
t.Fatal("unbrotliBytes must reject output exceeding gunzipCap") t.Fatal("unbrotliBytes must reject output exceeding gunzipCap")
} }
// fail-open through the inject path. // fail-open through the inject path.
if out, ok := injectIntoBody(brBomb, "br", "x", false, false); ok || !bytes.Equal(out, brBomb) { if out, ok := injectIntoBody(brBomb, "br", inlineTestScript, false); ok || !bytes.Equal(out, brBomb) {
t.Fatal("over-cap br body must fail open with original bytes") t.Fatal("over-cap br body must fail open with original bytes")
} }
@ -188,7 +188,7 @@ func TestBrotliZstdBombGuard(t *testing.T) {
if _, err := unzstdBytes(zsBomb); err == nil { if _, err := unzstdBytes(zsBomb); err == nil {
t.Fatal("unzstdBytes must reject output exceeding gunzipCap") t.Fatal("unzstdBytes must reject output exceeding gunzipCap")
} }
if out, ok := injectIntoBody(zsBomb, "zstd", "x", false, false); ok || !bytes.Equal(out, zsBomb) { if out, ok := injectIntoBody(zsBomb, "zstd", inlineTestScript, false); ok || !bytes.Equal(out, zsBomb) {
t.Fatal("over-cap zstd body must fail open with original bytes") t.Fatal("over-cap zstd body must fail open with original bytes")
} }
} }

View File

@ -132,27 +132,32 @@ func TestInjectCosmeticCaseInsensitive(t *testing.T) {
} }
} }
func TestInjectLoaderAndCosmeticCompose(t *testing.T) { func TestInjectInlineBannerAndCosmeticCompose(t *testing.T) {
// Both markers must be present after composing the two injects (wg client). // Both markers must be present after composing the two injects (wg client).
// #662 — the banner is now the INLINE script (not a <script src> tag).
body := []byte(`<html><head></head><body>hi</body></html>`) body := []byte(`<html><head></head><body>hi</body></html>`)
out := string(injectHTML(body, "deadbeef", true, false)) out := string(injectHTML(body, inlineTestScript, true))
if !strings.Contains(out, bannerGuard) { if !strings.Contains(out, bannerGuard) {
t.Fatalf("loader marker missing after compose: %s", out) t.Fatalf("banner marker missing after compose: %s", out)
} }
if !strings.Contains(out, cosmeticGuard) { if !strings.Contains(out, cosmeticGuard) {
t.Fatalf("cosmetic marker missing after compose: %s", out) t.Fatalf("cosmetic marker missing after compose: %s", out)
} }
if !strings.Contains(out, `data-mh="deadbeef"`) { // The inline banner is an inline <script> carrying the baked body, NOT a src.
t.Fatalf("loader data-mh missing after compose: %s", out) if !strings.Contains(out, "<script>"+inlineTestScript+"</script>") {
t.Fatalf("inline banner body missing after compose: %s", out)
}
if strings.Contains(out, "<script src=") {
t.Fatalf("inline path must NOT emit a <script src> tag: %s", out)
} }
} }
func TestInjectHTMLNonWGSkipsCosmetic(t *testing.T) { func TestInjectHTMLNonWGSkipsCosmetic(t *testing.T) {
// Non-WG (non-R3) clients get the loader but NOT the cosmetic style. // Non-WG (non-R3) clients get the banner but NOT the cosmetic style.
body := []byte(`<html><head></head><body>hi</body></html>`) body := []byte(`<html><head></head><body>hi</body></html>`)
out := string(injectHTML(body, "x", false, false)) out := string(injectHTML(body, inlineTestScript, false))
if !strings.Contains(out, bannerGuard) { if !strings.Contains(out, bannerGuard) {
t.Fatalf("loader marker missing for non-wg: %s", out) t.Fatalf("banner marker missing for non-wg: %s", out)
} }
if strings.Contains(out, cosmeticGuard) { if strings.Contains(out, cosmeticGuard) {
t.Fatalf("cosmetic style must NOT be injected for non-wg client: %s", out) t.Fatalf("cosmetic style must NOT be injected for non-wg client: %s", out)
@ -163,7 +168,7 @@ func TestInjectIntoBodyGzipCarriesCosmetic(t *testing.T) {
// The gzip decompress→inject→recompress path must carry BOTH injects for wg. // The gzip decompress→inject→recompress path must carry BOTH injects for wg.
body := []byte(`<html><head></head><body>hi</body></html>`) body := []byte(`<html><head></head><body>hi</body></html>`)
gz := gzipBytes(body) gz := gzipBytes(body)
out, ok := injectIntoBody(gz, "gzip", "mh1", true, false) out, ok := injectIntoBody(gz, "gzip", inlineTestScript, true)
if !ok { if !ok {
t.Fatalf("injectIntoBody(gzip) returned ok=false") t.Fatalf("injectIntoBody(gzip) returned ok=false")
} }
@ -174,4 +179,8 @@ func TestInjectIntoBodyGzipCarriesCosmetic(t *testing.T) {
if !strings.Contains(string(plain), bannerGuard) || !strings.Contains(string(plain), cosmeticGuard) { if !strings.Contains(string(plain), bannerGuard) || !strings.Contains(string(plain), cosmeticGuard) {
t.Fatalf("gzip path lost a marker: %s", plain) t.Fatalf("gzip path lost a marker: %s", plain)
} }
// The inline banner script body survives the gzip round-trip.
if !strings.Contains(string(plain), "<script>"+inlineTestScript+"</script>") {
t.Fatalf("inline banner body lost on gzip path: %s", plain)
}
} }

View File

@ -146,31 +146,39 @@ func zstdBytes(in []byte) ([]byte, error) {
} }
// injectHTML applies BOTH HTML transforms in one pass over the DECOMPRESSED // injectHTML applies BOTH HTML transforms in one pass over the DECOMPRESSED
// body: the transparency-banner loader (always) AND, for R3 (wg) clients, the // body: the transparency-banner (always, via the INLINE script) AND, for R3 (wg)
// ad/popup-hiding cosmetic <style> (#662 — the cutover left this unported). Both // clients, the ad/popup-hiding cosmetic <style> (#662 — the cutover left this
// are idempotent (own guard markers) and order-independent; running them in the // unported). Both are idempotent (own guard markers) and order-independent;
// same decompressed step means the cosmetic style benefits from the gzip // running them in the same decompressed step means the cosmetic style benefits
// handling exactly like the loader. The cosmetic style is gated to wg because it // from the gzip handling exactly like the banner. The cosmetic style is gated to
// is an R3-tunnel opt-in behaviour (mirrors the Python addon's _is_r3plus gate). // wg because it is an R3-tunnel opt-in behaviour (mirrors the Python addon's
func injectHTML(plain []byte, clientHash string, wg, cspBypassed bool) []byte { // _is_r3plus gate).
out := injectLoader(plain, clientHash, wg, cspBypassed) //
// #662 — scriptBody is the COMPLETE inline banner IIFE pre-fetched server-side
// from the portal (fetchInlineBanner). We INLINE it (injectInlineBanner) instead
// of a <script src="/__toolbox/loader.js"> tag so a site's SERVICE WORKER has no
// same-origin request to hijack. An empty scriptBody (fetch failed/skipped) makes
// the banner inject a no-op — fail-open, page intact. The cosmetic <style> is
// already inline and SW-immune, so it is UNCHANGED.
func injectHTML(plain []byte, scriptBody string, wg bool) []byte {
out := injectInlineBanner(plain, scriptBody)
if wg { if wg {
out = injectCosmetic(out) out = injectCosmetic(out)
} }
return out return out
} }
// injectIntoBody runs the HTML injection (loader + R3 cosmetic style) over a // injectIntoBody runs the HTML injection (inline banner + R3 cosmetic style) over
// (possibly gzip-compressed) HTML body, returning the new body bytes to serve // a (possibly compressed) HTML body, returning the new body bytes to serve and
// and whether the body was rewritten. cspBypassed (#662) is threaded into the // whether the body was rewritten. scriptBody (#662) is the COMPLETE inline banner
// loader tag as data-csp="1" when a real CSP was relaxed on this page. // IIFE pre-fetched from the portal; "" → the banner inject is skipped (fail-open).
// //
// - encoding == "" (identity): injectHTML runs directly on body; the result // - encoding == "" (identity): injectHTML runs directly on body; the result
// is returned (ok=true). The caller MUST update Content-Length to len(out). // is returned (ok=true). The caller MUST update Content-Length to len(out).
// - encoding ∈ {gzip, br, zstd} (case-insensitive): the body is decoded, // - encoding ∈ {gzip, br, zstd} (case-insensitive): the body is decoded,
// injected, then RE-ENCODED in the SAME codec so the client transfer stays // injected, then RE-ENCODED in the SAME codec so the client transfer stays
// compressed (the tunnel is perf-sensitive) and Content-Encoding is // compressed (the tunnel is perf-sensitive) and Content-Encoding is
// UNCHANGED. The caller sets Content-Length to len(out). BOTH the loader and // UNCHANGED. The caller sets Content-Length to len(out). BOTH the banner and
// the cosmetic style are injected on the decompressed body, so the cosmetic // the cosmetic style are injected on the decompressed body, so the cosmetic
// CSS lands on compressed pages too (the common case). // CSS lands on compressed pages too (the common case).
// - any other encoding (deflate, multi-value, …): pass through untouched, // - any other encoding (deflate, multi-value, …): pass through untouched,
@ -181,23 +189,23 @@ func injectHTML(plain []byte, clientHash string, wg, cspBypassed bool) []byte {
// never broken or corrupted. // never broken or corrupted.
// //
// The 32MiB decompression-bomb cap (gunzipCap) is enforced uniformly across // The 32MiB decompression-bomb cap (gunzipCap) is enforced uniformly across
// gzip/br/zstd. idempotency / placement live inside injectLoader/injectCosmetic. // gzip/br/zstd. idempotency / placement live inside injectInlineBanner/injectCosmetic.
func injectIntoBody(body []byte, encoding, clientHash string, wg, cspBypassed bool) (out []byte, ok bool) { func injectIntoBody(body []byte, encoding, scriptBody string, wg bool) (out []byte, ok bool) {
switch strings.ToLower(strings.TrimSpace(encoding)) { switch strings.ToLower(strings.TrimSpace(encoding)) {
case "": case "":
return injectHTML(body, clientHash, wg, cspBypassed), true return injectHTML(body, scriptBody, wg), true
case "gzip": case "gzip":
plain, err := gunzipBytes(body) plain, err := gunzipBytes(body)
if err != nil { if err != nil {
return body, false // fail open: serve the original compressed bytes return body, false // fail open: serve the original compressed bytes
} }
return gzipBytes(injectHTML(plain, clientHash, wg, cspBypassed)), true return gzipBytes(injectHTML(plain, scriptBody, wg)), true
case "br": case "br":
plain, err := unbrotliBytes(body) plain, err := unbrotliBytes(body)
if err != nil { if err != nil {
return body, false // fail open return body, false // fail open
} }
reenc, err := brotliBytes(injectHTML(plain, clientHash, wg, cspBypassed)) reenc, err := brotliBytes(injectHTML(plain, scriptBody, wg))
if err != nil { if err != nil {
return body, false // fail open: never serve a truncated br frame return body, false // fail open: never serve a truncated br frame
} }
@ -207,7 +215,7 @@ func injectIntoBody(body []byte, encoding, clientHash string, wg, cspBypassed bo
if err != nil { if err != nil {
return body, false // fail open return body, false // fail open
} }
reenc, err := zstdBytes(injectHTML(plain, clientHash, wg, cspBypassed)) reenc, err := zstdBytes(injectHTML(plain, scriptBody, wg))
if err != nil { if err != nil {
return body, false // fail open: never serve a truncated zstd frame return body, false // fail open: never serve a truncated zstd frame
} }

View File

@ -44,7 +44,7 @@ func TestInjectIntoBodyGzip(t *testing.T) {
// End-to-end-ish: HTML with <head>, gzipped, run through the exact transform // End-to-end-ish: HTML with <head>, gzipped, run through the exact transform
// the inject path uses. Result must gunzip back to an injected, intact doc. // the inject path uses. Result must gunzip back to an injected, intact doc.
html := `<html><head><title>page</title></head><body>content</body></html>` html := `<html><head><title>page</title></head><body>content</body></html>`
out, ok := injectIntoBody(gzipBytes([]byte(html)), "gzip", "abc123", true, false) out, ok := injectIntoBody(gzipBytes([]byte(html)), "gzip", inlineTestScript, true)
if !ok { if !ok {
t.Fatal("gzip inject must report ok=true") t.Fatal("gzip inject must report ok=true")
} }
@ -68,7 +68,7 @@ func TestInjectIntoBodyGzip(t *testing.T) {
func TestInjectIntoBodyGzipCaseInsensitiveEncoding(t *testing.T) { func TestInjectIntoBodyGzipCaseInsensitiveEncoding(t *testing.T) {
html := `<head></head>` html := `<head></head>`
out, ok := injectIntoBody(gzipBytes([]byte(html)), "GZIP", "z", false, false) out, ok := injectIntoBody(gzipBytes([]byte(html)), "GZIP", inlineTestScript, false)
if !ok { if !ok {
t.Fatal("Content-Encoding GZIP (upper) must be recognised → ok=true") t.Fatal("Content-Encoding GZIP (upper) must be recognised → ok=true")
} }
@ -85,7 +85,7 @@ func TestInjectIntoBodyGzipFailOpen(t *testing.T) {
// Bytes labelled gzip but NOT gzip → fail open: original bytes, ok=false, // Bytes labelled gzip but NOT gzip → fail open: original bytes, ok=false,
// no panic. // no panic.
bad := []byte("not gzip at all <head></head>") bad := []byte("not gzip at all <head></head>")
out, ok := injectIntoBody(bad, "gzip", "x", false, false) out, ok := injectIntoBody(bad, "gzip", inlineTestScript, false)
if ok { if ok {
t.Fatal("corrupt gzip body must fail open (ok=false)") t.Fatal("corrupt gzip body must fail open (ok=false)")
} }
@ -97,7 +97,7 @@ func TestInjectIntoBodyGzipFailOpen(t *testing.T) {
func TestInjectIntoBodyIdentity(t *testing.T) { func TestInjectIntoBodyIdentity(t *testing.T) {
// Identity (empty Content-Encoding): inject directly, grown body returned. // Identity (empty Content-Encoding): inject directly, grown body returned.
html := []byte(`<html><head></head><body>hi</body></html>`) html := []byte(`<html><head></head><body>hi</body></html>`)
out, ok := injectIntoBody(html, "", "deadbeef", false, false) out, ok := injectIntoBody(html, "", inlineTestScript, false)
if !ok { if !ok {
t.Fatal("identity inject must report ok=true") t.Fatal("identity inject must report ok=true")
} }
@ -113,7 +113,7 @@ func TestInjectIntoBodyUnknownEncodingPassthrough(t *testing.T) {
// #662 — gzip/br/zstd are now ALL decoded+re-encoded; deflate (and any other // #662 — gzip/br/zstd are now ALL decoded+re-encoded; deflate (and any other
// codec / multi-value AE) remains an unknown encoding we pass through. // codec / multi-value AE) remains an unknown encoding we pass through.
body := []byte("\x78\x9c some deflate-ish bytes") body := []byte("\x78\x9c some deflate-ish bytes")
out, ok := injectIntoBody(body, "deflate", "x", false, false) out, ok := injectIntoBody(body, "deflate", inlineTestScript, false)
if ok { if ok {
t.Fatal("unknown encoding must pass through (ok=false)") t.Fatal("unknown encoding must pass through (ok=false)")
} }
@ -131,7 +131,7 @@ func TestGunzipBombGuard(t *testing.T) {
t.Fatal("gunzipBytes must reject output exceeding gunzipCap") t.Fatal("gunzipBytes must reject output exceeding gunzipCap")
} }
// And via the inject path: fail open, original bytes preserved. // And via the inject path: fail open, original bytes preserved.
out, ok := injectIntoBody(big, "gzip", "x", false, false) out, ok := injectIntoBody(big, "gzip", inlineTestScript, false)
if ok { if ok {
t.Fatal("over-cap gzip body must fail open through injectIntoBody") t.Fatal("over-cap gzip body must fail open through injectIntoBody")
} }

View File

@ -539,16 +539,26 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
strings.Contains(resp.Header.Get("Content-Type"), "text/html") { strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
// #662 CONSENTED-DEMONSTRATION — ONLY here, on the responses we actually // #662 CONSENTED-DEMONSTRATION — ONLY here, on the responses we actually
// inject into (2xx text/html, R3/wg gate), and ONLY when the operator // inject into (2xx text/html, R3/wg gate), and ONLY when the operator
// left the demo on, do we relax the page's CSP so the same-origin // left the demo on, do we relax the page's CSP so the inline banner can
// /__toolbox/loader.js can execute even on strict-CSP sites. cspBypassed // run even on strict-CSP sites. cspBypassed is true iff there was a real
// is true iff there was a real CSP to bypass — it becomes data-csp="1" on // CSP to bypass — it becomes csp=1 on the inline script and the banner
// the loader tag and the portal banner renders a 🔓 as the visible proof. // renders a 🔓 as the visible proof. We never strip CSP on non-injected
// We never strip CSP on non-injected responses. // responses.
cspBypassed := false cspBypassed := false
if px.cspDemo { if px.cspDemo {
cspBypassed = relaxCSPForLoader(resp.Header) cspBypassed = relaxCSPForLoader(resp.Header)
} }
if out, ok := injectIntoBody(body, resp.Header.Get("Content-Encoding"), clientHash, wg, cspBypassed); ok { // #662 — INLINE the banner (supersedes the <script src="/__toolbox/
// loader.js"> tag): sites with a SERVICE WORKER (leparisien, cnn…) hijack
// the same-origin src + its fetch("/__toolbox/bundle") before they reach
// this engine, so the banner never appeared. We fetch the COMPLETE script
// body from the portal server-side (mh/wg/csp + bundle baked as JS
// literals — no same-origin request for the SW to touch) and bake it into
// a self-contained <script>…</script>. Fail-open: a dead/slow portal →
// scriptBody=="" → the banner inject is skipped and the page is served
// intact (the cosmetic <style>, already inline, is unaffected).
scriptBody, _ := fetchInlineBanner(px.portal, clientHash, wg, cspBypassed)
if out, ok := injectIntoBody(body, resp.Header.Get("Content-Encoding"), scriptBody, wg); ok {
body = out body = out
// Keep the response framing consistent with the served bytes. The // Keep the response framing consistent with the served bytes. The
// encoding is unchanged (gzip stays gzip, identity stays identity); // encoding is unchanged (gzip stays gzip, identity stays identity);

View File

@ -1,3 +1,11 @@
secubox-toolbox-ng (0.1.13-1~bookworm1) bookworm; urgency=medium
* banner: INLINE the banner (server-side bundle fetch, baked literals) instead
of <script src>/fetch — defeats site service workers that intercept the
same-origin /__toolbox/* requests (leparisien, cnn). Fail-open. (ref #662)
-- Gerald KERMA <devel@cybermind.fr> Thu, 19 Jun 2026 13:15:00 +0000
secubox-toolbox-ng (0.1.12-1~bookworm1) bookworm; urgency=medium secubox-toolbox-ng (0.1.12-1~bookworm1) bookworm; urgency=medium
* adlearn: live-reload the blocklist (mtime) so promotions/edits block without * adlearn: live-reload the blocklist (mtime) so promotions/edits block without

View File

@ -51,13 +51,16 @@ table inet wg-toolbox {
chain forward { chain forward {
type filter hook forward priority filter; policy accept; type filter hook forward priority filter; policy accept;
# Phase 6.K / #662 — drop UDP 443 (QUIC/HTTP3) FIRST, before the blanket
# outbound accept below. If it sits AFTER the accept it is never reached
# (the accept terminates evaluation) → QUIC slips through and the whole
# MITM is bypassed (no inject, no ad-block, no metrics, no social). The
# drop forces Chrome/Firefox to fall back to HTTP/2 over TCP, which our
# DNAT intercepts. ORDER IS LOAD-BEARING — keep this rule first.
iif "wg-toolbox" udp dport 443 counter drop
# Outbound from tunnel → internet # Outbound from tunnel → internet
iif "wg-toolbox" oif "lan0" accept iif "wg-toolbox" oif "lan0" accept
# Return traffic # Return traffic
iif "lan0" oif "wg-toolbox" ct state established,related accept iif "lan0" oif "wg-toolbox" ct state established,related accept
# Phase 6.K — drop UDP 443 (QUIC/HTTP3) so browsers fall back to
# HTTP/2 over TCP, which our DNAT can intercept. Without this,
# Chrome/Firefox prefer QUIC and bypass mitm entirely.
iif "wg-toolbox" udp dport 443 counter drop
} }
} }

View File

@ -78,6 +78,31 @@ async def toolbox_bundle(mh: str = Query(default=""), wg: int = Query(default=0)
) )
@router.get("/__toolbox/inline")
async def toolbox_inline(
mh: str = Query(default=""),
wg: int = Query(default=0),
csp: int = Query(default=0),
) -> Response:
"""#662 — COMPLETE self-contained inline banner script BODY.
Sites with a SERVICE WORKER (leparisien, cnn) intercept every same-origin
request, so the legacy ``<script src="/__toolbox/loader.js">`` + its
``fetch("/__toolbox/bundle")`` are hijacked by the SW (404 / app-shell)
before reaching our MITM engine no banner. The Go engine fetches THIS
body server-side at inject time and bakes it into a self-contained
``<script></script>`` no same-origin fetch for the SW to touch.
``mh`` / ``wg`` / ``csp`` come from the query params (baked as JS literals,
not data-attrs / currentScript); the bundle is ``get_bundle(mh, wg)`` baked
as a JSON literal (not fetched). no-store like the loader (it evolves)."""
return Response(
content=bundlemod.inline_script(mh, bool(wg), bool(csp)),
media_type="application/javascript",
headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
)
# #662 — ad-block metrics ingest from the Go MITM engine (sbxmitm). The #662 # #662 — ad-block metrics ingest from the Go MITM engine (sbxmitm). The #662
# cutover moved the BLOCK decision (204 on ad/tracker hosts) into the Go engine # cutover moved the BLOCK decision (204 on ad/tracker hosts) into the Go engine
# but left the METRICS unported, so the #ads dashboard froze. The engine now # but left the METRICS unported, so the #ads dashboard froze. The engine now

View File

@ -103,26 +103,31 @@ def get_bundle(client_id: str, is_wg: bool = False) -> dict:
"tracker_patterns": TRACKER_PATTERNS, "ts": int(time.time())} "tracker_patterns": TRACKER_PATTERNS, "ts": int(time.time())}
# Cosmetic client-side loader. Served static + cached; applies the transparency # ── shared banner JS body (#662) ─────────────────────────────────────────────
# banner from the bundle off the page's critical render path. Per-page stats #
# (trackers, cookies) are derived in-browser (Resource Timing / document.cookie), # The render + SPA-re-assert + dismiss + countTrackers + 🔓 cspProof logic is
# so the proxy never scans the body. Self-guarded, dismissible, fail-silent. # IDENTICAL between the legacy src-loader (LOADER_JS, fetched as
LOADER_JS = r"""(function(){ # /__toolbox/loader.js → fetch()es /__toolbox/bundle) and the new INLINE banner
"use strict"; # (inline_script(), baked into the page by the Go engine at inject time). To
if (window.__SBX_LOADER__) return; window.__SBX_LOADER__ = 1; # avoid drift, that logic lives ONCE in _BANNER_CORE; each caller differs only in
var s = document.currentScript || {}; # its PRELUDE — how `bundle`, `mh`, `wg`, `csp`, `dismissed` are obtained:
var ds = s.dataset || {}; #
var mh = ds.mh || "", wg = ds.wg || "0"; # * LOADER_JS → reads data-mh/data-wg/data-csp off document.currentScript and
// #662 CONSENTED-DEMONSTRATION: the engine relaxed this page's CSP so this # fetch()es the bundle (legacy; kept working for the
// loader could run even under a strict policy, and stamped data-csp="1" on our # /__toolbox/loader.js route).
// <script>. When set, the banner shows a 🔓 as VISIBLE proof the page's CSP was # * inline → mh/wg/csp/bundle are baked as JS LITERALS (no currentScript,
// bypassed to inject. Absent no proof emoji (page had no CSP to bypass). # no fetch) so a site's SERVICE WORKER has nothing same-origin to
var csp = ds.csp || ""; # hijack (leparisien, cnn… run a SW that 404s our assets).
// SPA support (#662): cache the bundle + remember an explicit dismiss, so the #
// banner can be re-asserted after client-side navigation / DOM re-renders # _BANNER_CORE assumes `mh`, `wg`, `csp`, `bundle`, `dismissed` are already
// (cnn, youtube swap content without reloading the one-shot loader would # declared by the prelude and runs render/SPA off them.
// otherwise vanish). Re-assert never fights a user who clicked .
var bundle = null, dismissed = false; # render + SPA-re-assert + dismiss + countTrackers + 🔓 cspProof. Shared verbatim
# by both preludes. References `mh`, `wg`, `csp`, `bundle`, `dismissed` from the
# enclosing prelude scope. Defines ensure() + installs the history/popstate hooks
# + 2s poll; the prelude calls ensure() (inline) or sets `bundle` then ensure()s
# (src-loader).
_BANNER_CORE = r"""
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]; }); }
@ -168,10 +173,6 @@ LOADER_JS = r"""(function(){
// ensure(): (re)render the banner if it's absent and the bundle is loaded and // ensure(): (re)render the banner if it's absent and the bundle is loaded and
// the user hasn't dismissed it. Cheap (a getElementById guard inside render). // the user hasn't dismissed it. Cheap (a getElementById guard inside render).
function ensure(){ if (bundle && !dismissed) ready(function(){ render(bundle); }); } function ensure(){ if (bundle && !dismissed) ready(function(){ render(bundle); }); }
fetch("/__toolbox/bundle?mh=" + encodeURIComponent(mh) + "&wg=" + encodeURIComponent(wg), {credentials:"omit"})
.then(function(r){ return r.json(); })
.then(function(b){ bundle = b; ensure(); })
.catch(function(){});
// SPA re-assert: wrap history nav + popstate (defer so the framework settles), // SPA re-assert: wrap history nav + popstate (defer so the framework settles),
// plus a light 2s poll as a catch-all for DOM re-renders that drop the banner. // plus a light 2s poll as a catch-all for DOM re-renders that drop the banner.
["pushState","replaceState"].forEach(function(m){ ["pushState","replaceState"].forEach(function(m){
@ -182,5 +183,93 @@ LOADER_JS = r"""(function(){
}); });
window.addEventListener("popstate", function(){ setTimeout(ensure, 150); }); window.addEventListener("popstate", function(){ setTimeout(ensure, 150); });
setInterval(ensure, 2000); setInterval(ensure, 2000);
"""
def _js_str(value: str) -> str:
"""JS string LITERAL for an arbitrary string. json.dumps yields a valid JS
string; we additionally escape ``</`` ``<\\/`` so a value can never close
the surrounding inline <script> (e.g. a value of "</script>")."""
return json.dumps(value).replace("</", "<\\/")
def _js_json(obj) -> str:
"""JS object LITERAL for a JSON-serialisable object, hardened against a
``</script>`` breakout: json.dumps is valid JS, and escaping ``</`` ``<\\/``
means no nested string (pin, report_url) can terminate the inline script."""
return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")
def inline_script(mh: str, wg: bool, csp: bool) -> str:
"""Build the COMPLETE self-contained inline banner script BODY (#662).
Service-worker survival: sites like leparisien / cnn register a SW that
intercepts every same-origin request so the legacy
``<script src="/__toolbox/loader.js">`` + its ``fetch("/__toolbox/bundle")``
are hijacked by the SW (404 / app-shell) before reaching our MITM engine, and
the banner never appears. The fix is to bake EVERYTHING as JS literals so the
inline script makes NO same-origin request the SW can touch:
* ``bundle`` is ``get_bundle(mh, wg)`` baked as a JSON literal (not fetched),
* ``mh`` / ``wg`` / ``csp`` are baked as string literals (NOT data-attrs /
currentScript the null-currentScript-in-async bug killed #653),
* NO ``document.currentScript``, NO ``fetch()``.
Returns an IIFE string suitable for ``<script></script>``. The single-run
guard (``window.__SBX_LOADER__``), the ``#sbx-banner`` element-id guard, the
dismissed flag, the history pushState/replaceState/popstate hooks + 2s poll,
and the 🔓 proof when ``csp`` is set are all preserved (from _BANNER_CORE).
"""
bundle_obj = get_bundle(mh, bool(wg))
prelude = (
"(function(){\n"
' "use strict";\n'
" if (window.__SBX_LOADER__) return; window.__SBX_LOADER__ = 1;\n"
# Baked literals — no currentScript / dataset, no fetch (SW-immune).
" var mh = " + _js_str(mh or "") + ";\n"
" var wg = " + _js_str("1" if wg else "0") + ";\n"
# csp=="1" → the engine relaxed a real CSP to inject; render the 🔓 proof.
" var csp = " + _js_str("1" if csp else "0") + ";\n"
" var bundle = " + _js_json(bundle_obj) + ";\n"
" var dismissed = false;\n"
)
# Inline path renders on the first tick — the bundle is already present (no
# async fetch to wait on), so ensure() can run immediately.
return prelude + _BANNER_CORE + " ensure();\n})();"
# Cosmetic client-side loader. Served static + cached; applies the transparency
# banner from the bundle off the page's critical render path. Per-page stats
# (trackers, cookies) are derived in-browser (Resource Timing / document.cookie),
# so the proxy never scans the body. Self-guarded, dismissible, fail-silent.
#
# Legacy src-loader (#620): kept working for the /__toolbox/loader.js route. The
# INLINE path (inline_script) supersedes it in the live engine inject path because
# a site service-worker hijacks the same-origin src + fetch (#662).
_LOADER_PRELUDE = r"""(function(){
"use strict";
if (window.__SBX_LOADER__) return; window.__SBX_LOADER__ = 1;
var s = document.currentScript || {};
var ds = s.dataset || {};
var mh = ds.mh || "", wg = ds.wg || "0";
// #662 CONSENTED-DEMONSTRATION: the engine relaxed this page's CSP so this
// loader could run even under a strict policy, and stamped data-csp="1" on our
// <script>. When set, the banner shows a 🔓 as VISIBLE proof the page's CSP was
// bypassed to inject. Absent no proof emoji (page had no CSP to bypass).
var csp = ds.csp || "";
// SPA support (#662): cache the bundle + remember an explicit dismiss, so the
// banner can be re-asserted after client-side navigation / DOM re-renders
// (cnn, youtube swap content without reloading the one-shot loader would
// otherwise vanish). Re-assert never fights a user who clicked .
var bundle = null, dismissed = false;
"""
# The legacy src-loader fetches the bundle (same-origin), then ensure()s. The
# render + SPA logic is the SAME _BANNER_CORE the inline path uses (no drift).
LOADER_JS = _LOADER_PRELUDE + _BANNER_CORE + r"""
fetch("/__toolbox/bundle?mh=" + encodeURIComponent(mh) + "&wg=" + encodeURIComponent(wg), {credentials:"omit"})
.then(function(r){ return r.json(); })
.then(function(b){ bundle = b; ensure(); })
.catch(function(){});
})(); })();
""" """

View File

@ -48,6 +48,9 @@ def test_get_bundle_caches(monkeypatch):
def test_loader_js_is_served_string(): def test_loader_js_is_served_string():
assert "addEventListener" not in bundle.LOADER_JS # uses currentScript pattern # The legacy src-loader uses the currentScript pattern and fetch()es the
# bundle same-origin (the inline path #662 supersedes it in the live engine
# but /__toolbox/loader.js still serves this).
assert "currentScript" in bundle.LOADER_JS
assert "__toolbox/bundle" in bundle.LOADER_JS assert "__toolbox/bundle" in bundle.LOADER_JS
assert bundle.LOADER_JS.strip().startswith("(function()") assert bundle.LOADER_JS.strip().startswith("(function()")

View File

@ -0,0 +1,138 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""SecuBox-Deb :: toolbox :: inline (SW-immune) banner script tests (#662).
The inline banner survives sites with a SERVICE WORKER (leparisien, cnn): the
engine bakes the bundle + mh/wg/csp as JS literals so there is NO same-origin
fetch the SW can hijack. These tests pin that contract:
* a valid baked `var bundle = {...}` (JSON), mh/wg/csp literals,
* the 🔓 proof gated by csp,
* NO currentScript (the #653 null-in-async bug) and NO fetch(,
* `</script>` is escaped (no inline-script breakout),
* get_bundle is called with (mh, bool(wg)).
"""
import json
import os
import re
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from secubox_toolbox import api, bundle # noqa: E402
def _baked_bundle(script: str) -> dict:
"""Extract + parse the baked `var bundle = {...};` JSON from an inline script.
Undoes the `</` `<\\/` breakout escaping before parsing as JSON."""
m = re.search(r"var bundle = (\{.*?\});\n", script, re.S)
assert m, "no baked `var bundle = {...};` in inline script"
return json.loads(m.group(1).replace("<\\/", "</"))
def test_inline_bakes_valid_bundle_json():
s = bundle.inline_script("x", wg=True, csp=True)
b = _baked_bundle(s)
assert b["v"] == 1
assert b["client_id"] == "x"
# wg=True → public report URL (proves get_bundle was called with wg=True)
assert b["report_url"] == bundle.REPORT_URL_PUBLIC + "?mh=x"
assert isinstance(b["tracker_patterns"], list) and b["tracker_patterns"]
def test_inline_bakes_mh_wg_csp_literals():
s = bundle.inline_script("deadbeef", wg=True, csp=True)
assert 'var mh = "deadbeef";' in s
assert 'var wg = "1";' in s
assert 'var csp = "1";' in s
s0 = bundle.inline_script("deadbeef", wg=False, csp=False)
assert 'var wg = "0";' in s0
assert 'var csp = "0";' in s0
def test_inline_csp_literal_and_proof_logic():
# The 🔓 literal lives in the shared render core, gated at runtime by
# csp === "1". csp=1 → var csp = "1" so render shows the proof.
s1 = bundle.inline_script("x", wg=False, csp=True)
assert "\U0001f513" in s1 # 🔓 present in the render logic
assert 'var csp = "1";' in s1 # runtime gate ON
# csp=0 → gate OFF (no proof rendered), even though the literal is in core.
s0 = bundle.inline_script("x", wg=False, csp=False)
assert 'var csp = "0";' in s0
def test_inline_has_no_currentscript_no_fetch():
# #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).
s = bundle.inline_script("x", wg=True, csp=True)
assert "currentScript" not in s
assert "fetch(" not in s
def test_inline_keeps_guards_and_spa_hooks():
s = bundle.inline_script("x", wg=True, csp=True)
assert "window.__SBX_LOADER__" in s # single-run guard
assert 'getElementById("sbx-banner")' in s # element-id guard
assert "dismissed" in s
assert "pushState" in s and "replaceState" in s and "popstate" in s
assert "setInterval(ensure, 2000)" in s
assert "countTrackers" in s
def test_inline_escapes_script_breakout():
# A bundle value that literally contains </script> must NOT close the inline
# <script> — it must be escaped to <\/script>.
orig = bundle._read_pin
bundle._read_pin = lambda: "</script><img src=x onerror=alert(1)>"
bundle._cache.clear()
try:
s = bundle.inline_script("z", wg=False, csp=False)
finally:
bundle._read_pin = orig
bundle._cache.clear()
# The IIFE close is the only legitimate "})();"; nothing before the final
# close should contain a raw "</script>".
head = s[: s.rfind("})();")]
assert "</script>" not in head
assert "<\\/script>" in head # escaped form present
def test_inline_get_bundle_called_with_bool_wg(monkeypatch):
seen = {}
def fake_get_bundle(mh, is_wg=False):
seen["args"] = (mh, is_wg)
return {"v": 1, "client_id": mh, "level": "r1", "pin": "",
"report_url": "http://x", "tracker_patterns": ["doubleclick"],
"ts": 0}
monkeypatch.setattr(bundle, "get_bundle", fake_get_bundle)
bundle.inline_script("abc", wg=1, csp=0) # wg passed as truthy int
assert seen["args"] == ("abc", True) # coerced to bool
def test_legacy_loader_still_intact():
# The src-loader must keep working: it reads currentScript + data-attrs and
# fetch()es the bundle (the inline path supersedes it in the live engine, but
# the /__toolbox/loader.js route still serves it).
assert "currentScript" in bundle.LOADER_JS
assert "fetch(" in bundle.LOADER_JS
assert "function render" in bundle.LOADER_JS
assert "window.__SBX_LOADER__" in bundle.LOADER_JS
def test_inline_route_returns_javascript_body():
import asyncio
resp = asyncio.run(api.toolbox_inline(mh="abc", wg=1, csp=1))
assert resp.status_code == 200
assert "javascript" in resp.media_type
assert "no-store" in resp.headers.get("Cache-Control", "")
body = resp.body.decode("utf-8")
assert "window.__SBX_LOADER__" in body
assert "currentScript" not in body
assert "fetch(" not in body
assert 'var mh = "abc";' in body
assert 'var csp = "1";' in body