Skip to content

feat: config-driven MITM interception for HTTPS and plain HTTP - #8

Open
TiaraBasori wants to merge 5 commits into
ProjectClash:mainfrom
TiaraBasori:feat/mitm
Open

TiaraBasori wants to merge 5 commits into
ProjectClash:mainfrom
TiaraBasori:feat/mitm

Conversation

@TiaraBasori

Copy link
Copy Markdown

Summary

  • Adds a new component/mitm engine behind a top-level mitm: config block: a generated CA mints per-host leaf certificates, connections are gated on the ClientHello SNI (peeked, never consumed), and gated traffic is terminated and re-originated through the same rule-resolved proxy adapter — interception never changes which proxy carries a connection.
  • HTTP/1.1 and HTTP/2 on both legs: the inner leg negotiates ALPN and serves h2 through a small frame layer (hpack via golang.org/x/net/http2/hpack, already a direct dependency; frame + flow control implemented locally so the Apple SDK build stays free of a full HTTP/2 stack); the outer leg dials per request burst and bridges h2↔HTTP/1.1 in either direction.
  • Declarative rewrites, ordered first-match: host+path url-patterns, 302 redirect (bare-host redirects keep path/query), 4xx reject, request-header set/add/remove with value-prefix composition.
  • Bind layer exports MITMState / ExportMITMCa / ResetMITMCa so the client can display, export and reset the trust profile the user installs.

Motivation

Anywhere (NodePassProject) ships a built-in MITM (declarative rules + JS over terminated TLS) as a core feature of a native client. ProjectClash's kernel is a mihomo-lineage fork where the sniffer can only observe SNI/Host — there is no way to rewrite HTTP on hosts the user controls. This PR brings the interception model over, adapted to this kernel's architecture: the tun lives in the Go core (the NE feed sits on top of it), so MITM must be a core component; a Swift-side MITM would never see the streams.

Design notes that drove the implementation:

  • Gating is fail-closed and reversible. The gate is evaluated from metadata first (port, skip-dst, skip-domain); only then is the ClientHello peeked for SNI. Any miss returns the conn with peeked bytes still buffered, so the tunnel's normal path (including deferred-handshake peeks) proceeds unmodified. crypto/tls is only constructed after the host is known to be gated.
  • The upstream dial is injected. The tunnel builds the dial func from the rule-resolved proxy adapter with metadata.Pure() (fake-IP host restored, dst IP cleared, DNS re-resolved by the core). MITM changes what is said to the origin, never how it gets there.
  • Bounded memory by construction. Leaf cache ≤4096 hosts with half-life re-mint; h2 request bodies buffer ≤1 MiB/stream (larger uploads answered 413, stream not connection); send windows are granted only for bytes actually consumed; everything else streams through.
  • Reject/redirect never dial. A policy that answers locally leaves the upstream untouched — that is what makes ad-block style rules cheap.

