mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 19:16:07 +00:00
Compare commits
No commits in common. "c870b6362b54f0fa57cee5b0ed651e954656a9af" and "7355e606cabea439663a067c5db778f07c8ad70f" have entirely different histories.
c870b6362b
...
7355e606ca
9
packages/secubox-toolbox-ng/.gitignore
vendored
9
packages/secubox-toolbox-ng/.gitignore
vendored
|
|
@ -1,12 +1,3 @@
|
||||||
/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,8 +179,6 @@ 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 {
|
||||||
|
|
@ -212,11 +210,7 @@ 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")
|
||||||
|
|
||||||
// Decide once on (host, sni). For the CONNECT PoC the SNI is the CONNECT
|
if px.pol.action(host) == "splice" {
|
||||||
// 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 {
|
||||||
|
|
@ -241,22 +235,11 @@ 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 verdict == "block" {
|
if px.pol.action(host) == "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 = ""
|
||||||
|
|
@ -266,35 +249,18 @@ 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)
|
||||||
}
|
}
|
||||||
writeResponse(tconn, resp, body)
|
hdr := map[string]string{"Content-Type": resp.Header.Get("Content-Type")}
|
||||||
|
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 {
|
||||||
|
|
@ -308,18 +274,10 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
// 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,39 +7,10 @@ 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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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).
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
#!/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@
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# 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 +0,0 @@
|
||||||
3.0 (native)
|
|
||||||
Loading…
Reference in New Issue
Block a user