mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 12:31:31 +00:00
Compare commits
5 Commits
40aa2a6a63
...
ded89934d0
| Author | SHA1 | Date | |
|---|---|---|---|
| ded89934d0 | |||
|
|
9a843cec72 | ||
| 8988a1078a | |||
| bfc28f1081 | |||
| 05eedca6e8 |
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
213
packages/secubox-toolbox-ng/cmd/sbxmitm/csp.go
Normal file
213
packages/secubox-toolbox-ng/cmd/sbxmitm/csp.go
Normal 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
|
||||||
|
}
|
||||||
200
packages/secubox-toolbox-ng/cmd/sbxmitm/csp_test.go
Normal file
200
packages/secubox-toolbox-ng/cmd/sbxmitm/csp_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,7 @@ type Proxy struct {
|
||||||
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 {
|
||||||
|
|
@ -458,6 +472,7 @@ func main() {
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {"&":"&","<":"<",">":">","\"":"""}[c]; }); }
|
return {"&":"&","<":"<",">":">","\"":"""}[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);
|
||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user