mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 16:31:31 +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
|
||||
*.test
|
||||
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
|
||||
pol *Policy
|
||||
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 {
|
||||
|
|
@ -210,7 +212,11 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|||
defer client.Close()
|
||||
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).
|
||||
up, err := net.DialTimeout("tcp", r.URL.Host, 10*time.Second)
|
||||
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
|
||||
|
||||
if px.pol.action(host) == "block" {
|
||||
if verdict == "block" {
|
||||
writeRaw(tconn, 204, "No Content", map[string]string{"X-SecuBox-Ng": "blocked"}, nil)
|
||||
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.
|
||||
up := &http.Client{Timeout: 30 * time.Second}
|
||||
req.RequestURI = ""
|
||||
|
|
@ -249,18 +266,35 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
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))
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
|
||||
body = px.pol.injectMarker(body)
|
||||
}
|
||||
hdr := map[string]string{"Content-Type": resp.Header.Get("Content-Type")}
|
||||
writeRaw(tconn, resp.StatusCode, resp.Status, hdr, body)
|
||||
writeResponse(tconn, resp, body)
|
||||
}
|
||||
|
||||
func main() {
|
||||
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")
|
||||
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()
|
||||
ca, err := loadCA(*caCert, *caKey)
|
||||
if err != nil {
|
||||
|
|
@ -274,10 +308,18 @@ func main() {
|
|||
log.Fatalf("policy load: %v", err)
|
||||
}
|
||||
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{
|
||||
ca: ca,
|
||||
pol: pol,
|
||||
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) {
|
||||
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"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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.
|
||||
func writeRaw(c io.Writer, code int, status string, headers map[string]string, body []byte) {
|
||||
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