Changes

  • component/mitm/ — new package: mitm.go (CA engine: RSA-2048 root, P-256 leaves, persist/load/regenerate, Equal()-based key-pair verification), config.go (normalize/validate: host gates example.com / +.example.com, skip rules, port ranges via the existing utils.IntRanges, rewrite policies; rejects wildcards, bare IPs, header injection, javascript: redirects, loops through >64 policies), dispatcher.go (tunnel entry: gate → SNI peek → terminate → reverse-proxy loop; upstream TLS honors tls.custom-trust-cert), h2.go (h2 server + client legs: frame codec, flow control with cond-based window waits, CONTINUATION assembly, padded frames, one serial response at a time).
  • tunnel/tunnel.go — mitmDispatcher atomic slot + UpdateMITM; the TCP hot path asks MITM after resolveMetadataContext so the dial func has the selected proxy.
  • config/config.go — RawMITM + parseMITM (ports reuse the sniffer's range parser; +.example.com normalizes to the leading-dot form).
  • hub/executor/executor.go — updateMITM alongside updateSniffer, fail-closed on init errors, custom-trust-cert seeding.
  • bind/clash/mitm_ca.go — CA exports for the App, under the standard recoverAppCall guard.
  • docs/config.yaml — annotated mitm: example in the existing style.

Test Plan

  • component/mitm unit tests: CA mint/verify against own pool, leaf cache identity + eviction bound, save/load round-trip (incl. 0600 key perms), key-mismatch rejection, gate semantics (suffix match without notexample.com/evil.test false-positives, skip-domain precedence), normalize rejection matrix, first-match-wins, port ranges.
  • End-to-end tests over real TCP+TLS with stdlib clients (ALPN h2 and forced HTTP/1.1): intercept+forward, header rewrite observed at the origin, reject without upstream, immediate 302, 16 KiB streaming body, keep-alive multi-request, ungated SNI passthrough (handshake refused, conn returned unmodified).
  • go build ./... and go vet ./... with the CI toolchain (go1.26.9), main module and bind/clash.
  • Apple SDK slice build (CI xcframework job) — gomobile-bindable APIs only (string/[]byte/JSON structs).

Notes for Reviewers

  • Known limitations, intentional for a first cut: response-side rewriting and body replacement are not included (request headers carry the auth/geo state MITM exists for; response editing is a separate, larger surface); JS scripting is deliberately out — the declarative policy covers Anywhere's rule half without a script VM's lifecycle cost in the extension process; h2 push is refused per spec for proxies; QUIC/HTTP/3 is untouched and will bypass MITM until a UDP-side policy exists (Anywhere solves this the same way — fail H3 fast so h2 is used).
  • The h2 layer is hand-rolled because the SDK module cannot take net/http-transport-shaped dependencies; if reviewers prefer, http.Server's h2 could serve the inner leg via h2c-style wrapping at the cost of tying the leg to stdlib server semantics — the frame layer here is ~600 lines and gives explicit control over buffering and stream limits.
  • updateMITM is called after updateSniffer on every reload; a broken CA store logs a warning and disables MITM rather than poisoning the hot path (fail closed).

Adds a component/mitm engine behind a new top-level 'mitm:' config block:
a generated CA (persisted under store-dir) mints per-host leaf certs,
connections are gated on SNI peeked from the ClientHello before any TLS
state exists, and gated traffic is terminated and re-originated through
the same rule-resolved proxy adapter, so interception never changes
which proxy carries a connection. Rewrites are declarative: ordered
policies with host+path patterns, 302 redirect, 4xx reject, and request
header set/add/remove.

HTTP/1.1 and HTTP/2 are handled on both legs. The inner leg negotiates
ALPN and serves h2 through a small frame layer (hpack via
golang.org/x/net/http2/hpack, already a direct dependency); the outer
leg dials per request burst and bridges an h2 client to an HTTP/1.1
upstream or vice versa. Ungated SNI, wrong ports, and gate misses return
the conn untouched for the normal path; reject and redirect policies
never dial upstream. Request bodies buffer up to 1 MiB per h2 stream and
larger uploads are refused 413 rather than buffered without bound.

The bind layer exposes MITMState, ExportMITMCa and ResetMITMCa so the
client can show, export and reset the trust profile the user installs.
The custom-trust-cert seeding referenced cfg from outside its scope and
used x509 without importing it; go1.23 masked both because the tuic
package stops the build before hub/executor compiles. Caught by the
CI-toolchain build (go1.26.9).
@TiaraBasori

Copy link
Copy Markdown
Author

CI run 38510720399 sits in action_required — the workflow needs maintainer approval for a first-time fork branch (TiaraBasori:feat/mitm). Approving it will run the standard build/vet gates.

Verification already done locally with the CI toolchain (go1.26.9):

  • go build ./... and go vet ./... on the main module, and the same on bind/clash (the gomobile module the SDK build depends on)
  • go test ./component/mitm/ — CA mint/verify, save/load round-trip, key-mismatch rejection, gate semantics, cache bounds, plus end-to-end tests over real TCP+TLS with stdlib clients on both ALPN h2 and forced HTTP/1.1 paths

Connection lifecycle:
- tunnel: the 5s dialCtx no longer bounds the whole intercepted
  connection; serve switches to a long-lived context and each upstream
  dial re-arms its own DefaultTCPTimeout, so keep-alive legs survive
  past the tunnel's dial budget (was: guaranteed 502s after 5s)
- dispatcher: h1 keep-alive works end to end — resp.Close from the
  upstream leg (which we force to Connection:close ourselves) no longer
  closes the client leg, the upstream body's Close is decoupled from
  Response.Write's internal close so FlushBody can stream it, and the
  client keep-alive signal rides leg.reqClose
- dial metadata is an explicit fresh copy (Pure() can alias), the
  deferred handshake is reported on the claimed path, and gated conns
  leave an interception trace in the log

Protocol correctness:
- rewrite url-patterns match the request path with segment boundaries
  (was: pattern path ignored, policies fired on every path)
- host-only redirect targets (redirect: mirror.example.com) build
  scheme://host + original path + query (was: same-origin loop)
- plain vs TLS upstream is chosen by the gate scheme, not the port;
  overlapping http/https port lists are rejected at load
- SNI peek grows the bufio buffer before the second Peek, so >4KB
  ClientHellos (post-quantum key shares) are intercepted
- WebSocket upgrade requests are refused with 501 instead of
  corrupting the request loop with a forwarded 101

Resource safety:
- panic recovery around the per-connection intercept path
- request/response heads capped at 64KB (431 on overflow) on both legs
- per-operation deadlines: streaming bodies are never truncated by a
  sticky deadline and a non-reading client cannot pin a goroutine
- 413 drains a bounded amount before answering so well-behaved
  transports see the status

h2 legs:
- flow control: streams register at the tracked initial window with
  RFC 9113 6.9.2 delta math; negative windows are connection errors
  (was: grant(sid,0) deadlocked every bodied request)
- CONTINUATION flood capped at 64KB with open-block and same-stream
  checks (CVE-2024-27316 shape)
- MAX_CONCURRENT_STREAMS=32 advertised with REFUSED_STREAM beyond it,
  plus an 8MB connection-level buffered-body budget with deferred
  window grants for real backpressure
- terminal responses (reject/redirect/502) complete with END_STREAM
- upstream RST_STREAM fails the stream, not the connection
- frame hardening: zero/overflow WINDOW_UPDATE, wrong-length SETTINGS,
  unknown-stream DATA all GOAWAY; PING echoed with length enforced
- upstream leg offers h2 via ALPN (was unreachable) with an
  EnableHTTP2 e2e bridging test

CA engine and config:
- SaveState is atomic (temp + rename) and LoadState only regenerates
  when BOTH files are absent — a transient read error no longer
  silently destroys an installed CA
- gates canonicalized (lowercase, trailing dot) and non-ASCII (IDN)
  rejected with punycode guidance; skip-domain bare entries now cover
  subdomains like host gates
- value-prefix prepends to the client's existing header value; the
  docs example (which paired value+value-prefix and failed Normalize)
  is fixed and pinned by a round-trip test
- header names validated as RFC 7230 tokens, pattern hosts validated
  at load, gate/skip lists capped at 256
- leaf minting moved out of the engine mutex and eviction replaces the
  oldest quarter instead of nuking the cache
- reload with empty store-dir reuses the running engine instead of
  rotating the CA on every ApplyConfig
- custom-trust-cert seeding appends per-cert with honest logging;
  ExportMITMCa returns the DER fingerprint (matches MITMState)

Tests: 21 unit + 30 h2 frame/e2e + ~20 dispatcher e2e additions
(big-ClientHello, plain-HTTP, path matching, redirect host-only,
413/431, upgrade 501, h2-upstream bridging, keep-alive longevity,
slow-chunked streaming, docs example round-trip).
@TiaraBasori

Copy link
Copy Markdown
Author

Pushed a hardening pass (2095d57c) after an adversarial review of the branch. Highlights (full detail in the commit message):

Must-fixes found by review, now fixed

  • The tunnel's 5s dialCtx used to bound the whole intercepted connection — every keep-alive leg died with 502s after 5 seconds. Serve now runs on a long-lived context and each upstream dial re-arms its own timeout.
  • Rewrite url-patterns never looked at the request path (a tautology) — policies fired on every path. Matching is now path-aware with segment boundaries.
  • Host-only redirects (redirect: mirror.example.com) looped on the same origin; they now build scheme://host + original path + query.
  • h1 keep-alive was broken end to end (upstream Connection:close leaked into the client leg; Response.Write's internal body close killed the streaming loop).
  • The h2 flow-control layer deadlocked on any bodied request (grant(sid,0)), had a CVE-2024-27316-shaped CONTINUATION flood, unbounded stream memory, and terminal responses without END_STREAM — all fixed, with 30 new frame/e2e tests.
  • SNI peek overflows the default 4KB bufio on post-quantum ClientHellos — Grow-before-Peek added with a >4KB test.
  • SaveState/LoadState could silently destroy an installed CA on a transient read error; persistence is now atomic (temp+rename) and regenerates only when both files are absent.
  • Reloads with empty store-dir reused to rotate the CA on every ApplyConfig; the running engine is now reused.

Also: panic recovery on the per-conn path, 64KB header caps (431), per-operation deadlines, WebSocket upgrades refused 501, h2 ALPN to upstream (was unreachable) with an EnableHTTP2 e2e, gate canonicalization (case/trailing-dot/IDN-reject), docs example fixed (it paired value+value-prefix and failed Normalize — now pinned by a round-trip test), value-prefix now prepends to the client's existing header value, leaf minting moved out of the engine mutex, ExportMITMCa fingerprint aligned with MITMState.

Local verification (go1.26.9, CI toolchain): go test ./component/mitm/ (62s, ~80 tests) green, ./config green, bind/clash build+vet+mitm tests green, vet clean on all touched packages. The 4 failing pre-existing bind tests (certstore/platform-verifier assertions) fail identically on main — unrelated to this PR.

@TiaraBasori TiaraBasori left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Review (round 2)

Full independent pass over the head commit (also verified locally with go1.26.9: go build ./..., go vet, full ./component/mitm + ./config suite green — no flakes; and probed adversarially with purpose-built tests, all removed after).

Verdict: Changes required (self-review — cannot formally request changes on own PR) — 4 P0s, all reproduced, plus wiring/spec issues. The P0s: the feature never activates in production (dispatcher stored into a write-only var), every h2-origin response body >64 KiB truncates (WINDOW_UPDATE misroute), a 0-length RST_STREAM panics the process, and the h2 inner leg wedges after 32 keep-alive GETs (s.active leak).

✅ Held up well under the second pass

  • CA persistence (atomic save/load, fail-closed on broken state) — probed corrupt/half-missing/dir-shaped states, all correct.
  • Gate semantics, path-prefix matching (segment boundaries verified against traversal/encoded shapes), header-field validation, big-ClientHello Grow fix, h1 keep-alive and 413/431 flows — all behave as documented.
  • Goroutine stability across 30 sequential intercepted requests: clean.

Details in the inline comments, ordered by severity.

Comment thread hub/executor/executor.go
tunnel.UpdateMITM(nil)
return
}
tunnel.UpdateMITM(dispatcher)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P0 — the dispatcher is stored into a write-only variable; MITM never activates in production.

tunnel.UpdateMITM(dispatcher) stores into tunnel.mitmDispatcher (tunnel.go:69) — and nothing ever reads that variable. The hot path reads mitm.ActiveDispatcher() (tunnel.go:780), which loads component/mitm's own atomic (dispatcher.go:584), and mitm.UpdateMITM — the only writer of that store — is called only from bind/clash/mitm_ca_test.go. grep -rn 'mitm.UpdateMITM' --include='*.go' . outside tests returns nothing.

Consequences, all silent:

  • interception never runs (hot path always sees nil); every e2e still passes because they drive InnerHandle/NewDispatcher directly;
  • MITMState/ExportMITMCa/ResetMITMCa always report disabled;
  • StableDispatcher(mitm.ActiveDispatcher(), …) always gets nil as previous, so the "reuse engine across reloads" path never executes;
  • the disable path (executor.go:420/458 tunnel.UpdateMITM(nil)) looks like it turns MITM off but cannot.

Fix: call mitm.UpdateMITM(dispatcher) / mitm.UpdateMITM(nil) here (or drop tunnel.mitmDispatcher entirely and have UpdateMITM in tunnel delegate to mitm.UpdateMITM). Add one integration test that goes through executor.ApplyConfig → mitm.ActiveDispatcher() != nil so this class of wiring break cannot re-land.

Comment thread component/mitm/h2.go Outdated
var wu [4]byte
wu[0], wu[1], wu[2], wu[3] = byte(len(payload)>>24)&0x7f, byte(len(payload)>>16), byte(len(payload)>>8), byte(len(payload))
_ = c.writeFrame(h2FrameWindowUpdate, 0, 0, wu[:])
_ = c.writeFrame(h2FrameWindowUpdate, 0, c.streamID, wu[:])

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P0 — stream-level WINDOW_UPDATE goes to the wrong stream; every h2-origin response body > 64 KiB is truncated.

RoundTrip bumps c.streamID += 2 (line 1216) before the response arrives, so by the time response DATA flows, c.streamID points at the next stream. The per-stream grant here lands on a phantom id instead of the stream that carried the bytes.

Reproduced against a real Go httptest h2 origin through the full dispatcher: a 100 KiB body delivers exactly 65535 bytes then unexpected EOF / upstream PROTOCOL_ERROR, and the leg emits zero WINDOW_UPDATEs for stream 1 (conn-window grants do arrive). TestE2EH2UpstreamBridgedEndToEnd passes only because its body is 9 bytes.

Fix: track the in-flight stream id explicitly (e.g. c.activeStream set in RoundTrip, cleared in FinishBody), and grant on that. Please add a big-body (>64 KiB) e2e — this is the single most common real-world shape.

Comment thread component/mitm/h2.go Outdated
// Per-stream error only: fail the in-flight round trip with the
// code; the connection itself stays usable unless the peer also
// sends GOAWAY (W5).
code := uint32(f.payload[0])<<24 | uint32(f.payload[1])<<16 | uint32(f.payload[2])<<8 | uint32(f.payload[3])

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P0 — remote panic / process kill on a 0-length RST_STREAM.

f.payload[0..3] is read before the len(f.payload) == 4 check. A upstream sending RST_STREAM with an empty payload crashes the read loop:

panic: runtime error: index out of range [0] with length 0
component/mitm.(*h2ClientConn).readLoop(...) h2.go:1058

Verified with a scripted upstream. In this kernel a panic in a serve goroutine takes the process (the whole VPN) down. Swap the order: check len(f.payload) == 4 first, read the code inside that branch (which the else already does).

Comment thread component/mitm/h2.go
_ = s.writeFrame(h2FrameRSTStream, 0, streamID, []byte{0, 0, 0, h2ErrorCodeRefused})
return
}
s.active++

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 P0 — s.active leaks for every END_STREAM-on-HEADERS request; the h2 inner leg wedges after 32 keep-alive GETs.

