Skip to content

Commit e2314be

Browse files
committed
fix: warn when a ReadsBody plugin lands in SSE passthrough
A plugin that declares ReadsBody but is not a StreamingResponder falls into streamPassthrough, where its OnResponse runs against an empty pctx.ResponseBody (the stream is forwarded unbuffered to avoid the #642 timeout). Emit a slog.Warn -- mirroring the existing WritesBody fallback -- so the misconfiguration surfaces instead of silently starving the plugin of the body. Buffering is intentionally not the fix; such a plugin should implement StreamingResponder. Add TestForwardProxy_SSE_ReadsBodyPluginWarnsAndStreams: the stream is still delivered verbatim, OnResponse sees an empty body, and the warning fires. Addresses review feedback on #675. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent e2395a5 commit e2314be

2 files changed

Lines changed: 98 additions & 5 deletions

File tree

authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import (
55
"bytes"
66
"context"
77
"io"
8+
"log/slog"
89
"net/http"
910
"net/http/httptest"
1011
"strings"
12+
"sync"
1113
"sync/atomic"
1214
"testing"
1315
"time"
@@ -139,27 +141,54 @@ func TestForwardProxy_SSE_StreamsWithoutResponder(t *testing.T) {
139141
// responseProbePlugin is a minimal non-StreamingResponder plugin whose
140142
// OnResponse uses only status/headers (like opa / litellm-budgettrack). It
141143
// records that it ran and can deny — used to prove streamPassthrough still runs
142-
// the response-phase pipeline before streaming.
144+
// the response-phase pipeline before streaming. When readsBody is set it also
145+
// declares ReadsBody, so it exercises the ReadsBody-but-not-StreamingResponder
146+
// footgun (see TestForwardProxy_SSE_ReadsBodyPluginWarnsAndStreams).
143147
type responseProbePlugin struct {
144-
deny bool
145-
ranResp atomic.Bool
148+
deny bool
149+
readsBody bool
150+
ranResp atomic.Bool
151+
// sawBodyLen is the length of pctx.ResponseBody observed in OnResponse.
152+
sawBodyLen atomic.Int64
146153
}
147154

148155
func (p *responseProbePlugin) Name() string { return "response-probe" }
149156
func (p *responseProbePlugin) Capabilities() pipeline.PluginCapabilities {
150-
return pipeline.PluginCapabilities{} // no ReadsBody/WritesBody/StreamingResponder → streamPassthrough path
157+
// Never a StreamingResponder → streamPassthrough path. ReadsBody is opt-in
158+
// so most callers stay on the status/header-only shape.
159+
return pipeline.PluginCapabilities{ReadsBody: p.readsBody}
151160
}
152161
func (p *responseProbePlugin) OnRequest(context.Context, *pipeline.Context) pipeline.Action {
153162
return pipeline.Action{Type: pipeline.Continue}
154163
}
155-
func (p *responseProbePlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action {
164+
func (p *responseProbePlugin) OnResponse(_ context.Context, pctx *pipeline.Context) pipeline.Action {
156165
p.ranResp.Store(true)
166+
p.sawBodyLen.Store(int64(len(pctx.ResponseBody)))
157167
if p.deny {
158168
return pipeline.DenyStatus(403, "test.denied", "denied by response probe")
159169
}
160170
return pipeline.Action{Type: pipeline.Continue}
161171
}
162172

173+
// syncBuffer is a goroutine-safe io.Writer for capturing slog output written
174+
// from the proxy's request-handler goroutine while the test reads it.
175+
type syncBuffer struct {
176+
mu sync.Mutex
177+
buf bytes.Buffer
178+
}
179+
180+
func (b *syncBuffer) Write(p []byte) (int, error) {
181+
b.mu.Lock()
182+
defer b.mu.Unlock()
183+
return b.buf.Write(p)
184+
}
185+
186+
func (b *syncBuffer) String() string {
187+
b.mu.Lock()
188+
defer b.mu.Unlock()
189+
return b.buf.String()
190+
}
191+
163192
// sseProxy wires an SSE upstream + a forward proxy with the given pipeline and
164193
// returns a client bound to the proxy. The upstream writes one event and
165194
// returns (closes) — enough to exercise the response-phase decision.
@@ -245,3 +274,52 @@ func TestForwardProxy_SSE_HeaderOnResponseRuns(t *testing.T) {
245274
t.Errorf("SSE body not delivered verbatim: %q", body)
246275
}
247276
}
277+
278+
// TestForwardProxy_SSE_ReadsBodyPluginWarnsAndStreams covers the ReadsBody
279+
// footgun raised in review: a plugin that declares ReadsBody but is NOT a
280+
// StreamingResponder falls into streamPassthrough. The proxy must NOT buffer to
281+
// feed it a body (that would reintroduce the #642 timeout on a live stream), so
282+
// the plugin's OnResponse sees an empty pctx.ResponseBody. The stream must still
283+
// be delivered verbatim, and the misconfiguration must be surfaced via a warning
284+
// rather than silently starving the plugin of the body.
285+
func TestForwardProxy_SSE_ReadsBodyPluginWarnsAndStreams(t *testing.T) {
286+
// Capture slog output. The warning is emitted from the proxy's handler
287+
// goroutine, so use a mutex-guarded buffer to stay race-clean; tests in this
288+
// package don't run in parallel, so swapping the default logger is safe.
289+
logBuf := &syncBuffer{}
290+
prev := slog.Default()
291+
slog.SetDefault(slog.New(slog.NewTextHandler(logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})))
292+
t.Cleanup(func() { slog.SetDefault(prev) })
293+
294+
probe := &responseProbePlugin{readsBody: true}
295+
client, url := sseProxy(t, []pipeline.Plugin{probe})
296+
297+
req, _ := http.NewRequest("POST", url, bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)))
298+
req.Header.Set("Content-Type", "application/json")
299+
resp, err := client.Do(req)
300+
if err != nil {
301+
t.Fatalf("request failed: %v", err)
302+
}
303+
defer resp.Body.Close()
304+
body, _ := io.ReadAll(resp.Body)
305+
306+
if !probe.ranResp.Load() {
307+
t.Error("OnResponse did not run on the SSE response")
308+
}
309+
// The plugin declared ReadsBody, but the stream is passed through unbuffered,
310+
// so it sees no body — the documented limitation this warning surfaces.
311+
if n := probe.sawBodyLen.Load(); n != 0 {
312+
t.Errorf("ReadsBody plugin saw %d body bytes, want 0 (stream is not buffered for it)", n)
313+
}
314+
// The stream must still be delivered verbatim — no regression to the #642 timeout.
315+
if resp.StatusCode != http.StatusOK {
316+
t.Errorf("status = %d, want 200", resp.StatusCode)
317+
}
318+
if !strings.Contains(string(body), "event: message") || !strings.Contains(string(body), "id: 7") {
319+
t.Errorf("SSE body not delivered verbatim: %q", body)
320+
}
321+
// The misconfiguration must be logged, not silent.
322+
if got := logBuf.String(); !strings.Contains(got, "ReadsBody plugin that is not a StreamingResponder") {
323+
t.Errorf("expected a warning about the ReadsBody+SSE misconfiguration, got logs: %q", got)
324+
}
325+
}

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,21 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, isBridge
395395
// with per-write flushing. Re-framing (handleStreamingResponse)
396396
// would drop the event:/id:/retry: lines that generic SSE
397397
// clients (e.g. an MCP Streamable HTTP client) depend on. Fixes #642.
398+
//
399+
// A plugin that declares ReadsBody (but not WritesBody, and is
400+
// not a StreamingResponder) also lands here, and its OnResponse
401+
// runs against an empty pctx.ResponseBody: streamPassthrough
402+
// forwards the stream without buffering it. We deliberately don't
403+
// buffer to satisfy such a plugin — that would reintroduce the
404+
// #642 timeout on a live stream. A plugin that must inspect a
405+
// streamed body should implement StreamingResponder. Warn
406+
// (mirroring the WritesBody fallback above) so the
407+
// misconfiguration surfaces instead of the plugin silently seeing
408+
// no body. WritesBody is already false in this branch, so
409+
// NeedsBody() here implies ReadsBody.
410+
if s.OutboundPipeline.NeedsBody() {
411+
slog.Warn("forward-proxy: text/event-stream response with a ReadsBody plugin that is not a StreamingResponder — streaming byte-for-byte; its OnResponse will see an empty body (implement StreamingResponder to inspect a streamed body)", "host", r.Host)
412+
}
398413
s.streamPassthrough(w, r, resp, pctx)
399414
return
400415
}

0 commit comments

Comments
 (0)