Skip to content

Commit e2395a5

Browse files
committed
Fix: run response-phase pipeline before SSE passthrough streaming
streamPassthrough previously skipped RunResponse (matching handleStreamingResponse), which silently bypassed header/status response gates on SSE responses — e.g. opa's response-phase deny and litellm-budgettrack's cost accounting would not run for a streamed response. Run RunResponse before the first byte in streamPassthrough and honor a deny. This is safe only here: the path is reached exclusively when no StreamingResponder is configured, so RunResponse cannot double-dispatch a plugin that also implements OnResponseFrame, and the plugins reachable here use status/headers only (never pctx.ResponseBody). Body-level response inspection on a stream still requires implementing StreamingResponder. Add tests: a response-phase deny short-circuits before any SSE byte is written, and a non-denying header-level OnResponse still runs while the stream is delivered verbatim. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent ec2fb7e commit e2395a5

2 files changed

Lines changed: 131 additions & 4 deletions

File tree

authbridge/authlib/listener/forwardproxy/mcp_sse_stream_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package forwardproxy
33
import (
44
"bufio"
55
"bytes"
6+
"context"
67
"io"
78
"net/http"
89
"net/http/httptest"
910
"strings"
11+
"sync/atomic"
1012
"testing"
1113
"time"
1214

@@ -133,3 +135,113 @@ func TestForwardProxy_SSE_StreamsWithoutResponder(t *testing.T) {
133135
close(release)
134136
closed = true
135137
}
138+
139+
// responseProbePlugin is a minimal non-StreamingResponder plugin whose
140+
// OnResponse uses only status/headers (like opa / litellm-budgettrack). It
141+
// records that it ran and can deny — used to prove streamPassthrough still runs
142+
// the response-phase pipeline before streaming.
143+
type responseProbePlugin struct {
144+
deny bool
145+
ranResp atomic.Bool
146+
}
147+
148+
func (p *responseProbePlugin) Name() string { return "response-probe" }
149+
func (p *responseProbePlugin) Capabilities() pipeline.PluginCapabilities {
150+
return pipeline.PluginCapabilities{} // no ReadsBody/WritesBody/StreamingResponder → streamPassthrough path
151+
}
152+
func (p *responseProbePlugin) OnRequest(context.Context, *pipeline.Context) pipeline.Action {
153+
return pipeline.Action{Type: pipeline.Continue}
154+
}
155+
func (p *responseProbePlugin) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action {
156+
p.ranResp.Store(true)
157+
if p.deny {
158+
return pipeline.DenyStatus(403, "test.denied", "denied by response probe")
159+
}
160+
return pipeline.Action{Type: pipeline.Continue}
161+
}
162+
163+
// sseProxy wires an SSE upstream + a forward proxy with the given pipeline and
164+
// returns a client bound to the proxy. The upstream writes one event and
165+
// returns (closes) — enough to exercise the response-phase decision.
166+
func sseProxy(t *testing.T, plugins []pipeline.Plugin) (*http.Client, string) {
167+
t.Helper()
168+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
169+
w.Header().Set("Content-Type", "text/event-stream")
170+
w.WriteHeader(http.StatusOK)
171+
io.WriteString(w, "event: message\nid: 7\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n")
172+
if f, ok := w.(http.Flusher); ok {
173+
f.Flush()
174+
}
175+
}))
176+
t.Cleanup(upstream.Close)
177+
178+
store := session.New(5*time.Minute, 100, 0)
179+
t.Cleanup(store.Close)
180+
pipe, err := pipeline.New(plugins)
181+
if err != nil {
182+
t.Fatalf("pipeline.New: %v", err)
183+
}
184+
srv, err := NewServer(pipeline.NewHolder(pipe), store, nil)
185+
if err != nil {
186+
t.Fatalf("NewServer: %v", err)
187+
}
188+
proxy := httptest.NewServer(srv.Handler())
189+
t.Cleanup(proxy.Close)
190+
191+
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(proxy.URL))}}
192+
return client, upstream.URL + "/mcp"
193+
}
194+
195+
// TestForwardProxy_SSE_ResponsePhaseDenyShortCircuits proves the fix keeps
196+
// response-phase enforcement on streamed responses: a plugin that denies in
197+
// OnResponse must short-circuit BEFORE any SSE byte is written.
198+
func TestForwardProxy_SSE_ResponsePhaseDenyShortCircuits(t *testing.T) {
199+
probe := &responseProbePlugin{deny: true}
200+
client, url := sseProxy(t, []pipeline.Plugin{probe})
201+
202+
req, _ := http.NewRequest("POST", url, bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)))
203+
req.Header.Set("Content-Type", "application/json")
204+
resp, err := client.Do(req)
205+
if err != nil {
206+
t.Fatalf("request failed: %v", err)
207+
}
208+
defer resp.Body.Close()
209+
body, _ := io.ReadAll(resp.Body)
210+
211+
if !probe.ranResp.Load() {
212+
t.Error("OnResponse did not run on the SSE response (RunResponse skipped)")
213+
}
214+
if resp.StatusCode != http.StatusForbidden {
215+
t.Errorf("status = %d, want 403 (response-phase deny not honored)", resp.StatusCode)
216+
}
217+
if strings.Contains(string(body), "event: message") {
218+
t.Errorf("SSE body was forwarded despite the deny: %q", body)
219+
}
220+
}
221+
222+
// TestForwardProxy_SSE_HeaderOnResponseRuns proves a non-denying, header-level
223+
// OnResponse (e.g. cost accounting) still fires on an SSE response and the
224+
// stream is delivered.
225+
func TestForwardProxy_SSE_HeaderOnResponseRuns(t *testing.T) {
226+
probe := &responseProbePlugin{deny: false}
227+
client, url := sseProxy(t, []pipeline.Plugin{probe})
228+
229+
req, _ := http.NewRequest("POST", url, bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)))
230+
req.Header.Set("Content-Type", "application/json")
231+
resp, err := client.Do(req)
232+
if err != nil {
233+
t.Fatalf("request failed: %v", err)
234+
}
235+
defer resp.Body.Close()
236+
body, _ := io.ReadAll(resp.Body)
237+
238+
if !probe.ranResp.Load() {
239+
t.Error("OnResponse did not run on the SSE response")
240+
}
241+
if resp.StatusCode != http.StatusOK {
242+
t.Errorf("status = %d, want 200", resp.StatusCode)
243+
}
244+
if !strings.Contains(string(body), "event: message") || !strings.Contains(string(body), "id: 7") {
245+
t.Errorf("SSE body not delivered verbatim: %q", body)
246+
}
247+
}

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -670,10 +670,16 @@ func (s *Server) handleStreamingResponse(w http.ResponseWriter, r *http.Request,
670670
// through to an unflushed io.Copy and never reached the client until the
671671
// upstream closed the connection (issue #642).
672672
//
673-
// Response-phase plugins (RunResponse / RunResponseFrame) are intentionally not
674-
// invoked here — consistent with the streaming contract that legacy/body
675-
// plugins don't run on streamed responses — but the response event is still
676-
// recorded for the session API / abctl.
673+
// Unlike handleStreamingResponse, the response-phase pipeline (RunResponse) IS
674+
// run before the first byte. That is safe only because this path is reached
675+
// exclusively when no StreamingResponder is configured, so RunResponse cannot
676+
// double-dispatch a plugin that also handles OnResponseFrame. Running it lets
677+
// header/status-based response gates fire on streamed responses too — e.g.
678+
// opa's response-phase deny (status + headers) and litellm-budgettrack's cost
679+
// accounting (a response header) — and a deny is honored before any byte is
680+
// written. The plugins reachable here do not read pctx.ResponseBody in
681+
// OnResponse, so leaving the body unbuffered is fine; body-level response
682+
// inspection on a stream requires implementing StreamingResponder.
677683
func (s *Server) streamPassthrough(w http.ResponseWriter, r *http.Request, resp *http.Response, pctx *pipeline.Context) {
678684
flusher, ok := w.(http.Flusher)
679685
if !ok {
@@ -685,6 +691,15 @@ func (s *Server) streamPassthrough(w http.ResponseWriter, r *http.Request, resp
685691
return
686692
}
687693

694+
// Run the response-phase pipeline before the first byte so header/status
695+
// gates still fire on a streamed response and a deny short-circuits before
696+
// anything is written. streamFallbackBuffered runs its own RunResponse, so
697+
// this is done only on the flushing path to avoid double-dispatch.
698+
if respAction := s.OutboundPipeline.RunResponse(r.Context(), pctx); respAction.Type == pipeline.Reject {
699+
httpx.WriteRejection(w, respAction)
700+
return
701+
}
702+
688703
// Record the response event on every exit path (normal EOF, upstream read
689704
// error, downstream write error) so a SessionResponse row still lands.
690705
defer s.recordOutboundResponseEvent(pctx, resp.StatusCode)

0 commit comments

Comments
 (0)