finishHeaders increments s.active, then for endStream requests delivers immediately and returns without any stream entry in s.streams — so finalizeData/releaseStream never run for it and the counter is never decremented. The common case (every GET, every HEAD) leaks one unit per request.

Reproduced: 40 sequential GETs on one h2 conn — request 33 is refused with RST_STREAM(REFUSED_STREAM) and the connection is wedged from then on. A gated h2 keep-alive browser session dies after 32 requests.

Fix: decrement for the endStream path too (e.g. s.active-- after deliverRequest, or track completion in the serve loop), plus a regression test doing >h2MaxConcurrentStreams sequential GETs on one connection.

Comment thread component/mitm/h2.go Outdated
func (s *h2Server) finishHeaders(streamID uint32, endStream bool) {
fields, err := s.decodeHeaderBlock(s.hdrBuf, streamID)
if err != nil {
_ = s.writeFrame(h2FrameRSTStream, 0, streamID, []byte{0, 0, 0, h2ErrorCodeInternal})

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HPACK decode failure must be a CONNECTION error, not RST_STREAM.

On decodeHeaderBlock error the leg sends RST_STREAM(INTERNAL) and keeps reading (verified: the connection still accepts a valid request afterwards). RFC 9113 §4.3 requires COMPRESSION_ERROR — after a failed block the dynamic table state is undefined, so subsequent requests decode against a possibly-desynced table (cross-stream header confusion on a MITM leg is request-smuggling-shaped). TestH2DecodeErrorsDoNotPanic currently accepts RST or GOAWAY, which masks this — it should assert GOAWAY + connection close.

Comment thread component/mitm/mitm.go
// cached leaf and drop ours so all connections share one identity.
return cached, nil
}
e.insertLeafLocked(normalized, leaf)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Regenerate can race MintLeaf: a mint that snapshotted the pre-reset CA outside the lock inserts an old-CA leaf into the fresh cache after the reset, and leafFresh is time-only — the stale-CA leaf is then served for up to leafValidity (7 days), breaking the just-installed new profile for that host. Cheap fix: store the owning CA fingerprint in HostCertificate and re-check it against the current CA before serving/inserting.

// proxyPass dials the upstream for one request, writes it, and streams the
// response back. upstreamHost is empty for direct forwards; policies that
// rewrite to another host pass the rewritten host so the dial uses it.
func (d *Dispatcher) proxyPass(ctx context.Context, leg *clientLeg, req *http.Request, host, scheme string, dial DialFunc, dstPort uint16, upstreamHost string) bool {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 proxyPass dials the upstream per request — an h1 keep-alive client pays TCP + TLS + full proxy handshake for every request, and an h2 upstream conn is closed after exactly one response (the W5 connection-reuse machinery only ever serves one round trip). The PR body describes "per request burst" dialing; the code is per request. Fine for a first cut, but ad-block style hot paths will feel this — worth a follow-up pooling note in the docs at least.

}

func (u *http1Upstream) RoundTrip(ctx context.Context, req *http.Request) (*http.Response, error) {
_ = ctx

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 http1Upstream.RoundTrip discards ctx (_ = ctx): a client-cancelled request (navigated away) leaves the upstream leg pinned up to the 120s idle deadline. Even without full cancellation plumbing, a shorter cap for the head phase (like outerHandshakeTimeout) would shrink the orphan window.

Comment thread docs/config.yaml
# - 8000-9999

mitm:
enable: false

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 QUIC/HTTP3 bypasses interception entirely (PR body acknowledges; the docs don't). Users enabling this for ad-block will see it silently stop working on h3-upgraded sites. A one-line warning here ("block UDP 443 to keep h3 from bypassing MITM") would save a support thread.

if err != nil {
// H10 policy: if the failure is inside h2.go's client leg, keep
// the NextProtos fix and record why the bridge is not green.
t.Skipf("h2 upstream bridge failed inside h2.go: %v", err)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 t.Skipf on h2-bridge failure is a escape hatch that can silently rot the outer-leg h2 path (and did mask the >64 KiB truncation). Now that the bridge exists, prefer hard failure.

P0 (all reproduced before fixing):
- executor wiring: tunnel.UpdateMITM stored into a write-only
  tunnel-local var; the hot path reads mitm.ActiveDispatcher. Delegate
  to mitm.UpdateMITM so interception/CA export/disable actually reach
  the running core. Integration test pins it.
- h2 client leg: stream-level WINDOW_UPDATE went to c.streamID (the
  NEXT stream, already bumped) instead of the in-flight one, so
  response bodies > 64 KiB truncated at exactly 65535 bytes. Grant on
  c.streamID-2. Big-body (>64 KiB) e2e added against a real h2 origin.
- h2 client leg: 0-length RST_STREAM indexed f.payload[0..3] before
  the length check -> read-loop panic took down the process. Check
  length first.
- h2 server leg: s.active leaked for every END_STREAM-on-HEADERS
  request (all GETs), wedging the connection at 32 keep-alive GETs.
  Release the slot on the fully-delivered path. Regression test drives
  72 sequential GETs.

P1:
- HPACK decode failure is now COMPRESSION_ERROR + close (dynamic table
  state undefined after a failed block; RST-and-continue risked
  cross-stream header confusion).
- server-leg DATA writes bounded by a per-chunk flow-control deadline
  (takeWithDeadline; channel-based wakeup replacing cond.Broadcast) +
  flow.Shutdown on readLoop exit/Close, so a stalled client can no
  longer park the serve goroutine forever.
- SNI peek bounded by innerHandshakeTimeout; a stalled pre-ClientHello
  client releases the tunnel conn slot instead of pinning it.

P2/P3:
- HEADERS on stream 0 / even IDs GOAWAY (PROTOCOL_ERROR) instead of
  being answered from stream 0.
- plain-HTTP gate no longer requires DNSMode==DNSMapping (fake-ip and
  sniffer-replaced metadata never carry it; port-80 interception was
  unreachable on default configs). Host quality enforced downstream by
  MatchHost in the cleartext path.
- port overlap validation now compares EFFECTIVE sets: https-ports:
  ["80"] (http standard-port-only) is rejected instead of loading into
  a config where every :80 conn fails a TLS handshake.
- HostCertificate records its signing-CA fingerprint; leaves minted by
  a previous CA are refused and re-minted after Regenerate/LoadState
  (race replay test).
- docs: note that QUIC/HTTP3 bypasses MITM and how to force h2.
- pre-existing race: h2ClientConn round-trip state (respOnce/respReady/
  respErr/status/header/pipes) reset by RoundTrip while the readLoop
  published a failure; guarded by rtMu (found by -race).

12 new regression tests, all reproducing the pre-fix defects.
@TiaraBasori

Copy link
Copy Markdown
Author

All findings fixed in 02d516a (pushed):

P0

  1. Wiring — tunnel.UpdateMITM now delegates to mitm.UpdateMITM (the store the hot path reads); write-only tunnel slot removed. Pinned by an executor-level integration test (TestFixUpdateMITMRegistersActiveDispatcher) that fails on the old code.
  2. WINDOW_UPDATE misroute — grants go to c.streamID-2 (the in-flight stream). New e2e TestFixBridgeH2UpstreamBigBody drives a 100 KiB body through a real Go h2 origin; it truncated at 65535 before, completes now.
  3. 0-length RST_STREAM panic — length check gates the payload read (TestFixZeroLengthRSTStream).
  4. s.active leak — END_STREAM-on-HEADERS releases the slot (TestFixActiveCounterKeepsServingGETs, 72 sequential GETs).

P1
5. HPACK decode failure → GOAWAY(COMPRESSION_ERROR) + close (TestFixHpackDecodeErrorIsConnectionError).
6. Server-leg DATA writes bounded: takeWithDeadline (channel wakeup) + flow.Shutdown on readLoop exit/Close (TestFixStalledClientReleasesServeLoop).
7. SNI peek bounded by innerHandshakeTimeout (TestFixPeekDeadlineReleasesStalledClient).

P2/P3
8. HEADERS on stream 0/even IDs → PROTOCOL_ERROR GOAWAY (TestFixHeadersOnInvalidStreamID).
9. Plain-HTTP gate no longer requires DNSMode==DNSMapping; MatchHost enforced in the cleartext path (TestFixPlainHTTPGateWithoutDNSMapping covers DNSNormal + DNSFakeIP).
10. Port overlap compares EFFECTIVE sets — https-ports:["80"] and http-ports:["443"] rejected (TestFixEffectivePortOverlap).
11. Leaf/CA ownership: HostCertificate.caFingerprint + leafOwnedLocked; stale-CA leaves re-minted (TestFixLeafCacheOwnerAfterRegenerate, race replayed).
12. docs/config.yaml: QUIC/HTTP3 bypass note.
13. Bonus (found by -race during verification): h2ClientConn round-trip state was reset by RoundTrip racing readLoop's abort/failStream — now guarded by rtMu.

Verification: go build ./..., go vet, full ./component/mitm + ./config + ./tunnel + ./hub suite, and the h2/e2e/fix set under -race (2 consecutive clean full-race runs, 0 warnings) with go1.26.9. 12 regression tests added, each failing on the pre-fix code.

- idleTimeout became an atomic (idleTimeoutNow accessor): tests shorten
  it while leg goroutines from earlier tests are still reading it — the
  mutation is test-only and benign, but a plain var data-races. Product
  behavior unchanged.
- TestE2EOversizedPostRejected413: the exact overflow write that fails
  races the interceptor's 413+close on loopback buffering; tolerate a
  broken pipe past the drain bound (>=16 of 17 chunks) and go read the
  answer.

Verified: 6 consecutive full -race runs of ./component/mitm, all green,
0 warnings.
@TiaraBasori

Copy link
Copy Markdown
Author

Follow-up 94b46d7: the late -race background runs flagged two flaky tests (product code unaffected):

  • idleTimeout is now an atomic.Int64 (idleTimeoutNow() accessor) — tests shorten it while leg goroutines from earlier tests still read it; the mutation is test-only, but the plain var raced.
  • TestE2EOversizedPostRejected413: which of the overflow writes loses the race against the interceptor's 413+close is decided by loopback buffering; the test now tolerates a broken pipe past the 1 MiB drain bound (≥16 of 17 chunks) and proceeds to read the 413.

Verified with 6 consecutive full -race runs of ./component/mitm — all green, 0 warnings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant