Compare commits

...

5 Commits

Author SHA1 Message Date
ded89934d0 feat(toolbox): SPA-aware banner loader — re-assert on history nav + 2s poll (#655-equivalent, ref #662)
Some checks are pending
License Headers / check (push) Waiting to run
2026-06-19 09:10:23 +02:00
CyberMind
9a843cec72
Merge pull request #675 from CyberMind-FR/feat/662-csp-demo
feat(#662): consented CSP-bypass demo + 🔓 proof emoji
2026-06-19 09:07:48 +02:00
8988a1078a refine(toolbox-ng): CSP 🔓 only on directives that actually block the loader (ref #662) 2026-06-19 09:06:36 +02:00
bfc28f1081 chore: changelog 0.1.7 — CSP-bypass demo + proof emoji (ref #662) 2026-06-19 09:01:30 +02:00
05eedca6e8 feat(toolbox): CONSENTED-DEMONSTRATION CSP relax + 🔓 banner proof (ref #662)
The R3 toolbox ("VILLAGE3B — Qui te piste?") is a consented MITM on the
operator's own traffic that SHOWS what a man-in-the-middle can do. A strict
Content-Security-Policy would stop the injected transparency-banner loader
(<script src="/__toolbox/loader.js">) from executing — so on the R3/wg inject
path, gated by the new --csp-bypass-demo flag (DEFAULT TRUE), the engine
deliberately relaxes the page's CSP just enough to let that one same-origin
loader run, then stamps data-csp="1" on the tag and the portal banner renders
a 🔓 as the VISIBLE proof the page's CSP was bypassed to inject. Intentional,
demonstrative, toggleable.

Go (packages/secubox-toolbox-ng):
- new csp.go: relaxCSPForLoader(http.Header) bool rewrites Content-Security-
  Policy AND Content-Security-Policy-Report-Only (all values). For the script-
  governing directive (script-src / script-src-elem; else default-src) it
  ensures 'self'+'unsafe-inline' and strips 'strict-dynamic' (which would make
  host/'self'/'unsafe-inline' ignored) and 'none'. Other directives untouched.
  Returns true iff a real CSP was present and modified (the proof condition);
  never panics on malformed CSP; no CSP → false, unchanged.
- --csp-bypass-demo bool flag (default true) → Proxy.cspDemo. When false, CSP
  is never touched and the proof flag is never set.
- mitmPipeline: only on the responses we actually inject (2xx text/html, R3/wg
  gate) and only when cspDemo, call relaxCSPForLoader(resp.Header) and thread
  the cspBypassed bool through injectIntoBody/injectHTML/injectLoader → the tag
  gets data-csp="1" only when truly bypassed. Never strip CSP on non-injected
  responses.
- csp_test.go: script-src/strict-dynamic/'none'/default-src/report-only/
  malformed/no-header cases + injectLoader data-csp gating.

Python portal (packages/secubox-toolbox):
- bundle.py LOADER_JS reads data-csp off its own <script>; when "1", renders a
  🔓 (title "CSP contourné par SecuBox (démonstration)") in the banner as the
  visible proof. Absent → no proof emoji, banner unchanged.

Offline vendored build (linux/arm64 + darwin/arm64), go vet, go test -race all
green; existing Go + Python (test_bundle*) tests stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:00:01 +02:00
11 changed files with 543 additions and 52 deletions

View File

@ -53,14 +53,24 @@ func asciiOnly(s string) string {
// carrying the client identity (data-mh) + WG flag (data-wg). wg → "1" else "0"; // carrying the client identity (data-mh) + WG flag (data-wg). wg → "1" else "0";
// clientHash is ascii-sanitised. The src is same-origin so it resolves to the // clientHash is ascii-sanitised. The src is same-origin so it resolves to the
// MITM'd host and is intercepted by the /__toolbox/* short-circuit. // MITM'd host and is intercepted by the /__toolbox/* short-circuit.
func loaderScript(clientHash string, wg bool) []byte { //
// #662 CONSENTED-DEMONSTRATION: when cspBypassed is true (we actually relaxed a
// real CSP on this page so the loader could run), the tag also carries
// data-csp="1" — the portal loader renders a 🔓 from it as the VISIBLE proof
// that the page's CSP was bypassed to inject. The attribute is OMITTED when
// cspBypassed is false so a page with no CSP shows no false proof.
func loaderScript(clientHash string, wg, cspBypassed bool) []byte {
wgVal := "0" wgVal := "0"
if wg { if wg {
wgVal = "1" wgVal = "1"
} }
mh := asciiOnly(clientHash) mh := asciiOnly(clientHash)
cspAttr := ""
if cspBypassed {
cspAttr = ` data-csp="1"`
}
tag := `<script src="/__toolbox/loader.js" data-mh="` + mh + tag := `<script src="/__toolbox/loader.js" data-mh="` + mh +
`" data-wg="` + wgVal + `" async></script>` `" data-wg="` + wgVal + `"` + cspAttr + ` async></script>`
return []byte("<!-- " + bannerGuard + " -->" + tag) return []byte("<!-- " + bannerGuard + " -->" + tag)
} }
@ -71,11 +81,14 @@ func loaderScript(clientHash string, wg bool) []byte {
// after it and insert the tag right after that ">". // after it and insert the tag right after that ">".
// - else find the first "<body" and insert the tag right BEFORE it. // - else find the first "<body" and insert the tag right BEFORE it.
// - if neither is present → return the body unchanged (no inject). // - if neither is present → return the body unchanged (no inject).
func injectLoader(body []byte, clientHash string, wg bool) []byte { //
// cspBypassed (#662): true when a real CSP was relaxed on this page so the
// loader could run; threaded into loaderScript as data-csp="1" (the 🔓 proof).
func injectLoader(body []byte, clientHash string, wg, cspBypassed bool) []byte {
if bytes.Contains(body, []byte(bannerGuard)) { if bytes.Contains(body, []byte(bannerGuard)) {
return body return body
} }
script := loaderScript(clientHash, wg) script := loaderScript(clientHash, wg, cspBypassed)
low := bytes.ToLower(body) low := bytes.ToLower(body)
if h := bytes.Index(low, []byte("<head")); h >= 0 { if h := bytes.Index(low, []byte("<head")); h >= 0 {

View File

@ -17,7 +17,7 @@ import (
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>")
out := injectLoader(body, "abc123", false) out := injectLoader(body, "abc123", false, false)
if string(out) != string(body) { if string(out) != string(body) {
t.Fatalf("guarded body must be unchanged.\n got: %s", out) t.Fatalf("guarded body must be unchanged.\n got: %s", out)
} }
@ -25,7 +25,7 @@ func TestInjectLoaderGuardIdempotent(t *testing.T) {
func TestInjectLoaderHeadInsertion(t *testing.T) { func TestInjectLoaderHeadInsertion(t *testing.T) {
body := []byte(`<html><head lang="en"><title>x</title></head><body>hi</body></html>`) body := []byte(`<html><head lang="en"><title>x</title></head><body>hi</body></html>`)
out := string(injectLoader(body, "deadbeef", true)) out := string(injectLoader(body, "deadbeef", true, false))
// The tag must land right AFTER the first <head ...>'s closing '>'. // The tag must land right AFTER the first <head ...>'s closing '>'.
headOpen := `<head lang="en">` headOpen := `<head lang="en">`
idx := strings.Index(out, headOpen) idx := strings.Index(out, headOpen)
@ -46,7 +46,7 @@ func TestInjectLoaderHeadInsertion(t *testing.T) {
func TestInjectLoaderBodyFallback(t *testing.T) { func TestInjectLoaderBodyFallback(t *testing.T) {
// No <head> → insert right BEFORE the first <body>. // No <head> → insert right BEFORE the first <body>.
body := []byte(`<html><body class="x">hi</body></html>`) body := []byte(`<html><body class="x">hi</body></html>`)
out := string(injectLoader(body, "cafe", false)) out := string(injectLoader(body, "cafe", false, false))
wantTag := `<!-- ` + bannerGuard + ` --><script src="/__toolbox/loader.js" data-mh="cafe" data-wg="0" async></script>` wantTag := `<!-- ` + bannerGuard + ` --><script src="/__toolbox/loader.js" data-mh="cafe" data-wg="0" async></script>`
if !strings.Contains(out, wantTag+`<body class="x">`) { if !strings.Contains(out, wantTag+`<body class="x">`) {
t.Fatalf("tag not inserted right before <body>.\n got: %s", out) t.Fatalf("tag not inserted right before <body>.\n got: %s", out)
@ -55,7 +55,7 @@ func TestInjectLoaderBodyFallback(t *testing.T) {
func TestInjectLoaderNeitherHeadNorBody(t *testing.T) { func TestInjectLoaderNeitherHeadNorBody(t *testing.T) {
body := []byte(`<p>just a fragment</p>`) body := []byte(`<p>just a fragment</p>`)
out := injectLoader(body, "x", true) out := injectLoader(body, "x", true, false)
if string(out) != string(body) { if string(out) != string(body) {
t.Fatalf("no head/body → must be unchanged.\n got: %s", out) t.Fatalf("no head/body → must be unchanged.\n got: %s", out)
} }
@ -70,7 +70,7 @@ func TestInjectLoaderWGAttr(t *testing.T) {
{false, `data-wg="0"`}, {false, `data-wg="0"`},
} }
for _, c := range cases { for _, c := range cases {
out := string(injectLoader([]byte(`<head></head>`), "mh1", c.wg)) out := string(injectLoader([]byte(`<head></head>`), "mh1", c.wg, false))
if !strings.Contains(out, c.want) { if !strings.Contains(out, c.want) {
t.Fatalf("wg=%v: want %q in %s", c.wg, c.want, out) t.Fatalf("wg=%v: want %q in %s", c.wg, c.want, out)
} }
@ -79,7 +79,7 @@ func TestInjectLoaderWGAttr(t *testing.T) {
func TestInjectLoaderNonASCIIHashStripped(t *testing.T) { func TestInjectLoaderNonASCIIHashStripped(t *testing.T) {
// Non-ascii bytes in the client hash are dropped (Python .encode("ascii","ignore")). // Non-ascii bytes in the client hash are dropped (Python .encode("ascii","ignore")).
out := string(injectLoader([]byte(`<head></head>`), "abécÿ12", false)) out := string(injectLoader([]byte(`<head></head>`), "abécÿ12", false, false))
if !strings.Contains(out, `data-mh="abc12"`) { if !strings.Contains(out, `data-mh="abc12"`) {
t.Fatalf("non-ascii bytes not stripped: %s", out) t.Fatalf("non-ascii bytes not stripped: %s", out)
} }
@ -87,7 +87,7 @@ func TestInjectLoaderNonASCIIHashStripped(t *testing.T) {
func TestInjectLoaderHeadCaseInsensitive(t *testing.T) { func TestInjectLoaderHeadCaseInsensitive(t *testing.T) {
body := []byte(`<HTML><HEAD></HEAD><BODY>hi</BODY></HTML>`) body := []byte(`<HTML><HEAD></HEAD><BODY>hi</BODY></HTML>`)
out := string(injectLoader(body, "z", false)) out := string(injectLoader(body, "z", false, false))
if !strings.Contains(out, `<HEAD><!-- `+bannerGuard) { if !strings.Contains(out, `<HEAD><!-- `+bannerGuard) {
t.Fatalf("case-insensitive <HEAD> match failed: %s", out) t.Fatalf("case-insensitive <HEAD> match failed: %s", out)
} }

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) 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", "abc123", 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", "z", 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", "x", 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", "x", 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", "x", 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", "x", 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")
} }
} }

View File

@ -135,7 +135,7 @@ func TestInjectCosmeticCaseInsensitive(t *testing.T) {
func TestInjectLoaderAndCosmeticCompose(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).
body := []byte(`<html><head></head><body>hi</body></html>`) body := []byte(`<html><head></head><body>hi</body></html>`)
out := string(injectHTML(body, "deadbeef", true)) out := string(injectHTML(body, "deadbeef", true, false))
if !strings.Contains(out, bannerGuard) { if !strings.Contains(out, bannerGuard) {
t.Fatalf("loader marker missing after compose: %s", out) t.Fatalf("loader marker missing after compose: %s", out)
} }
@ -150,7 +150,7 @@ func TestInjectLoaderAndCosmeticCompose(t *testing.T) {
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 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, "x", false)) out := string(injectHTML(body, "x", false, false))
if !strings.Contains(out, bannerGuard) { if !strings.Contains(out, bannerGuard) {
t.Fatalf("loader marker missing for non-wg: %s", out) t.Fatalf("loader marker missing for non-wg: %s", out)
} }
@ -163,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", "mh1", 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")
} }

View File

@ -0,0 +1,213 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: toolbox-ng :: CONSENTED-DEMONSTRATION CSP relax (#662)
//
// The R3 toolbox appliance is literally "VILLAGE3B — Qui te piste?": a consented
// man-in-the-middle on the operator's OWN R3 traffic whose whole point is to
// SHOW the user what a MITM can do. A strict Content-Security-Policy would stop
// the transparency-banner loader (<script src="/__toolbox/loader.js">) from
// executing — so on the R3/wg inject path (and ONLY there, gated by the
// --csp-bypass-demo flag) we deliberately relax the page's CSP just enough to
// let that one same-origin loader script run, then the injected tag carries
// data-csp="1" and the portal banner renders a 🔓 — the VISIBLE proof that the
// page's CSP was bypassed to inject. This is intentional, demonstrative, and
// toggleable; it is never applied to non-injected responses.
//
// relaxCSPForLoader rewrites BOTH the enforced and the report-only CSP headers.
// For the script-governing directive it ensures 'self' + 'unsafe-inline' are
// present and strips 'strict-dynamic' (which would make host/'self'/'unsafe-
// inline' ignored) and 'none'. ONLY the script directive is touched — img-src,
// style-src, connect-src, etc. are left exactly as the origin set them: we relax
// the minimum the loader needs, nothing more. It returns true iff a real CSP was
// present and modified — that is the proof condition for the 🔓.
//
// Pure standard library.
package main
import (
"net/http"
"strings"
)
// cspHeaderNames are the two response headers that carry a Content-Security-
// Policy. Both are rewritten: a report-only policy doesn't block scripts, but
// relaxing it too keeps the demonstration consistent (no console violations).
var cspHeaderNames = []string{
"Content-Security-Policy",
"Content-Security-Policy-Report-Only",
}
// loaderAllowSources are the source-expressions the same-origin loader script
// needs. 'self' lets /__toolbox/loader.js (same origin as the page) load;
// 'unsafe-inline' is added defensively so an inline shim is never blocked.
var loaderAllowSources = []string{"'self'", "'unsafe-inline'"}
// cspDropSources are source-expressions removed from the script directive:
// - 'strict-dynamic' propagates trust only to scripts loaded by an already-
// trusted (nonce/hash) script and makes host-source / 'self' / 'unsafe-
// inline' IGNORED — leaving it in would defeat the relax.
// - 'none' forbids every source; it must go for the loader to run.
var cspDropSources = map[string]bool{"'strict-dynamic'": true, "'none'": true}
// relaxCSPForLoader rewrites every CSP / CSP-Report-Only header value so a
// same-origin /__toolbox/loader.js <script> is allowed to execute, and reports
// whether any CSP header was present AND modified (the 🔓 proof condition).
//
// Robust by construction: it never panics on malformed input (empty values,
// stray semicolons, value-less directives all parse to harmless no-ops). If no
// CSP header is present at all, it changes nothing and returns false.
func relaxCSPForLoader(h http.Header) bool {
modified := false
for _, name := range cspHeaderNames {
vals := h.Values(name)
if len(vals) == 0 {
continue
}
out := make([]string, 0, len(vals))
anyBypass := false
for _, v := range vals {
relaxed, bypassed := relaxCSPValue(v)
if bypassed {
out = append(out, relaxed)
anyBypass = true
} else {
out = append(out, v) // not blocking → keep the original verbatim (minimal touch)
}
}
if anyBypass {
h.Del(name)
for _, v := range out {
h.Add(name, v)
}
modified = true
}
}
return modified
}
// relaxCSPValue relaxes a single CSP header value (one header line; a policy is
// a ';'-separated list of directives). It relaxes ONLY the effective script
// directive and ONLY when that directive would block the same-origin loader; it
// returns the rewritten value and whether such a blocking CSP was actually
// bypassed (the 🔓 proof condition). A value with no blocking script directive
// is returned effectively unchanged with bypassed=false.
func relaxCSPValue(value string) (out string, bypassed bool) {
rawDirectives := strings.Split(value, ";")
// Locate the script-governing directives. script-src and script-src-elem
// govern <script> directly; if NEITHER is present, default-src is the
// fallback that governs scripts, so that is the one we relax.
var idxScriptSrc, idxScriptSrcElem, idxDefaultSrc = -1, -1, -1
type dir struct {
name string // lower-cased directive name ("" for a blank fragment)
tokens []string // raw value tokens after the name
}
dirs := make([]dir, 0, len(rawDirectives))
for _, raw := range rawDirectives {
fields := strings.Fields(raw)
if len(fields) == 0 {
continue // blank fragment (leading/trailing/double ';') → drop
}
name := strings.ToLower(fields[0])
d := dir{name: name, tokens: fields[1:]}
switch name {
case "script-src":
idxScriptSrc = len(dirs)
case "script-src-elem":
idxScriptSrcElem = len(dirs)
case "default-src":
idxDefaultSrc = len(dirs)
}
dirs = append(dirs, d)
}
// Find the EFFECTIVE directive governing a <script src> element: per CSP,
// script-src-elem wins, else script-src, else default-src. Only that one
// decides whether the same-origin loader is blocked — and only it is relaxed.
effective := -1
switch {
case idxScriptSrcElem >= 0:
effective = idxScriptSrcElem
case idxScriptSrc >= 0:
effective = idxScriptSrc
case idxDefaultSrc >= 0:
effective = idxDefaultSrc
}
// Relax + flag the bypass ONLY when the effective directive would actually
// BLOCK the same-origin loader. If the page already allows it (e.g. 'self' /
// '*' / https: and no 'strict-dynamic'), or imposes no script restriction at
// all, we touch nothing and report no bypass — so the 🔓 is honest proof that
// a blocking CSP was defeated, not just that a CSP existed.
if effective >= 0 && scriptDirectiveBlocksLoader(dirs[effective].tokens) {
dirs[effective].tokens = relaxScriptTokens(dirs[effective].tokens)
bypassed = true
}
// Re-serialise: "name token token; name token; ...".
parts := make([]string, 0, len(dirs))
for _, d := range dirs {
if len(d.tokens) > 0 {
parts = append(parts, d.name+" "+strings.Join(d.tokens, " "))
} else {
parts = append(parts, d.name)
}
}
return strings.Join(parts, "; "), bypassed
}
// scriptDirectiveBlocksLoader reports whether a script-governing directive (its
// value tokens) would BLOCK a same-origin external <script src="/__toolbox/
// loader.js">. It blocks when:
// - 'none' (forbids everything), or an empty directive (also forbids all), or
// - 'strict-dynamic' is present (host-source / 'self' / 'unsafe-inline' become
// IGNORED, so a plain src script with no matching nonce/hash is refused), or
// - none of 'self' / '*' / https: / http: is present (only specific foreign
// hosts are allowed, which don't cover the page's own origin).
// It does NOT block when 'self' / '*' / a scheme-source allows same-origin AND
// 'strict-dynamic' is absent — then the loader already runs and we leave the CSP
// untouched (no false 🔓).
func scriptDirectiveBlocksLoader(tokens []string) bool {
if len(tokens) == 0 {
return true // empty script directive forbids all scripts
}
allowsSameOrigin := false
for _, tk := range tokens {
switch strings.ToLower(tk) {
case "'none'", "'strict-dynamic'":
return true
case "'self'", "*", "https:", "http:":
allowsSameOrigin = true
}
}
return !allowsSameOrigin
}
// relaxScriptTokens rewrites the value tokens of a script-governing directive:
// drop 'strict-dynamic' / 'none', then ensure 'self' + 'unsafe-inline' are
// present (appended once). Existing host sources / nonces / hashes are kept.
func relaxScriptTokens(tokens []string) []string {
kept := make([]string, 0, len(tokens)+len(loaderAllowSources))
have := map[string]bool{}
for _, tk := range tokens {
// Source-expressions are matched case-insensitively for the keywords we
// touch ('strict-dynamic' / 'none' / 'self' / 'unsafe-inline'); hosts,
// nonces and hashes are preserved verbatim.
low := strings.ToLower(tk)
if cspDropSources[low] {
continue
}
if !have[low] {
kept = append(kept, tk)
have[low] = true
}
}
for _, src := range loaderAllowSources {
if !have[src] {
kept = append(kept, src)
have[src] = true
}
}
return kept
}

View File

@ -0,0 +1,200 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
//
// SecuBox-Deb :: toolbox-ng :: CONSENTED-DEMONSTRATION CSP relax tests (#662)
//
// The R3 toolbox is literally "VILLAGE3B — Qui te piste?": a consented MITM on
// the operator's own traffic that SHOWS the user what a man-in-the-middle can
// do. relaxCSPForLoader rewrites a page's Content-Security-Policy so the
// injected /__toolbox/loader.js can execute even on strict-CSP sites; when it
// actually had a CSP to bypass it returns true, and injectLoader then stamps a
// data-csp="1" the portal banner renders as a 🔓 proof emoji. These tests pin
// the rewrite semantics + the data-csp gating.
package main
import (
"net/http"
"strings"
"testing"
)
// helper: build a one-header http.Header for the CSP under test.
func cspHeader(name, value string) http.Header {
h := http.Header{}
h.Set(name, value)
return h
}
func TestRelaxCSPScriptSrcAddsSelf(t *testing.T) {
// (a) A strict script-src that only allows a remote host: the loader is
// same-origin, so 'self' must be added → loader allowed, returns true.
h := cspHeader("Content-Security-Policy", "script-src github.githubassets.com; img-src *")
if !relaxCSPForLoader(h) {
t.Fatal("a real CSP that blocks the loader must be reported modified (true)")
}
got := h.Get("Content-Security-Policy")
if !strings.Contains(got, "'self'") || !strings.Contains(got, "'unsafe-inline'") {
t.Fatalf("script-src must now allow 'self'+'unsafe-inline': %q", got)
}
// Unrelated directives are left untouched.
if !strings.Contains(got, "img-src *") {
t.Fatalf("unrelated directive img-src must be preserved: %q", got)
}
// The original host is left in place — we only ADD what the loader needs.
if !strings.Contains(got, "github.githubassets.com") {
t.Fatalf("existing source must be preserved: %q", got)
}
}
func TestRelaxCSPStripsStrictDynamicAndNone(t *testing.T) {
// (b) 'strict-dynamic' makes host/'self'/'unsafe-inline' IGNORED, so it must
// be removed; 'none' must be removed too. 'self'+'unsafe-inline' added.
h := cspHeader("Content-Security-Policy", "script-src 'strict-dynamic' 'nonce-x'")
if !relaxCSPForLoader(h) {
t.Fatal("strict-dynamic CSP must be reported modified (true)")
}
got := h.Get("Content-Security-Policy")
if strings.Contains(got, "strict-dynamic") {
t.Fatalf("'strict-dynamic' must be removed: %q", got)
}
if !strings.Contains(got, "'self'") || !strings.Contains(got, "'unsafe-inline'") {
t.Fatalf("'self'+'unsafe-inline' must be present: %q", got)
}
}
func TestRelaxCSPRemovesNone(t *testing.T) {
h := cspHeader("Content-Security-Policy", "default-src 'none'; script-src 'none'")
if !relaxCSPForLoader(h) {
t.Fatal("'none' CSP must be reported modified (true)")
}
got := h.Get("Content-Security-Policy")
// script-src must no longer say 'none' and must allow the loader.
if strings.Contains(got, "script-src 'none'") || strings.Contains(got, "script-src 'self'") {
// (loose) just ensure 'self' present in script-src region
}
if !strings.Contains(got, "'self'") || !strings.Contains(got, "'unsafe-inline'") {
t.Fatalf("script-src must allow loader after 'none' removed: %q", got)
}
}
func TestRelaxCSPNoHeaderUnchanged(t *testing.T) {
// (c) No CSP header at all → false, headers unchanged (nothing to bypass).
h := http.Header{}
h.Set("Content-Type", "text/html")
if relaxCSPForLoader(h) {
t.Fatal("no CSP header → must return false")
}
if len(h) != 1 || h.Get("Content-Type") != "text/html" {
t.Fatalf("headers must be untouched when there is no CSP: %v", h)
}
}
func TestRelaxCSPReportOnlyRewritten(t *testing.T) {
// (d) The Report-Only header is rewritten too.
h := cspHeader("Content-Security-Policy-Report-Only", "script-src 'strict-dynamic'")
if !relaxCSPForLoader(h) {
t.Fatal("report-only CSP must be reported modified (true)")
}
got := h.Get("Content-Security-Policy-Report-Only")
if strings.Contains(got, "strict-dynamic") || !strings.Contains(got, "'self'") {
t.Fatalf("report-only header must be relaxed: %q", got)
}
}
func TestRelaxCSPBothHeaders(t *testing.T) {
// Both enforced + report-only present → both rewritten, returns true.
h := http.Header{}
h.Set("Content-Security-Policy", "script-src 'none'")
h.Set("Content-Security-Policy-Report-Only", "script-src 'strict-dynamic'")
if !relaxCSPForLoader(h) {
t.Fatal("both CSP headers present → true")
}
if !strings.Contains(h.Get("Content-Security-Policy"), "'self'") {
t.Fatalf("enforced header not relaxed: %q", h.Get("Content-Security-Policy"))
}
if !strings.Contains(h.Get("Content-Security-Policy-Report-Only"), "'self'") {
t.Fatalf("report-only header not relaxed: %q", h.Get("Content-Security-Policy-Report-Only"))
}
}
func TestRelaxCSPMalformedNoPanic(t *testing.T) {
// (e) Malformed CSP values must never panic; whatever comes out, no crash,
// and a present (non-empty) header is still treated as "had a CSP" → true.
cases := []string{
";;;",
" ",
"script-src", // directive with no values
"script-src;", // trailing empty
";script-src 'self'", // leading empty
"default-src 'self' 'self' 'self'",
"script-src 'strict-dynamic' 'strict-dynamic'",
}
for _, v := range cases {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("relaxCSPForLoader panicked on %q: %v", v, r)
}
}()
h := cspHeader("Content-Security-Policy", v)
_ = relaxCSPForLoader(h) // must not panic
}()
}
}
func TestRelaxCSPDefaultSrcFallback(t *testing.T) {
// No script-src/script-src-elem → default-src governs scripts. A BLOCKING
// default-src (only a foreign host, no 'self') must be relaxed.
h := cspHeader("Content-Security-Policy", "default-src cdn.example.com")
if !relaxCSPForLoader(h) {
t.Fatal("blocking default-src-only CSP must be reported modified (true)")
}
got := h.Get("Content-Security-Policy")
if !strings.Contains(got, "'self'") || !strings.Contains(got, "'unsafe-inline'") {
t.Fatalf("default-src must gain 'self'+'unsafe-inline' for the loader: %q", got)
}
}
func TestRelaxCSPAlreadyAllowedNotFlagged(t *testing.T) {
// The honesty test: a CSP that ALREADY allows the same-origin loader (has
// 'self', no 'strict-dynamic') must NOT be flagged as bypassed and must be
// left byte-for-byte unchanged — no false 🔓.
for _, v := range []string{
"script-src 'self' 'unsafe-inline' https://js.stripe.com 'sha256-abc'",
"script-src * 'unsafe-eval'",
"default-src 'self'",
"img-src *; style-src 'self'", // no script governing directive at all
} {
h := cspHeader("Content-Security-Policy", v)
if relaxCSPForLoader(h) {
t.Fatalf("already-permissive CSP must NOT be flagged bypassed: %q", v)
}
if h.Get("Content-Security-Policy") != v {
t.Fatalf("non-blocking CSP must be left verbatim: in=%q out=%q", v, h.Get("Content-Security-Policy"))
}
}
}
func TestRelaxCSPEmptyHeaderValue(t *testing.T) {
// An explicitly-present but empty CSP value → nothing meaningful to bypass.
h := cspHeader("Content-Security-Policy", "")
if relaxCSPForLoader(h) {
t.Fatal("empty CSP value → false (nothing to bypass)")
}
}
// ── injectLoader data-csp gating ─────────────────────────────────────────────
func TestInjectLoaderDataCSPWhenBypassed(t *testing.T) {
out := string(injectLoader([]byte(`<head></head>`), "mh1", true, true))
if !strings.Contains(out, `data-csp="1"`) {
t.Fatalf("cspBypassed=true must stamp data-csp=\"1\": %s", out)
}
}
func TestInjectLoaderNoDataCSPWhenNotBypassed(t *testing.T) {
out := string(injectLoader([]byte(`<head></head>`), "mh1", true, false))
if strings.Contains(out, "data-csp") {
t.Fatalf("cspBypassed=false must NOT emit data-csp: %s", out)
}
}

View File

@ -152,8 +152,8 @@ func zstdBytes(in []byte) ([]byte, error) {
// same decompressed step means the cosmetic style benefits from the gzip // same decompressed step means the cosmetic style benefits from the gzip
// handling exactly like the loader. The cosmetic style is gated to wg because it // handling exactly like the loader. The cosmetic style is gated to wg because it
// is an R3-tunnel opt-in behaviour (mirrors the Python addon's _is_r3plus gate). // is an R3-tunnel opt-in behaviour (mirrors the Python addon's _is_r3plus gate).
func injectHTML(plain []byte, clientHash string, wg bool) []byte { func injectHTML(plain []byte, clientHash string, wg, cspBypassed bool) []byte {
out := injectLoader(plain, clientHash, wg) out := injectLoader(plain, clientHash, wg, cspBypassed)
if wg { if wg {
out = injectCosmetic(out) out = injectCosmetic(out)
} }
@ -162,7 +162,8 @@ func injectHTML(plain []byte, clientHash string, wg bool) []byte {
// injectIntoBody runs the HTML injection (loader + R3 cosmetic style) over a // injectIntoBody runs the HTML injection (loader + R3 cosmetic style) over a
// (possibly gzip-compressed) HTML body, returning the new body bytes to serve // (possibly gzip-compressed) HTML body, returning the new body bytes to serve
// and whether the body was rewritten. // and whether the body was rewritten. cspBypassed (#662) is threaded into the
// 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).
@ -181,22 +182,22 @@ func injectHTML(plain []byte, clientHash string, wg bool) []byte {
// //
// 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 injectLoader/injectCosmetic.
func injectIntoBody(body []byte, encoding, clientHash 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, clientHash, 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, clientHash, 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, clientHash, 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
} }
@ -206,7 +207,7 @@ func injectIntoBody(body []byte, encoding, clientHash 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, clientHash, 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
} }

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) 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", "z", 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", "x", 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, "", "deadbeef", 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", "x", 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", "x", 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")
} }

View File

@ -197,13 +197,14 @@ func ja4ish(h *tls.ClientHelloInfo) string {
// ── CONNECT-proxy MITM wiring ──────────────────────────────────────────────── // ── CONNECT-proxy MITM wiring ────────────────────────────────────────────────
type Proxy struct { type Proxy struct {
ca *CA ca *CA
pol *Policy pol *Policy
jaSink func(string) // JA4 observations (logged; a sidecar in prod) jaSink func(string) // JA4 observations (logged; a sidecar in prod)
jarKey []byte // anti-track HMAC fake-identity seed (nil → poison off) jarKey []byte // anti-track HMAC fake-identity seed (nil → poison off)
poison bool // master gate: poison tracker Set-Cookies (default on when jarKey present) poison bool // master gate: poison tracker Set-Cookies (default on when jarKey present)
portal string // portal base URL for /__toolbox/* reverse-proxy (banner assets) portal string // portal base URL for /__toolbox/* reverse-proxy (banner assets)
ads *adStats // #662 — ad-block metrics aggregator (flushed to the portal) ads *adStats // #662 — ad-block metrics aggregator (flushed to the portal)
cspDemo bool // #662 CONSENTED-DEMONSTRATION: relax a page's CSP so the injected loader runs, and flag the bypass (data-csp=1 → 🔓). Default on.
} }
// recordAdBlock forwards a 204'd ad/tracker block to the engine's metrics // recordAdBlock forwards a 204'd ad/tracker block to the engine's metrics
@ -406,7 +407,18 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
// keep them consistent with the bytes we actually serve. // keep them consistent with the bytes we actually serve.
if resp.StatusCode >= 200 && resp.StatusCode < 300 && if resp.StatusCode >= 200 && resp.StatusCode < 300 &&
strings.Contains(resp.Header.Get("Content-Type"), "text/html") { strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
if out, ok := injectIntoBody(body, resp.Header.Get("Content-Encoding"), clientHash, wg); ok { // #662 CONSENTED-DEMONSTRATION — ONLY here, on the responses we actually
// 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
// /__toolbox/loader.js can execute even on strict-CSP sites. cspBypassed
// is true iff there was a real CSP to bypass — it becomes data-csp="1" on
// the loader tag and the portal banner renders a 🔓 as the visible proof.
// We never strip CSP on non-injected responses.
cspBypassed := false
if px.cspDemo {
cspBypassed = relaxCSPForLoader(resp.Header)
}
if out, ok := injectIntoBody(body, resp.Header.Get("Content-Encoding"), clientHash, wg, cspBypassed); 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);
@ -431,6 +443,8 @@ func main() {
"transparent mode: accept nft-DNAT'd conns + recover SO_ORIGINAL_DST (live R3); default is the CONNECT proxy PoC") "transparent mode: accept nft-DNAT'd conns + recover SO_ORIGINAL_DST (live R3); default is the CONNECT proxy PoC")
portal := flag.String("portal", "http://127.0.0.1:8088", portal := flag.String("portal", "http://127.0.0.1:8088",
"portal base URL; /__toolbox/loader.js + /__toolbox/bundle are reverse-proxied here (banner assets, served for any MITM'd origin)") "portal base URL; /__toolbox/loader.js + /__toolbox/bundle are reverse-proxied here (banner assets, served for any MITM'd origin)")
cspDemo := flag.Bool("csp-bypass-demo", true,
"CONSENTED DEMONSTRATION: relax a page's CSP so the injected transparency-banner loader runs even on strict-CSP sites, and flag the bypass (banner shows 🔓). Only on injected 2xx text/html R3 responses; never on non-injected responses. Set false to never touch CSP.")
flag.Parse() flag.Parse()
ca, err := loadCA(*caCert, *caKey) ca, err := loadCA(*caCert, *caKey)
if err != nil { if err != nil {
@ -451,13 +465,14 @@ func main() {
log.Printf("poison requested but jar key %s absent/empty → poison OFF", *jarKeyPath) log.Printf("poison requested but jar key %s absent/empty → poison OFF", *jarKeyPath)
} }
px := &Proxy{ px := &Proxy{
ca: ca, ca: ca,
pol: pol, pol: pol,
jaSink: func(s string) { log.Printf("ja4 %s", s) }, jaSink: func(s string) { log.Printf("ja4 %s", s) },
jarKey: jarKey, jarKey: jarKey,
poison: *poison, poison: *poison,
portal: *portal, portal: *portal,
ads: newAdStats(), ads: newAdStats(),
cspDemo: *cspDemo,
} }
// #662 — start the ad-block metrics flusher: the block path tallies every // #662 — start the ad-block metrics flusher: the block path tallies every
// 204 into px.ads, drained every 10s to the portal's /__toolbox/ad-event // 204 into px.ads, drained every 10s to the portal's /__toolbox/ad-event

View File

@ -1,3 +1,24 @@
secubox-toolbox-ng (0.1.8-1~bookworm1) bookworm; urgency=medium
* demo/csp: only relax + flag 🔓 when the page's effective script directive
would ACTUALLY block the same-origin loader (no 'self'/'*'/scheme, or
'strict-dynamic'/'none'/empty). CSPs that already allow the loader are left
byte-for-byte untouched — the 🔓 is now honest proof of a defeated CSP, not
just "a CSP existed". (ref #662)
-- Gerald KERMA <devel@cybermind.fr> Thu, 19 Jun 2026 10:05:00 +0000
secubox-toolbox-ng (0.1.7-1~bookworm1) bookworm; urgency=medium
* demo: consented CSP-bypass for the transparency banner — on the R3 inject
path (gated --csp-bypass-demo, default on) relax the page script-src/-elem
(drop strict-dynamic/none, add 'self'/'unsafe-inline') so the loader runs on
strict-CSP sites (github etc.), and mark the bypass with data-csp=1 → the
portal banner shows a 🔓 proof. Only the script directive is touched, only on
injected 2xx-html R3 responses. (ref #662)
-- Gerald KERMA <devel@cybermind.fr> Thu, 19 Jun 2026 09:45:00 +0000
secubox-toolbox-ng (0.1.6-1~bookworm1) bookworm; urgency=medium secubox-toolbox-ng (0.1.6-1~bookworm1) bookworm; urgency=medium
* anti-bot: present a Chrome TLS fingerprint upstream via uTLS (HelloChrome_Auto) * anti-bot: present a Chrome TLS fingerprint upstream via uTLS (HelloChrome_Auto)

View File

@ -113,6 +113,16 @@ LOADER_JS = r"""(function(){
var s = document.currentScript || {}; var s = document.currentScript || {};
var ds = s.dataset || {}; var ds = s.dataset || {};
var mh = ds.mh || "", wg = ds.wg || "0"; 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;
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]; }); }
@ -127,6 +137,7 @@ LOADER_JS = r"""(function(){
} catch (_) { return 0; } } catch (_) { return 0; }
} }
function render(b){ function render(b){
if (dismissed) return;
if (document.getElementById("sbx-banner")) return; if (document.getElementById("sbx-banner")) return;
var trk = countTrackers(b.tracker_patterns); var trk = countTrackers(b.tracker_patterns);
var ck = 0; var ck = 0;
@ -138,7 +149,11 @@ LOADER_JS = r"""(function(){
+ "border-bottom:2px solid #148C66;padding:6px 12px;display:flex;gap:14px;align-items:center;" + "border-bottom:2px solid #148C66;padding:6px 12px;display:flex;gap:14px;align-items:center;"
+ "box-shadow:0 2px 12px rgba(0,0,0,.4)"); + "box-shadow:0 2px 12px rgba(0,0,0,.4)");
var pin = b.pin ? "<span title=\"pinned\">📌 " + esc(b.pin) + "</span>" : ""; var pin = b.pin ? "<span title=\"pinned\">📌 " + esc(b.pin) + "</span>" : "";
// #662 — 🔓 proof: the engine relaxed this page's CSP to inject this banner.
var cspProof = (csp === "1")
? "<span title=\"CSP contourné par SecuBox (démonstration)\">🔓</span>" : "";
bar.innerHTML = "<b style=\"color:#148C66\">SecuBox</b>" bar.innerHTML = "<b style=\"color:#148C66\">SecuBox</b>"
+ cspProof
+ "<span>" + esc((b.level || "r1").toUpperCase()) + "</span>" + "<span>" + esc((b.level || "r1").toUpperCase()) + "</span>"
+ "<span>🛰️ " + trk + " trackers</span>" + "<span>🛰️ " + trk + " trackers</span>"
+ "<span>🍪 " + ck + " cookies</span>" + "<span>🍪 " + ck + " cookies</span>"
@ -148,11 +163,24 @@ LOADER_JS = r"""(function(){
document.body.appendChild(bar); document.body.appendChild(bar);
try { document.body.style.paddingTop = (bar.offsetHeight || 34) + "px"; } catch (_) {} try { document.body.style.paddingTop = (bar.offsetHeight || 34) + "px"; } catch (_) {}
var btn = bar.querySelector("button"); var btn = bar.querySelector("button");
if (btn) btn.onclick = function(){ try { document.body.style.paddingTop = ""; } catch (_) {} bar.remove(); }; if (btn) btn.onclick = function(){ dismissed = true; try { document.body.style.paddingTop = ""; } catch (_) {} bar.remove(); };
} }
// ensure(): (re)render the banner if it's absent and the bundle is loaded and
// the user hasn't dismissed it. Cheap (a getElementById guard inside render).
function ensure(){ if (bundle && !dismissed) ready(function(){ render(bundle); }); }
fetch("/__toolbox/bundle?mh=" + encodeURIComponent(mh) + "&wg=" + encodeURIComponent(wg), {credentials:"omit"}) fetch("/__toolbox/bundle?mh=" + encodeURIComponent(mh) + "&wg=" + encodeURIComponent(wg), {credentials:"omit"})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(b){ ready(function(){ render(b); }); }) .then(function(b){ bundle = b; ensure(); })
.catch(function(){}); .catch(function(){});
// 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.
["pushState","replaceState"].forEach(function(m){
var o = history[m];
if (typeof o === "function") {
try { history[m] = function(){ var r = o.apply(this, arguments); setTimeout(ensure, 150); return r; }; } catch (_) {}
}
});
window.addEventListener("popstate", function(){ setTimeout(ensure, 150); });
setInterval(ensure, 2000);
})(); })();
""" """