Skip to content

mitm: flush response writes per-chunk so streaming responses are delivered live#197

Closed
devniel wants to merge 1 commit into
Infisical:mainfrom
callRounded:mitm-stream-flush
Closed

mitm: flush response writes per-chunk so streaming responses are delivered live#197
devniel wants to merge 1 commit into
Infisical:mainfrom
callRounded:mitm-stream-flush

Conversation

@devniel

@devniel devniel commented May 20, 2026

Copy link
Copy Markdown

Problem

The forward path in internal/mitm/forward.go writes the upstream response body with:

_, _ = io.Copy(w, io.LimitReader(resp.Body, brokercore.MaxResponseBytes))

That leaves chunk delivery to the http.ResponseWriter's default coalescing. For non-streaming responses (single JSON body with Content-Length) that's fine — one Write, one delivery. For streaming protocols layered on top of HTTP, the client needs each upstream chunk to arrive promptly. When it doesn't, downstream parsers stall or abort mid-response.

The concrete failure that prompted this PR: git clone over HTTPS through the proxy. After authentication (Basic / Bearer / api-key) is correctly injected, the actual pack transfer fails with:

error: 6650 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output

git's sideband pack-protocol parser is built on top of HTTP and uses its own framing within the response body. Without per-chunk flushing, byte boundaries from upstream collapse into a buffered blob, the parser hits a stall at the wrong point, and the connection is closed before the final packet arrives.

Same class of bug bites:

  • SSE event streams (events buffered until upstream closes the body)
  • Long-running gRPC-over-HTTP/2 streams
  • Anything else that relies on upstream byte boundaries being preserved end-to-end

Change

Wrap the ResponseWriter in a flushingWriter that calls Flush() after every successful Write, so upstream chunk boundaries reach the client unbuffered:

if f, ok := w.(http.Flusher); ok {
    _, _ = io.Copy(&flushingWriter{w: w, f: f}, io.LimitReader(resp.Body, brokercore.MaxResponseBytes))
} else {
    _, _ = io.Copy(w, io.LimitReader(resp.Body, brokercore.MaxResponseBytes))
}

flushingWriter is dead-simple (Write → Flush) and doesn't change semantics for non-streaming responses — io.Copy already does a single Write in that case, so the extra Flush is a no-op.

Regression test

TestMITMForwardStreamsChunksPromptly (new) wires an upstream that writes N chunks separated by 80ms delays, flushing each, and asserts the arrivals at the client are spread across time. On the prior io.Copy-only path, all chunks land in a single read after the upstream's final sleep — the test fails with:

chunks arrived in 5.958µs; expected ≥ 120ms
(proxy is buffering instead of flushing per-chunk)

With the fix the spread is preserved and the test passes.

Verification

  • New regression test passes; baseline (pre-fix) fails clearly with the diagnostic above
  • Full internal/mitm/ test suite green (go test ./internal/mitm/... -count=1)
  • Verified locally: git clone of a real GitHub repo through the proxy completes successfully (was failing with early EOF before)

Opening as a draft to invite review on the approach. Two alternatives I considered and would happily switch to if the maintainers prefer:

  1. httputil.ReverseProxy — swap the hand-rolled copy entirely. Larger blast radius, but FlushInterval=-1 is the idiomatic Go answer for proxying streaming responses. I held off because the existing forward code is doing a few non-trivial things (LimitReader cap, custom header strip, request-log emission) that would need to be wired up via ModifyResponse/ErrorHandler/Transport callbacks.
  2. http.NewResponseController(w).Flush() periodically on a timer, similar to ReverseProxy's FlushInterval. Slightly more complex, marginally more efficient on tiny chunks. The per-Write Flush in this PR is plenty fast in practice and easier to reason about.

…ered live without throttling throughput

The forward path's io.Copy left chunk delivery to the ResponseWriter's default coalescing. For non-streaming responses (single JSON body with Content-Length) that's fine. For streaming protocols layered on top of HTTP, the client needs each upstream chunk to arrive promptly: git smart-HTTP sideband packs ('git clone' over HTTPS) idle-time-out and abort mid-pack with 'fatal: early EOF / fetch-pack: invalid index-pack output'; SSE event streams buffer until upstream closes the body; long-running gRPC-over-HTTP/2 streams stall similarly.

Use a periodic flusher (50ms) modeled on net/http/httputil.maxLatencyWriter — the same pattern ReverseProxy.FlushInterval uses for exactly this case.

