mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 21:38:35 +00:00
Compare commits
No commits in common. "a48f43607b5aec27ca205898d994fc5fcde7d5c0" and "78ad554ece9b4cefd0b2e140e7449cda4a39ef9a" have entirely different histories.
a48f43607b
...
78ad554ece
|
|
@ -24,7 +24,6 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -112,94 +111,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,10 @@
|
||||||
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>")
|
||||||
|
|
@ -139,141 +130,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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", inlineTestScript, true)
|
out, ok := injectIntoBody(enc, "br", "abc123", true, false)
|
||||||
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", inlineTestScript, true)
|
out, ok := injectIntoBody(enc, "zstd", "abc123", true, false)
|
||||||
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", inlineTestScript, false)
|
out, ok := injectIntoBody(enc, "BR", "z", false, 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", inlineTestScript, false)
|
out, ok := injectIntoBody(bad, "br", "x", false, 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", inlineTestScript, false)
|
out, ok := injectIntoBody(bad, "zstd", "x", false, 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", inlineTestScript, false); ok || !bytes.Equal(out, brBomb) {
|
if out, ok := injectIntoBody(brBomb, "br", "x", false, 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", inlineTestScript, false); ok || !bytes.Equal(out, zsBomb) {
|
if out, ok := injectIntoBody(zsBomb, "zstd", "x", false, 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,32 +132,27 @@ func TestInjectCosmeticCaseInsensitive(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInjectInlineBannerAndCosmeticCompose(t *testing.T) {
|
func TestInjectLoaderAndCosmeticCompose(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, inlineTestScript, true))
|
out := string(injectHTML(body, "deadbeef", true, false))
|
||||||
if !strings.Contains(out, bannerGuard) {
|
if !strings.Contains(out, bannerGuard) {
|
||||||
t.Fatalf("banner marker missing after compose: %s", out)
|
t.Fatalf("loader 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)
|
||||||
}
|
}
|
||||||
// The inline banner is an inline <script> carrying the baked body, NOT a src.
|
if !strings.Contains(out, `data-mh="deadbeef"`) {
|
||||||
if !strings.Contains(out, "<script>"+inlineTestScript+"</script>") {
|
t.Fatalf("loader data-mh missing after compose: %s", out)
|
||||||
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 banner but NOT the cosmetic style.
|
// Non-WG (non-R3) clients get the loader 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, inlineTestScript, false))
|
out := string(injectHTML(body, "x", false, false))
|
||||||
if !strings.Contains(out, bannerGuard) {
|
if !strings.Contains(out, bannerGuard) {
|
||||||
t.Fatalf("banner marker missing for non-wg: %s", out)
|
t.Fatalf("loader 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)
|
||||||
|
|
@ -168,7 +163,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", inlineTestScript, true)
|
out, ok := injectIntoBody(gz, "gzip", "mh1", true, false)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("injectIntoBody(gzip) returned ok=false")
|
t.Fatalf("injectIntoBody(gzip) returned ok=false")
|
||||||
}
|
}
|
||||||
|
|
@ -179,8 +174,4 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,39 +146,31 @@ 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 (always, via the INLINE script) AND, for R3 (wg)
|
// body: the transparency-banner loader (always) AND, for R3 (wg) clients, the
|
||||||
// clients, the ad/popup-hiding cosmetic <style> (#662 — the cutover left this
|
// ad/popup-hiding cosmetic <style> (#662 — the cutover left this unported). Both
|
||||||
// unported). Both are idempotent (own guard markers) and order-independent;
|
// are idempotent (own guard markers) and order-independent; running them in the
|
||||||
// running them in the same decompressed step means the cosmetic style benefits
|
// same decompressed step means the cosmetic style benefits from the gzip
|
||||||
// from the gzip handling exactly like the banner. The cosmetic style is gated to
|
// handling exactly like the loader. The cosmetic style is gated to wg because it
|
||||||
// wg because it is an R3-tunnel opt-in behaviour (mirrors the Python addon's
|
// is an R3-tunnel opt-in behaviour (mirrors the Python addon's _is_r3plus gate).
|
||||||
// _is_r3plus gate).
|
func injectHTML(plain []byte, clientHash string, wg, cspBypassed bool) []byte {
|
||||||
//
|
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 (inline banner + R3 cosmetic style) over
|
// injectIntoBody runs the HTML injection (loader + R3 cosmetic style) over a
|
||||||
// a (possibly compressed) HTML body, returning the new body bytes to serve and
|
// (possibly gzip-compressed) HTML body, returning the new body bytes to serve
|
||||||
// whether the body was rewritten. scriptBody (#662) is the COMPLETE inline banner
|
// and whether the body was rewritten. cspBypassed (#662) is threaded into the
|
||||||
// IIFE pre-fetched from the portal; "" → the banner inject is skipped (fail-open).
|
// loader tag as data-csp="1" when a real CSP was relaxed on this page.
|
||||||
//
|
//
|
||||||
// - 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 banner and
|
// UNCHANGED. The caller sets Content-Length to len(out). BOTH the loader 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,
|
||||||
|
|
@ -189,23 +181,23 @@ func injectHTML(plain []byte, scriptBody string, wg 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 injectInlineBanner/injectCosmetic.
|
// gzip/br/zstd. idempotency / placement live inside injectLoader/injectCosmetic.
|
||||||
func injectIntoBody(body []byte, encoding, scriptBody string, wg bool) (out []byte, ok bool) {
|
func injectIntoBody(body []byte, encoding, clientHash string, wg, cspBypassed bool) (out []byte, ok bool) {
|
||||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
case "":
|
case "":
|
||||||
return injectHTML(body, scriptBody, wg), true
|
return injectHTML(body, clientHash, wg, cspBypassed), 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, scriptBody, wg)), true
|
return gzipBytes(injectHTML(plain, clientHash, wg, cspBypassed)), 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, scriptBody, wg))
|
reenc, err := brotliBytes(injectHTML(plain, clientHash, wg, cspBypassed))
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -215,7 +207,7 @@ func injectIntoBody(body []byte, encoding, scriptBody string, wg bool) (out []by
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return body, false // fail open
|
return body, false // fail open
|
||||||
}
|
}
|
||||||
reenc, err := zstdBytes(injectHTML(plain, scriptBody, wg))
|
reenc, err := zstdBytes(injectHTML(plain, clientHash, wg, cspBypassed))
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", inlineTestScript, true)
|
out, ok := injectIntoBody(gzipBytes([]byte(html)), "gzip", "abc123", true, false)
|
||||||
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", inlineTestScript, false)
|
out, ok := injectIntoBody(gzipBytes([]byte(html)), "GZIP", "z", false, 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", inlineTestScript, false)
|
out, ok := injectIntoBody(bad, "gzip", "x", false, 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, "", inlineTestScript, false)
|
out, ok := injectIntoBody(html, "", "deadbeef", false, 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", inlineTestScript, false)
|
out, ok := injectIntoBody(body, "deflate", "x", false, 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", inlineTestScript, false)
|
out, ok := injectIntoBody(big, "gzip", "x", false, 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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -539,26 +539,16 @@ 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 inline banner can
|
// left the demo on, do we relax the page's CSP so the same-origin
|
||||||
// run even on strict-CSP sites. cspBypassed is true iff there was a real
|
// /__toolbox/loader.js can execute even on strict-CSP sites. cspBypassed
|
||||||
// CSP to bypass — it becomes csp=1 on the inline script and the banner
|
// is true iff there was a real CSP to bypass — it becomes data-csp="1" on
|
||||||
// renders a 🔓 as the visible proof. We never strip CSP on non-injected
|
// the loader tag and the portal banner renders a 🔓 as the visible proof.
|
||||||
// responses.
|
// We never strip CSP on non-injected responses.
|
||||||
cspBypassed := false
|
cspBypassed := false
|
||||||
if px.cspDemo {
|
if px.cspDemo {
|
||||||
cspBypassed = relaxCSPForLoader(resp.Header)
|
cspBypassed = relaxCSPForLoader(resp.Header)
|
||||||
}
|
}
|
||||||
// #662 — INLINE the banner (supersedes the <script src="/__toolbox/
|
if out, ok := injectIntoBody(body, resp.Header.Get("Content-Encoding"), clientHash, wg, cspBypassed); ok {
|
||||||
// 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);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,3 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -51,16 +51,13 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,31 +78,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -103,31 +103,26 @@ 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())}
|
||||||
|
|
||||||
|
|
||||||
# ── shared banner JS body (#662) ─────────────────────────────────────────────
|
# Cosmetic client-side loader. Served static + cached; applies the transparency
|
||||||
#
|
# banner from the bundle off the page's critical render path. Per-page stats
|
||||||
# The render + SPA-re-assert + dismiss + countTrackers + 🔓 cspProof logic is
|
# (trackers, cookies) are derived in-browser (Resource Timing / document.cookie),
|
||||||
# IDENTICAL between the legacy src-loader (LOADER_JS, fetched as
|
# so the proxy never scans the body. Self-guarded, dismissible, fail-silent.
|
||||||
# /__toolbox/loader.js → fetch()es /__toolbox/bundle) and the new INLINE banner
|
LOADER_JS = r"""(function(){
|
||||||
# (inline_script(), baked into the page by the Go engine at inject time). To
|
"use strict";
|
||||||
# avoid drift, that logic lives ONCE in _BANNER_CORE; each caller differs only in
|
if (window.__SBX_LOADER__) return; window.__SBX_LOADER__ = 1;
|
||||||
# its PRELUDE — how `bundle`, `mh`, `wg`, `csp`, `dismissed` are obtained:
|
var s = document.currentScript || {};
|
||||||
#
|
var ds = s.dataset || {};
|
||||||
# * LOADER_JS → reads data-mh/data-wg/data-csp off document.currentScript and
|
var mh = ds.mh || "", wg = ds.wg || "0";
|
||||||
# fetch()es the bundle (legacy; kept working for the
|
// #662 CONSENTED-DEMONSTRATION: the engine relaxed this page's CSP so this
|
||||||
# /__toolbox/loader.js route).
|
// loader could run even under a strict policy, and stamped data-csp="1" on our
|
||||||
# * inline → mh/wg/csp/bundle are baked as JS LITERALS (no currentScript,
|
// <script>. When set, the banner shows a 🔓 as VISIBLE proof the page's CSP was
|
||||||
# no fetch) so a site's SERVICE WORKER has nothing same-origin to
|
// bypassed to inject. Absent → no proof emoji (page had no CSP to bypass).
|
||||||
# hijack (leparisien, cnn… run a SW that 404s our assets).
|
var csp = ds.csp || "";
|
||||||
#
|
// SPA support (#662): cache the bundle + remember an explicit dismiss, so the
|
||||||
# _BANNER_CORE assumes `mh`, `wg`, `csp`, `bundle`, `dismissed` are already
|
// banner can be re-asserted after client-side navigation / DOM re-renders
|
||||||
# declared by the prelude and runs render/SPA off them.
|
// (cnn, youtube… swap content without reloading → the one-shot loader would
|
||||||
|
// otherwise vanish). Re-assert never fights a user who clicked ✕.
|
||||||
# render + SPA-re-assert + dismiss + countTrackers + 🔓 cspProof. Shared verbatim
|
var bundle = null, dismissed = false;
|
||||||
# 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 {"&":"&","<":"<",">":">","\"":"""}[c]; }); }
|
return {"&":"&","<":"<",">":">","\"":"""}[c]; }); }
|
||||||
|
|
@ -173,6 +168,10 @@ _BANNER_CORE = r"""
|
||||||
// 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){
|
||||||
|
|
@ -183,93 +182,5 @@ _BANNER_CORE = r"""
|
||||||
});
|
});
|
||||||
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(){});
|
|
||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,6 @@ def test_get_bundle_caches(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_loader_js_is_served_string():
|
def test_loader_js_is_served_string():
|
||||||
# The legacy src-loader uses the currentScript pattern and fetch()es the
|
assert "addEventListener" not in bundle.LOADER_JS # uses currentScript pattern
|
||||||
# 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()")
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
# 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
|
|
||||||
Loading…
Reference in New Issue
Block a user