feat(proxy): WebSocket support via ReverseProxy refactor#22
Conversation
Add 15 tests exercising handleConnectWithInterception through the full CONNECT+TLS interception path. These form the behavioral baseline for the upcoming ReverseProxy refactor that adds WebSocket support. Coverage: credential injection, canonical log fields, multi-request keepalive, network policy denial, transport errors, extra/remove headers, request body forwarding, large responses, status codes, response headers, X-Request-Id injection, Proxy-Authorization stripping, and HTTP methods.
The test sends a WebSocket upgrade through the CONNECT+TLS interception path and verifies bidirectional byte exchange after the 101 response. Currently hangs because the manual request loop cannot handle protocol upgrades — resp.Write blocks on the 101 response body (the persistent WebSocket connection).
…t support
Replace the manual for { http.ReadRequest → transport.RoundTrip → resp.Write }
loop in handleConnectWithInterception with http.Server + httputil.ReverseProxy.
ReverseProxy natively handles WebSocket upgrades (101 Switching Protocols)
by hijacking both sides and doing bidirectional io.Copy via its built-in
switchProtocolCopier.
All existing behaviors preserved:
- Credential injection (Rewrite hook)
- Network policy and Keep policy (wrapping handler)
- LLM gateway policy (ModifyResponse via evaluateAndReplaceLLMResponse)
- Response transformers (ModifyResponse)
- Canonical log lines (ModifyResponse + ErrorHandler)
- X-Request-Id injection (Rewrite)
- Extra/remove headers, token substitution (Rewrite)
- Host gateway rewrite (Rewrite)
- Proxy-Authorization preserved from In request for token-exchange
Also addresses PR #21 review feedback:
- Fix unreachable 407 assertion in NetworkPolicyDenial test
- Remove unused BackendHostPort field from test setup
- Handle http.NewRequest errors in tests
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 4m 19s —— View job Code Review
Good refactor overall — the ReverseProxy approach cleanly solves the WebSocket problem and the test coverage is substantial. Two critical bugs need fixing before merge. Critical1. Goroutine leak per connection (
Fix — use l := newSingleConnListener(tlsClientConn)
srv := &http.Server{
Handler: handler,
IdleTimeout: 120 * time.Second,
ErrorLog: log.New(io.Discard, "", 0),
ConnState: func(conn net.Conn, state http.ConnState) {
if state == http.StateClosed || state == http.StateHijacked {
l.Close()
}
},
}
_ = srv.Serve(l)
2. Credential resolution error silently forwards the request ( When // Rewrite, ~line 1945
creds, credErr := p.getCredentialsForRequest(r, pr.Out, host)
if credErr != nil {
*pr.Out = *pr.Out.WithContext(context.WithValue(pr.Out.Context(), interceptCredErrKey{}, credErr))
return // ← request is still forwarded to upstream with a valid URL
}Fix — cancel the context so if credErr != nil {
ctx, cancel := context.WithCancel(pr.Out.Context())
cancel()
*pr.Out = *pr.Out.WithContext(context.WithValue(ctx, interceptCredErrKey{}, credErr))
return
}Moderate3. Injected credential values appear in canonical log (
Fix: in 4. Request body no longer logged ( The original loop called Minor5. Planning artifacts in the tree
Looks Good
|
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 5m 50s —— View job Code Review
The ReverseProxy refactor is a clean architecture improvement and the WebSocket support works correctly. Four issues need attention before merging. Bugs1. Goroutine / FD leak (line 2166) — After 2. Credential error silently dropped when upstream is reachable (line 1948) — When Security regression3. Token-substituted URLs logged (line 2014) — The old loop captured Minor4. Zero The test coverage in |
1. Fix goroutine/FD leak: singleConnListener now uses ConnState to detect StateClosed/StateHijacked and close the listener, allowing Serve to exit. For WebSocket (hijacked), defer skips closing the underlying connections since ReverseProxy owns them. 2. Fix credential error silently dropped: moved getCredentialsForRequest and injectMCPCredentialsWithContext to the wrapping handler (before ReverseProxy.ServeHTTP) so errors get an early 502 return. Resolved credentials are passed to Rewrite via context. 3. Fix token-substituted URLs in logs: capture pre-substitution URL in Rewrite via interceptLogURLKey context key; ModifyResponse and ErrorHandler use it for canonical log lines instead of req.URL which contains real tokens after substitution. 4. Fix zero Duration in policy denial logs: moved time.Now() to the top of the wrapping handler, before any policy checks.
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 3m 19s —— View job Code Review
The prior feedback has largely been addressed well. The goroutine leak is fixed ( SecurityInjected credential values appear in
The old loop snapshotted headers before injection ( // In Rewrite, before calling injectCredentials:
preInjectionHeaders := pr.Out.Header.Clone()
ctx = context.WithValue(pr.Out.Context(), interceptPreInjectionHeadersKey{}, preInjectionHeaders)
*pr.Out = *pr.Out.WithContext(ctx)
// In ModifyResponse, replace line 2033:
preHeaders, _ := req.Context().Value(interceptPreInjectionHeadersKey{}).(http.Header)
// use preHeaders instead of req.Header.Clone()BugsLLM policy denials no longer set
Options:
The body needs to be captured in the wrapping handler (after credential resolution) before // In handler, after credential resolution:
var reqBody []byte
reqBody, req.Body = captureBody(req.Body, req.Header.Get("Content-Type"))
ctx = context.WithValue(ctx, interceptReqBodyKey{}, reqBody)Then read MinorQuery params stripped from URLs in denial/error logs ( All four early-return log entries use Looks Good
|
1. Security: snapshot pre-injection headers in Rewrite so credential values don't appear in canonical log RequestHeaders. Uses interceptPreInjHeadersKey context key. 2. Bug: evaluateAndReplaceLLMResponse now returns (denied, reason) so ModifyResponse can set Denied/DenyReason in the canonical log line. Restores parity with the old loop's llmDenied/llmDenyReason tracking. 3. Bug: capture RequestBody in wrapping handler via captureBody before ReverseProxy consumes it. Passed to ModifyResponse via interceptReqBodyKey context. 4. Minor: denial/error log URLs now use req.URL.RequestURI() instead of req.URL.Path to preserve query parameters.
|
All three issues + the minor fixed in c990bc6:
|
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 4m 21s —— View job Code Review
All seven issues flagged in the prior rounds have been correctly addressed. The fixes are clean and the approach is sound. Two minor items remain. Security regression test missing
// Verify credential value is NOT present in logged request headers.
if v := logged.RequestHeaders.Get("Authorization"); v != "" {
t.Errorf("logged RequestHeaders contains injected Authorization value %q; credential values must not appear in logs", v)
}Minor
The network policy and Keep policy denial paths log Looks Good
|
…ields - Add assertion in TestIntercept_CredentialInjectionCanonicalLog that logged RequestHeaders does NOT contain the injected Authorization value - Add RequestSize/ResponseSize to credential error log path for consistency with other early-return paths
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 6m 30s —— View job Code Review
All critical and moderate issues from previous rounds are correctly fixed. Two minor regressions remain. Minor1. In the old loop, Fix — one line in the handler before req.Header.Set("X-Request-Id", innerReqID) // propagate so Rewrite preserves it
reverseProxy.ServeHTTP(w, req.WithContext(ctx))2. The network policy and Keep policy denial paths set Looks Good
|
- Set X-Request-Id on req.Header in wrapping handler before calling ReverseProxy so Rewrite preserves the same ID used in denial logs - Add RequestSize/ResponseSize to ErrorHandler log for consistency with other early-return paths
|
Claude finished @andybons's task in 4m 7s —— View job Code Review
All issues from the previous review rounds are correctly addressed. Here's a final pass over the full diff. Looks GoodGoroutine/FD leak — Credential error handling — Credential values in logs — Token-substituted URLs — LLM policy Denied/DenyReason — RequestBody in logs — Policy denial duration — Query params in denial/error logs — All four early-return paths use innerReqID propagation — ErrorHandler log fields — WebSocket test — Exercises raw bidirectional byte flow through the full MITM path (CONNECT → TLS handshake → upgrade request → 101 → raw echo). 101 body safety — Planning artifacts — Nothing blocking. This is ready to merge. |
Summary
Replaces the manual HTTP request loop in
handleConnectWithInterceptionwithhttputil.ReverseProxy, enabling WebSocket upgrade support through TLS-intercepted connections.Supersedes #21 (interception test coverage) — includes those tests plus the refactor and WebSocket test.
What changed
The core change is in
proxy/proxy.go:handleConnectWithInterception. The manualfor { http.ReadRequest → transport.RoundTrip → resp.Write }` loop is replaced with:Rewrite: Credential injection, extra/remove headers, token substitution, X-Request-Id, host gateway rewriteModifyResponse: LLM gateway policy, response transformers, canonical log linesErrorHandler: Transport errors → 502, credential resolution errorsReverseProxynatively handles 101 Switching Protocols — hijacks both sides, bidirectionalio.CopyNew supporting code
singleConnListener— wraps a singlenet.Connas anet.Listenerforhttp.Server.Serve()evaluateAndReplaceLLMResponse— extracted LLM policy evaluation into a method (was inline)Rewrite/ModifyResponse/ErrorHandlerTests (16 new)
CredentialInjectionCredentialInjectionCanonicalLogMultiRequestKeepaliveNetworkPolicyDenialTransportError502CanonicalLogFieldsExtraHeadersRemoveHeadersRequestBodyForwardedLargeResponseBodyResponseStatusCodesResponseHeadersXRequestIdInjectedProxyAuthorizationStrippedHTTPMethodsWebSocketUpgradeCloses
The original problem: gatekeeper's MITM proxy couldn't handle WebSocket frames after upgrade (
malformed HTTP request "\x82\xe1..."). Now it works becauseReverseProxyswitches to raw bidirectional copy after 101.