mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 18:06:21 +00:00
Compare commits
3 Commits
7355e606ca
...
c870b6362b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c870b6362b | ||
| de15a18c30 | |||
| f65af3355c |
9
packages/secubox-toolbox-ng/.gitignore
vendored
9
packages/secubox-toolbox-ng/.gitignore
vendored
|
|
@ -1,3 +1,12 @@
|
||||||
/sbxmitm
|
/sbxmitm
|
||||||
*.test
|
*.test
|
||||||
cmd/sbxmitm/sbxmitm
|
cmd/sbxmitm/sbxmitm
|
||||||
|
# Debian build artifacts (rules builds the binary + go caches in-tree)
|
||||||
|
/_gocache/
|
||||||
|
/_gopath/
|
||||||
|
/debian/.debhelper/
|
||||||
|
/debian/files
|
||||||
|
/debian/*.substvars
|
||||||
|
/debian/secubox-toolbox-ng/
|
||||||
|
/debian/debhelper-build-stamp
|
||||||
|
/debian/*.debhelper.log
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,8 @@ 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)
|
||||||
|
poison bool // master gate: poison tracker Set-Cookies (default on when jarKey present)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (px *Proxy) serverTLSConfig() *tls.Config {
|
func (px *Proxy) serverTLSConfig() *tls.Config {
|
||||||
|
|
@ -210,7 +212,11 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n")
|
io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||||
|
|
||||||
if px.pol.action(host) == "splice" {
|
// Decide once on (host, sni). For the CONNECT PoC the SNI is the CONNECT
|
||||||
|
// host; the transparent engine will splice on the real ClientHello SNI.
|
||||||
|
verdict := px.pol.Decide(host, host)
|
||||||
|
|
||||||
|
if verdict == "splice" {
|
||||||
// passthrough: raw TCP to upstream, no TLS interception (tls_splice).
|
// passthrough: raw TCP to upstream, no TLS interception (tls_splice).
|
||||||
up, err := net.DialTimeout("tcp", r.URL.Host, 10*time.Second)
|
up, err := net.DialTimeout("tcp", r.URL.Host, 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -235,11 +241,22 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
req.URL.Scheme, req.URL.Host = "https", r.URL.Host
|
req.URL.Scheme, req.URL.Host = "https", r.URL.Host
|
||||||
|
|
||||||
if px.pol.action(host) == "block" {
|
if verdict == "block" {
|
||||||
writeRaw(tconn, 204, "No Content", map[string]string{"X-SecuBox-Ng": "blocked"}, nil)
|
writeRaw(tconn, 204, "No Content", map[string]string{"X-SecuBox-Ng": "blocked"}, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── verdict ∈ {"allow","mitm"} → intercept normally ──────────────────────
|
||||||
|
//
|
||||||
|
// allow → own-infra / allowlist: clean MITM, apply NO block/poison.
|
||||||
|
// mitm → intercept + apply the response handlers (poison if a tracker).
|
||||||
|
//
|
||||||
|
// Always-on hygiene: anonymize the request on EVERY MITM'd flow (incl.
|
||||||
|
// allow — stripping operator headers + asserting opt-out is universally
|
||||||
|
// safe and never touches own-infra correctness).
|
||||||
|
clientHash := clientHashFromConn(client) // PoC: peer IP — TODO(#662 P6): mac_hash
|
||||||
|
anonymizeRequest(req.Header)
|
||||||
|
|
||||||
// proxy upstream, inject into HTML bodies.
|
// proxy upstream, inject into HTML bodies.
|
||||||
up := &http.Client{Timeout: 30 * time.Second}
|
up := &http.Client{Timeout: 30 * time.Second}
|
||||||
req.RequestURI = ""
|
req.RequestURI = ""
|
||||||
|
|
@ -249,18 +266,35 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Poison: only on MITM'd tracker flows (never on allow/own-infra), and only
|
||||||
|
// when the jar key is loaded. Replaces tracking-id Set-Cookie values with a
|
||||||
|
// stable fabricated persona; benign cookies pass through untouched.
|
||||||
|
if verdict == "mitm" && px.poison && len(px.jarKey) > 0 && px.pol.shouldPoison(host) {
|
||||||
|
if sc := resp.Header.Values("Set-Cookie"); len(sc) > 0 {
|
||||||
|
poisoned := poisonSetCookies(sc, clientHash, host, px.jarKey)
|
||||||
|
resp.Header.Del("Set-Cookie")
|
||||||
|
for _, c := range poisoned {
|
||||||
|
resp.Header.Add("Set-Cookie", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
|
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
|
||||||
body = px.pol.injectMarker(body)
|
body = px.pol.injectMarker(body)
|
||||||
}
|
}
|
||||||
hdr := map[string]string{"Content-Type": resp.Header.Get("Content-Type")}
|
writeResponse(tconn, resp, body)
|
||||||
writeRaw(tconn, resp.StatusCode, resp.Status, hdr, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
caCert := flag.String("ca-cert", "/etc/secubox/toolbox/ca-wg/ca.pem", "CA cert PEM")
|
caCert := flag.String("ca-cert", "/etc/secubox/toolbox/ca-wg/ca.pem", "CA cert PEM")
|
||||||
caKey := flag.String("ca-key", "/etc/secubox/toolbox/ca-wg/key.pem", "CA key PEM")
|
caKey := flag.String("ca-key", "/etc/secubox/toolbox/ca-wg/key.pem", "CA key PEM")
|
||||||
addr := flag.String("listen", ":8090", "CONNECT proxy listen addr")
|
addr := flag.String("listen", ":8090", "CONNECT proxy listen addr")
|
||||||
|
jarKeyPath := flag.String("jar-key", "/etc/secubox/secrets/privacy-jar.key",
|
||||||
|
"anti-track HMAC fake-identity seed (poison disabled if absent)")
|
||||||
|
poison := flag.Bool("poison", true,
|
||||||
|
"poison tracking Set-Cookies on MITM'd tracker flows (needs --jar-key; never touches allow/own-infra)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
ca, err := loadCA(*caCert, *caKey)
|
ca, err := loadCA(*caCert, *caKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -274,10 +308,18 @@ func main() {
|
||||||
log.Fatalf("policy load: %v", err)
|
log.Fatalf("policy load: %v", err)
|
||||||
}
|
}
|
||||||
pol.Inject = []byte("<!-- sbx-ng banner -->")
|
pol.Inject = []byte("<!-- sbx-ng banner -->")
|
||||||
|
// Anti-track jar seed: best-effort (like the Python _jar_key). Absent/empty
|
||||||
|
// → loadJarKey returns nil → poison stays off even if --poison is set.
|
||||||
|
jarKey := loadJarKey(*jarKeyPath)
|
||||||
|
if *poison && len(jarKey) == 0 {
|
||||||
|
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,
|
||||||
|
poison: *poison,
|
||||||
}
|
}
|
||||||
srv := &http.Server{Addr: *addr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := &http.Server{Addr: *addr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodConnect {
|
if r.Method == http.MethodConnect {
|
||||||
|
|
|
||||||
47
packages/secubox-toolbox-ng/cmd/sbxmitm/poison_gate_test.go
Normal file
47
packages/secubox-toolbox-ng/cmd/sbxmitm/poison_gate_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
//
|
||||||
|
// Gate tests for the poison emission (#662 Phase 5-prep, Part A): poison only
|
||||||
|
// fires on MITM'd TRACKER flows, never on allow/own-infra flows. This is the
|
||||||
|
// same safety envelope as anti-track — own-infra/allowlist flows stay clean.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShouldPoisonGate: a tracker host MITM'd → poison; an own-infra/allowlisted
|
||||||
|
// host → never poison (even though both are intercepted = "mitm" verb).
|
||||||
|
func TestShouldPoisonGate(t *testing.T) {
|
||||||
|
pf, dir := loadParityFile(t)
|
||||||
|
cfgPath := func(rel string) string { return filepath.Join(dir, filepath.FromSlash(rel)) }
|
||||||
|
pol, err := LoadPolicy(PolicyOpts{
|
||||||
|
AllowPath: cfgPath(pf.Config.AdAllowlist),
|
||||||
|
LearnedPath: cfgPath(pf.Config.LearnedTrackers),
|
||||||
|
SpliceSeedPath: cfgPath(pf.Config.SpliceSeed),
|
||||||
|
SpliceLearnPath: cfgPath(pf.Config.SpliceLearned),
|
||||||
|
PureTrackersPath: cfgPath(pf.Config.PureTrackers),
|
||||||
|
FortknoxSites: pf.Config.FortknoxSites,
|
||||||
|
SelfDomains: pf.Config.SelfDomains,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
// tracker hosts → poison eligible (a tracker we'd otherwise block, but
|
||||||
|
// once MITM'd we poison rather than blunt-block).
|
||||||
|
"ads.doubleclick.net": true,
|
||||||
|
"adnxs.com": true,
|
||||||
|
// own-infra + allowlisted + benign → NEVER poison.
|
||||||
|
"hub.secubox.in": false,
|
||||||
|
"analytics.example-allowed.com": false,
|
||||||
|
"news.example.com": false,
|
||||||
|
}
|
||||||
|
for host, want := range cases {
|
||||||
|
if got := pol.shouldPoison(host); got != want {
|
||||||
|
t.Errorf("shouldPoison(%q)=%v want %v", host, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
packages/secubox-toolbox-ng/cmd/sbxmitm/privacy.go
Normal file
179
packages/secubox-toolbox-ng/cmd/sbxmitm/privacy.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
//
|
||||||
|
// SecuBox-Deb :: toolbox-ng :: always-on anonymize + Set-Cookie poison wiring
|
||||||
|
// (#662 Phase 5-prep, Part A)
|
||||||
|
//
|
||||||
|
// These helpers wire the ported policy (policy.go) + HMAC fake-identity jar
|
||||||
|
// (jar.go) into the MITM response path. They mirror the INTENT of the Python
|
||||||
|
// privacy_guard._anonymize and privacy.fake_id poison (mitmproxy_addons/
|
||||||
|
// privacy_guard.py, secubox_toolbox/privacy.py) — best-effort privacy hygiene,
|
||||||
|
// NOT byte-identical to the Python request-Cookie path. The jar values
|
||||||
|
// themselves ARE byte-exact (proven in jar_test.go).
|
||||||
|
//
|
||||||
|
// Safety envelope (DARK, like anti-track): poison only acts on MITM'd TRACKER
|
||||||
|
// flows. allow/own-infra flows are left CLEAN — never poisoned, never blocked.
|
||||||
|
//
|
||||||
|
// Pure standard library — no external modules.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── anonymize: always-on hygiene ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// anonymizeStrip mirrors privacy_guard._STRIP / protective_mode._STRIP: the
|
||||||
|
// operator/carrier + re-identification REQUEST headers we drop on every MITM'd
|
||||||
|
// flow. Lower-cased for case-insensitive matching against canonicalised keys.
|
||||||
|
var anonymizeStrip = []string{
|
||||||
|
"msisdn", "x-msisdn", "x-up-calling-line-id", "x-up-subno",
|
||||||
|
"x-nokia-msisdn", "x-acr", "x-vf-acr", "x-amobee-1", "x-amobee-2",
|
||||||
|
"tm-user-id", "x-wap-profile", "x-wap-msisdn", "x-network-info",
|
||||||
|
"x-forwarded-for", "forwarded", "x-real-ip", "via",
|
||||||
|
}
|
||||||
|
|
||||||
|
// anonymizeRequest applies always-on privacy hygiene to a MITM'd request:
|
||||||
|
// drop the operator/tracking headers above, then pin DNT:1 + Sec-GPC:1 (the
|
||||||
|
// opt-out signals). Mirrors privacy_guard._anonymize. Minimal + best-effort:
|
||||||
|
// it never errors and is safe to call on every intercepted request.
|
||||||
|
//
|
||||||
|
// NOTE: unlike the Python spoof path we do NOT drop Cookie/Referer here —
|
||||||
|
// anonymize is the universally-safe hygiene layer; cookie neutralisation is the
|
||||||
|
// poison layer (poisonSetCookies), gated behind the tracker classification.
|
||||||
|
func anonymizeRequest(h http.Header) {
|
||||||
|
for _, name := range anonymizeStrip {
|
||||||
|
// http.Header.Del canonicalises the key; our list is lower-case but Del
|
||||||
|
// matches case-insensitively via CanonicalMIMEHeaderKey.
|
||||||
|
h.Del(name)
|
||||||
|
}
|
||||||
|
h.Set("DNT", "1")
|
||||||
|
h.Set("Sec-GPC", "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── poison: response Set-Cookie value replacement ────────────────────────────
|
||||||
|
|
||||||
|
// trackingCookieNames is the set of exact cookie names we treat as tracking
|
||||||
|
// identifiers worth poisoning (lower-cased). These map onto the shapes the jar
|
||||||
|
// (_shape in jar.go) knows how to forge plausibly.
|
||||||
|
var trackingCookieNames = map[string]bool{
|
||||||
|
"_fbp": true, "_fbc": true, "_gid": true, "_gcl_au": true,
|
||||||
|
"uid": true, "uuid": true, "_pk_id": true, "_pk_ses": true,
|
||||||
|
"__qca": true, "muid": true, "ide": true, "fr": true,
|
||||||
|
"_uetvid": true, "_uetsid": true, "anid": true, "nid": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTrackingCookieName reports whether a Set-Cookie name looks like a tracking
|
||||||
|
// identifier we should poison. Prefix rule: any "_ga*" cookie (GA + GA4
|
||||||
|
// per-property _ga_<id>) is a tracking id; otherwise an exact-match against
|
||||||
|
// trackingCookieNames. Benign session/CSRF cookies (sessionid, csrftoken, …)
|
||||||
|
// are NOT matched, so they pass through untouched.
|
||||||
|
func isTrackingCookieName(name string) bool {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if n == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(n, "_ga") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return trackingCookieNames[n]
|
||||||
|
}
|
||||||
|
|
||||||
|
// poisonSetCookies rewrites the response Set-Cookie header lines for a MITM'd
|
||||||
|
// tracker flow: for each cookie whose NAME is a tracking id, the value is
|
||||||
|
// replaced with the jar fakeID(clientHash, host, name, key) while ALL cookie
|
||||||
|
// attributes (Path, Domain, Max-Age, Secure, HttpOnly, SameSite, …) are
|
||||||
|
// preserved verbatim. Non-tracking cookies are returned byte-identical.
|
||||||
|
//
|
||||||
|
// Gating (caller's responsibility too, but defensive here): if the jar key is
|
||||||
|
// absent OR fakeID returns !ok (empty clientHash / tracker), the cookie is left
|
||||||
|
// UNCHANGED — we never emit a malformed cookie, and we never invent a fake
|
||||||
|
// where we lack the seed. This keeps the poison fail-closed-to-clean.
|
||||||
|
//
|
||||||
|
// This is the emission half of the jar; the classification half (is this a
|
||||||
|
// tracker flow at all) is Policy.shouldPoison, applied by the wiring before
|
||||||
|
// this is ever called — poison NEVER touches allow/own-infra flows.
|
||||||
|
func poisonSetCookies(setCookies []string, clientHash, host string, key []byte) []string {
|
||||||
|
if len(setCookies) == 0 {
|
||||||
|
return setCookies
|
||||||
|
}
|
||||||
|
out := make([]string, len(setCookies))
|
||||||
|
for i, sc := range setCookies {
|
||||||
|
out[i] = poisonOneSetCookie(sc, clientHash, host, key)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// poisonOneSetCookie rewrites a single Set-Cookie line. The line shape is
|
||||||
|
// `name=value; Attr1; Attr2=...`; we split off the first `;` to isolate the
|
||||||
|
// name=value pair, replace value if name is a tracking id and a fake mints,
|
||||||
|
// then re-attach the (unchanged) attribute tail.
|
||||||
|
func poisonOneSetCookie(sc, clientHash, host string, key []byte) string {
|
||||||
|
semi := strings.IndexByte(sc, ';')
|
||||||
|
pair := sc
|
||||||
|
tail := ""
|
||||||
|
if semi >= 0 {
|
||||||
|
pair = sc[:semi]
|
||||||
|
tail = sc[semi:] // includes the leading ';'
|
||||||
|
}
|
||||||
|
eq := strings.IndexByte(pair, '=')
|
||||||
|
if eq < 0 {
|
||||||
|
return sc // attribute-only / malformed → leave untouched
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(pair[:eq])
|
||||||
|
if !isTrackingCookieName(name) {
|
||||||
|
return sc
|
||||||
|
}
|
||||||
|
fake, ok := fakeID(clientHash, host, name, key)
|
||||||
|
if !ok {
|
||||||
|
return sc // no jar key / no clientHash → leave clean (fail-closed)
|
||||||
|
}
|
||||||
|
return name + "=" + fake + tail
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tracker classification + poison gate ─────────────────────────────────────
|
||||||
|
|
||||||
|
// isTracker mirrors the tracker classification used by the block decision
|
||||||
|
// (privacy.is_tracker / ad_ghost): _AD_HOST regex OR host/registrable in the
|
||||||
|
// learned-trackers set. Reused here so poison fires on exactly the hosts the
|
||||||
|
// engine already considers trackers.
|
||||||
|
func (p *Policy) isTracker(host string) bool {
|
||||||
|
return p.blockedByAd(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldPoison reports whether a MITM'd flow to host should have its tracking
|
||||||
|
// Set-Cookies poisoned. TRUE only for tracker hosts that are NOT own-infra /
|
||||||
|
// allowlisted — own-infra flows are left clean (same dark safety as the block
|
||||||
|
// path). The caller additionally requires a loaded jar key.
|
||||||
|
func (p *Policy) shouldPoison(host string) bool {
|
||||||
|
if p.allowed(host) {
|
||||||
|
return false // own-infra / allowlist → never poison
|
||||||
|
}
|
||||||
|
return p.isTracker(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── client identity ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// clientHashFromConn returns the per-client identity used to mint the stable
|
||||||
|
// fake persona (jar fakeID first arg).
|
||||||
|
//
|
||||||
|
// PoC / CONNECT path: this is the peer IP string. A real TRANSPARENT R3 deploy
|
||||||
|
// MUST replace this with the mac_hash the Python addon uses
|
||||||
|
// (privacy_guard._client_hash → _common.mac_hash_of(peer_ip)), resolved via the
|
||||||
|
// SO_ORIGINAL_DST original-destination socket option and the WireGuard-peer →
|
||||||
|
// MAC map. Using the raw peer IP here is NOT identity-stable across NAT/DHCP
|
||||||
|
// and is intentionally a Phase-6-cutover TODO, not a shipped behaviour.
|
||||||
|
//
|
||||||
|
// TODO(#662 Phase 6): wire mac_hash via SO_ORIGINAL_DST + WG-peer map.
|
||||||
|
func clientHashFromConn(conn net.Conn) string {
|
||||||
|
if conn == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
|
||||||
|
if err != nil {
|
||||||
|
return conn.RemoteAddr().String()
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
152
packages/secubox-toolbox-ng/cmd/sbxmitm/privacy_test.go
Normal file
152
packages/secubox-toolbox-ng/cmd/sbxmitm/privacy_test.go
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
//
|
||||||
|
// Unit tests for the always-on anonymize hygiene + the Set-Cookie poison
|
||||||
|
// emission wired into the MITM response path (#662 Phase 5-prep, Part A).
|
||||||
|
//
|
||||||
|
// These exercise the PURE helpers (anonymizeRequest / poisonSetCookies /
|
||||||
|
// isTrackingCookieName) so the wiring is testable without standing up a full
|
||||||
|
// proxy. The behaviour mirrors the Python privacy_guard._anonymize and the
|
||||||
|
// privacy.fake_id poison intent (see comments in privacy.go) — best-effort
|
||||||
|
// hygiene, not byte-identical to the request-Cookie path.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAnonymizeRequestStripsOperatorHeaders: the operator/carrier + re-id
|
||||||
|
// headers are dropped, and DNT:1 + Sec-GPC:1 are pinned (mirrors
|
||||||
|
// privacy_guard._anonymize / protective_mode spoof header hygiene).
|
||||||
|
func TestAnonymizeRequestStripsOperatorHeaders(t *testing.T) {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("X-MSISDN", "33612345678")
|
||||||
|
h.Set("X-ACR", "carrier-acr-token")
|
||||||
|
h.Set("X-Up-Calling-Line-Id", "33612345678")
|
||||||
|
h.Set("X-Wap-Profile", "http://wap.example/ua.xml")
|
||||||
|
h.Set("X-Forwarded-For", "10.0.0.7")
|
||||||
|
h.Set("Via", "1.1 carrier-proxy")
|
||||||
|
h.Set("User-Agent", "Mozilla/5.0") // must survive
|
||||||
|
|
||||||
|
anonymizeRequest(h)
|
||||||
|
|
||||||
|
for _, k := range []string{
|
||||||
|
"X-Msisdn", "X-Acr", "X-Up-Calling-Line-Id", "X-Wap-Profile",
|
||||||
|
"X-Forwarded-For", "Via",
|
||||||
|
} {
|
||||||
|
if v := h.Get(k); v != "" {
|
||||||
|
t.Errorf("anonymizeRequest left %s=%q (should be stripped)", k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.Get("User-Agent") != "Mozilla/5.0" {
|
||||||
|
t.Errorf("anonymizeRequest clobbered a benign header: User-Agent=%q", h.Get("User-Agent"))
|
||||||
|
}
|
||||||
|
if h.Get("DNT") != "1" {
|
||||||
|
t.Errorf("DNT not pinned: %q", h.Get("DNT"))
|
||||||
|
}
|
||||||
|
if h.Get("Sec-GPC") != "1" {
|
||||||
|
t.Errorf("Sec-GPC not pinned: %q", h.Get("Sec-GPC"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnonymizeRequestPinsSignalsWhenAbsent: DNT/Sec-GPC are asserted even when
|
||||||
|
// no operator headers were present (always-on hygiene).
|
||||||
|
func TestAnonymizeRequestPinsSignalsWhenAbsent(t *testing.T) {
|
||||||
|
h := http.Header{}
|
||||||
|
anonymizeRequest(h)
|
||||||
|
if h.Get("DNT") != "1" || h.Get("Sec-GPC") != "1" {
|
||||||
|
t.Fatalf("opt-out signals not pinned on a clean request: DNT=%q GPC=%q",
|
||||||
|
h.Get("DNT"), h.Get("Sec-GPC"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsTrackingCookieName: known tracking-id cookie names are recognised;
|
||||||
|
// benign session/CSRF cookies are not.
|
||||||
|
func TestIsTrackingCookieName(t *testing.T) {
|
||||||
|
track := []string{"_ga", "_GA_ABC123", "_fbp", "_gid", "uid", "uuid", "_pk_id", "__qca", "_gcl_au"}
|
||||||
|
for _, n := range track {
|
||||||
|
if !isTrackingCookieName(n) {
|
||||||
|
t.Errorf("isTrackingCookieName(%q)=false, want true", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
benign := []string{"sessionid", "csrftoken", "XSRF-TOKEN", "PHPSESSID", "cart", "lang"}
|
||||||
|
for _, n := range benign {
|
||||||
|
if isTrackingCookieName(n) {
|
||||||
|
t.Errorf("isTrackingCookieName(%q)=true, want false", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPoisonSetCookiesReplacesTrackingValue: a tracking Set-Cookie has its value
|
||||||
|
// replaced by the jar fakeID (attributes preserved), while a non-tracking cookie
|
||||||
|
// is left byte-identical.
|
||||||
|
func TestPoisonSetCookiesReplacesTrackingValue(t *testing.T) {
|
||||||
|
key := []byte("test-jar-seed-key-0123456789abcdef")
|
||||||
|
const ch = "203.0.113.9"
|
||||||
|
const host = "ads.doubleclick.net"
|
||||||
|
|
||||||
|
in := []string{
|
||||||
|
"_ga=GA1.2.111.222; Path=/; Domain=.doubleclick.net; Max-Age=63072000",
|
||||||
|
"sessionid=abc123; Path=/; HttpOnly",
|
||||||
|
}
|
||||||
|
out := poisonSetCookies(in, ch, host, key)
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("poisonSetCookies returned %d cookies, want 2", len(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The _ga value must be the jar fakeID and the attributes preserved.
|
||||||
|
want, ok := fakeID(ch, host, "_ga", key)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("fakeID returned !ok for _ga")
|
||||||
|
}
|
||||||
|
wantCookie := "_ga=" + want + "; Path=/; Domain=.doubleclick.net; Max-Age=63072000"
|
||||||
|
if out[0] != wantCookie {
|
||||||
|
t.Errorf("poisoned _ga = %q\n want %q", out[0], wantCookie)
|
||||||
|
}
|
||||||
|
if out[0] == in[0] {
|
||||||
|
t.Error("tracking cookie value was NOT changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The benign cookie must be untouched.
|
||||||
|
if out[1] != in[1] {
|
||||||
|
t.Errorf("non-tracking cookie altered: %q != %q", out[1], in[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPoisonSetCookiesNoKeyLeavesUnchanged: with no jar key (key present-gate),
|
||||||
|
// nothing is poisoned (fail-closed-to-clean: we never emit a broken cookie).
|
||||||
|
func TestPoisonSetCookiesNoKeyLeavesUnchanged(t *testing.T) {
|
||||||
|
in := []string{"_ga=GA1.2.1.2; Path=/"}
|
||||||
|
out := poisonSetCookies(in, "1.2.3.4", "ads.doubleclick.net", nil)
|
||||||
|
if len(out) != 1 || out[0] != in[0] {
|
||||||
|
t.Fatalf("poisonSetCookies with nil key altered output: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPoisonSetCookiesNoClientHashLeavesUnchanged: empty clientHash → fakeID !ok
|
||||||
|
// → cookie left as-is.
|
||||||
|
func TestPoisonSetCookiesNoClientHashLeavesUnchanged(t *testing.T) {
|
||||||
|
key := []byte("test-jar-seed-key-0123456789abcdef")
|
||||||
|
in := []string{"_ga=GA1.2.1.2; Path=/"}
|
||||||
|
out := poisonSetCookies(in, "", "ads.doubleclick.net", key)
|
||||||
|
if len(out) != 1 || out[0] != in[0] {
|
||||||
|
t.Fatalf("poisonSetCookies with empty clientHash altered output: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPoisonSetCookiesDeterministic: same (client,host,name) → same fake value
|
||||||
|
// across calls ('rémanent' jar — proven byte-exact in jar_test.go; here we just
|
||||||
|
// assert the wiring keeps it stable).
|
||||||
|
func TestPoisonSetCookiesDeterministic(t *testing.T) {
|
||||||
|
key := []byte("test-jar-seed-key-0123456789abcdef")
|
||||||
|
in := []string{"uid=real-user-7; Path=/"}
|
||||||
|
a := poisonSetCookies(in, "9.9.9.9", "adnxs.com", key)
|
||||||
|
b := poisonSetCookies(in, "9.9.9.9", "adnxs.com", key)
|
||||||
|
if a[0] != b[0] {
|
||||||
|
t.Fatalf("poison not deterministic: %q != %q", a[0], b[0])
|
||||||
|
}
|
||||||
|
if a[0] == in[0] {
|
||||||
|
t.Fatal("uid (tracking) cookie not poisoned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,10 +7,39 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newReader(c net.Conn) *bufio.Reader { return bufio.NewReader(c) }
|
func newReader(c net.Conn) *bufio.Reader { return bufio.NewReader(c) }
|
||||||
|
|
||||||
|
// writeResponse serializes an http.Response (status + headers + body) onto a
|
||||||
|
// (TLS) conn, preserving MULTI-VALUED headers (notably Set-Cookie, which the
|
||||||
|
// poison path rewrites per-cookie). Hop-by-hop framing headers are dropped and
|
||||||
|
// replaced with an explicit Content-Length + Connection: close, because we send
|
||||||
|
// the fully-buffered body.
|
||||||
|
func writeResponse(c io.Writer, resp *http.Response, body []byte) {
|
||||||
|
status := resp.Status
|
||||||
|
if status == "" {
|
||||||
|
status = fmt.Sprintf("%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c, "HTTP/1.1 %s\r\n", status)
|
||||||
|
for k, vals := range resp.Header {
|
||||||
|
switch http.CanonicalHeaderKey(k) {
|
||||||
|
case "Content-Length", "Transfer-Encoding", "Connection":
|
||||||
|
continue // we set framing ourselves
|
||||||
|
}
|
||||||
|
for _, v := range vals {
|
||||||
|
fmt.Fprintf(c, "%s: %s\r\n", k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c, "Content-Length: %d\r\n", len(body))
|
||||||
|
fmt.Fprintf(c, "Connection: close\r\n")
|
||||||
|
io.WriteString(c, "\r\n")
|
||||||
|
if len(body) > 0 {
|
||||||
|
c.Write(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// writeRaw writes a minimal HTTP/1.1 response onto a (TLS) conn.
|
// writeRaw writes a minimal HTTP/1.1 response onto a (TLS) conn.
|
||||||
func writeRaw(c io.Writer, code int, status string, headers map[string]string, body []byte) {
|
func writeRaw(c io.Writer, code int, status string, headers map[string]string, body []byte) {
|
||||||
if status == "" {
|
if status == "" {
|
||||||
|
|
|
||||||
9
packages/secubox-toolbox-ng/debian/changelog
Normal file
9
packages/secubox-toolbox-ng/debian/changelog
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
secubox-toolbox-ng (0.1.0-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
|
* Initial packaging of the Go MITM engine migration target (#662 Phase 5-prep).
|
||||||
|
Ships /usr/sbin/sbxmitm + a DISABLED systemd template unit
|
||||||
|
(secubox-toolbox-ng-worker@.service). DARK by design: the unit is not
|
||||||
|
enabled or started, no nft DNAT, no live-R3 wiring — enabled only at the
|
||||||
|
Phase 6 cutover.
|
||||||
|
|
||||||
|
-- Gerald KERMA <devel@cybermind.fr> Wed, 18 Jun 2026 22:00:00 +0200
|
||||||
22
packages/secubox-toolbox-ng/debian/control
Normal file
22
packages/secubox-toolbox-ng/debian/control
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
Source: secubox-toolbox-ng
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
||||||
|
Build-Depends: debhelper-compat (= 13), golang-go (>= 2:1.22~)
|
||||||
|
Standards-Version: 4.6.2
|
||||||
|
Homepage: https://cybermind.fr/secubox
|
||||||
|
Rules-Requires-Root: no
|
||||||
|
|
||||||
|
Package: secubox-toolbox-ng
|
||||||
|
Architecture: arm64
|
||||||
|
Depends: ${misc:Depends}
|
||||||
|
Description: SecuBox-Deb — Go MITM engine (migration target, DARK)
|
||||||
|
Multi-core Go re-implementation of the R3 toolbox MITM engine (#662),
|
||||||
|
ported off the GIL-bound Python mitmproxy worker fleet. Ships the
|
||||||
|
standalone sbxmitm binary plus a DISABLED systemd template unit.
|
||||||
|
.
|
||||||
|
This package is the Phase-6-cutover migration target. The unit is NOT
|
||||||
|
enabled or started by the maintainer scripts — the live R3 tunnel keeps
|
||||||
|
running on the Python workers until the cutover is performed manually.
|
||||||
|
Installing this package changes NO runtime behaviour (no service start,
|
||||||
|
no nft DNAT).
|
||||||
27
packages/secubox-toolbox-ng/debian/postinst
Executable file
27
packages/secubox-toolbox-ng/debian/postinst
Executable file
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# SecuBox-Deb :: toolbox-ng — postinst
|
||||||
|
#
|
||||||
|
# DARK by design (#662 Phase 5-prep):
|
||||||
|
# - DO reload the systemd unit catalogue so the template is known.
|
||||||
|
# - DO NOT enable or start secubox-toolbox-ng-worker@.service — this is the
|
||||||
|
# Phase-6 cutover target; the live R3 tunnel keeps running on the Python
|
||||||
|
# workers until the operator performs the cutover manually.
|
||||||
|
# - DO NOT touch nftables (no DNAT, no live-R3 rewiring).
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
# Intentionally NO `systemctl enable --now`. See the unit header and
|
||||||
|
# debian/changelog: enabled only at the Phase 6 cutover.
|
||||||
|
;;
|
||||||
|
abort-upgrade|abort-remove|abort-deconfigure)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
#DEBHELPER#
|
||||||
|
|
||||||
|
exit 0
|
||||||
44
packages/secubox-toolbox-ng/debian/rules
Executable file
44
packages/secubox-toolbox-ng/debian/rules
Executable file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/make -f
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# SecuBox-Deb :: toolbox-ng — Go MITM engine (migration target, DARK)
|
||||||
|
#
|
||||||
|
# The binary is pure-stdlib (no go.sum, no external modules), so it
|
||||||
|
# cross-compiles offline with GOPROXY=off. CI cross-builds for arm64;
|
||||||
|
# this rules file does the same with `GOOS=linux GOARCH=arm64 go build`.
|
||||||
|
|
||||||
|
export DH_VERBOSE = 1
|
||||||
|
|
||||||
|
# Build the static arm64 binary offline (stdlib only — no network, no go.sum).
|
||||||
|
export GOOS = linux
|
||||||
|
export GOARCH = arm64
|
||||||
|
export CGO_ENABLED = 0
|
||||||
|
export GOFLAGS = -mod=mod
|
||||||
|
export GOPROXY = off
|
||||||
|
# Keep the Go build/module cache inside the build tree (sandbox-friendly).
|
||||||
|
export GOCACHE = $(CURDIR)/_gocache
|
||||||
|
export GOPATH = $(CURDIR)/_gopath
|
||||||
|
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_build:
|
||||||
|
go build -trimpath -ldflags=-s -o sbxmitm ./cmd/sbxmitm
|
||||||
|
|
||||||
|
# No Go unit tests at package-build time (run in CI on the host arch; the
|
||||||
|
# arm64 cross-binary cannot execute its tests here).
|
||||||
|
override_dh_auto_test:
|
||||||
|
|
||||||
|
override_dh_auto_install:
|
||||||
|
install -d debian/secubox-toolbox-ng/usr/sbin
|
||||||
|
install -m 0755 sbxmitm debian/secubox-toolbox-ng/usr/sbin/sbxmitm
|
||||||
|
|
||||||
|
override_dh_auto_clean:
|
||||||
|
rm -f sbxmitm
|
||||||
|
rm -rf _gocache _gopath
|
||||||
|
|
||||||
|
# DARK: install the unit file into the catalogue but DO NOT enable or start it.
|
||||||
|
# This is the Phase-6 cutover target; the live R3 tunnel stays on the Python
|
||||||
|
# workers until the operator enables it manually. The postinst still reloads the
|
||||||
|
# unit catalogue so `systemctl` knows the template exists.
|
||||||
|
override_dh_installsystemd:
|
||||||
|
dh_installsystemd --no-enable --no-start --name=secubox-toolbox-ng-worker@
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# SecuBox-Deb :: toolbox-ng — Go MITM engine worker template (#662)
|
||||||
|
#
|
||||||
|
# ── DISABLED BY DESIGN (DARK) ────────────────────────────────────────────────
|
||||||
|
# This is the Phase-6 CUTOVER MIGRATION TARGET. It is NOT enabled or started by
|
||||||
|
# the package (postinst does not `systemctl enable --now`). The live R3 tunnel
|
||||||
|
# keeps running on the Python mitmproxy workers
|
||||||
|
# (secubox-toolbox-mitm-wg-worker@{1..4}, ports 8081-8084) until the cutover is
|
||||||
|
# performed manually.
|
||||||
|
#
|
||||||
|
# Mirrors the Python worker@ fanout: each %i ∈ {1..4} listens on 127.0.0.1:809%i
|
||||||
|
# (distinct from the Python 808%i ports so both fleets can coexist during a
|
||||||
|
# side-by-side cutover validation). Enable ONLY at Phase 6:
|
||||||
|
#
|
||||||
|
# systemctl enable --now secubox-toolbox-ng-worker@{1,2,3,4}.service
|
||||||
|
# # then re-point the nft DNAT at 809%i and retire the Python workers
|
||||||
|
#
|
||||||
|
# Rollback: disable these, re-point DNAT at the Python 808%i workers.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=SecuBox ToolBoX-NG Go MITM worker %i (migration target, port 809%i)
|
||||||
|
Documentation=https://github.com/CyberMind-FR/secubox-deb/issues/662
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=secubox-toolbox
|
||||||
|
Group=secubox-toolbox
|
||||||
|
|
||||||
|
# Reuse the EXISTING ca-wg CA (R3 clients already trust it — no re-enroll).
|
||||||
|
# The anti-track jar key is best-effort: absent → poison stays off.
|
||||||
|
ExecStart=/usr/sbin/sbxmitm \
|
||||||
|
--listen 127.0.0.1:809%i \
|
||||||
|
--ca-cert /etc/secubox/toolbox/ca-wg/ca.pem \
|
||||||
|
--ca-key /etc/secubox/toolbox/ca-wg/key.pem \
|
||||||
|
--jar-key /etc/secubox/secrets/privacy-jar.key
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Hardening (mirrors the Python worker envelope).
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ReadOnlyPaths=/etc/secubox
|
||||||
|
MemoryHigh=100M
|
||||||
|
MemoryMax=128M
|
||||||
|
TasksMax=128
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
1
packages/secubox-toolbox-ng/debian/source/format
Normal file
1
packages/secubox-toolbox-ng/debian/source/format
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.0 (native)
|
||||||
Loading…
Reference in New Issue
Block a user