Why not flush per Write: upstream Reads on chunked TLS can return MTU-sized fragments, and a syscall + TLS write per fragment caps throughput at ~tens of KB/s. Observed in production: a 282 MiB git pack that finishes in <2 minutes direct took >20 minutes through the proxy with per-Write flushing.

Two regression tests: TestMITMForwardStreamsChunksPromptly (correctness — chunk spread preserved end-to-end) and TestMITMForwardStreamsLargeBodyAtLineRate (throughput floor — 4 MiB must finish through the proxy in ≤5s; a flush-per-Write regression would take many seconds; full-buffering would be similar).

Also log copy errors with byte counts so silent truncation is no longer silent.
@devniel devniel force-pushed the mitm-stream-flush branch from 49429cf to 3ad57fd Compare May 20, 2026 13:21
saifsmailbox98 added a commit that referenced this pull request Jun 2, 2026
…SE) (#229)

## Summary

Streaming responses (git clone pack data, SSE event streams) were
buffered by Go's `ResponseWriter` until the internal 4 KB buffer filled
or the handler returned. Upstream chunk boundaries collapsed into a
single blob — git's sideband parser stalled, SSE events arrived late,
and any chunk-boundary-sensitive protocol broke. On top of that, the
CONNECT tunnel's `WriteTimeout: 5 * time.Minute` killed any transfer
that took longer than 5 minutes to stream.

Concrete failure: `git clone` of `microsoft/TypeScript` through the
proxy died with `fatal: early EOF` / `fetch-pack: unexpected disconnect
while reading sideband packet`. Same root cause as external draft PR
#197.

**Per-chunk flushing**

Wraps the `ResponseWriter` in a `flushingWriter` that calls
`http.Flusher.Flush()` after every successful `Write`. `io.Copy` uses a
32 KB buffer, so this flushes every ≤32 KB — upstream chunk boundaries
reach the client immediately instead of coalescing. Applied to both the
HTTP forward path (`forward.go`) and the WebSocket non-101 fallback path
(`websocket.go`).

For non-streaming responses (single JSON body with `Content-Length`),
this is effectively a no-op — `io.Copy` does one `Write` and one
`Flush`, same as before.

**CONNECT tunnel WriteTimeout raised to 30 minutes**

The previous 5-minute `WriteTimeout` on the CONNECT tunnel's
`http.Server` was an absolute deadline from request-header-read to
response-complete. Any streaming response >5 minutes was killed. Raised
to 30 minutes to cover realistic long-running transfers. Slow-loris
defense is preserved by `ReadHeaderTimeout` (10s) and `ReadTimeout`
(60s) on the request side, `IdleTimeout` (2 min) between requests, and
the upstream transport's `ResponseHeaderTimeout` (5 min) for stalled
upstreams.

**WebSocket oversized-frame substitution warning**

Added `slog.Warn` when a WebSocket text frame exceeds the 1 MB
substitution buffer (`maxWSSubstitutionPayload`) and substitutions are
configured. The existing fragmentation case already warned; the size
case silently passed through.

Supersedes #197.

## Type of change

- [x] Bug fix
- [ ] New feature
- [ ] Refactor / cleanup
- [ ] Documentation
- [ ] CI / build

## Test plan

- [x] Existing tests pass (`go test ./...` — all 20 packages green)
- [x] Added/updated tests for new behavior:

| Test | Validates |
|------|-----------|
| `TestMITMForwardStreamsChunksPromptly` | Upstream sends 5 chunks with
80ms delays; asserts inter-chunk arrival gaps ≥40ms at the client
(proves per-chunk flushing, not batch delivery) |

- [x] Manual testing:

| Scenario | Result |
|----------|--------|
| Full `git clone` of `microsoft/TypeScript` through proxy | Completed:
3.4 GB, 81K files, ~6 min. Previously failed at 100 MB (body cap, fixed
in #227) and at 5 min (WriteTimeout, fixed here). |
| SSE drip test (`httpbin.org/drip?duration=5&numbytes=5`) through proxy
| 5 bytes arrived 1/sec — same cadence as direct, no buffering. |

## Security checklist

- [x] No secrets or credentials in code
- [x] No new unauthenticated endpoints
- [x] Input validation on new API surfaces
- [x] Checked for OWASP top 10 (injection, XSS, etc.)

---------

Co-authored-by: saif <11242541+saifsmailbox98@users.noreply.github.com>
@saifsmailbox98

Copy link
Copy Markdown
Collaborator

superseded by #229

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.

2 participants