Skip to content

Commit 1da84e2

Browse files
authored
Merge pull request #675 from huang195/fix/forwardproxy-sse-passthrough
Fix: Forward-proxy streams SSE responses without a StreamingResponder
2 parents f8a34c7 + e2314be commit 1da84e2

2 files changed

Lines changed: 431 additions & 4 deletions

File tree

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
package forwardproxy
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"context"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"net/http/httptest"
11+
"strings"
12+
"sync"
13+
"sync/atomic"
14+
"testing"
15+
"time"
16+
17+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
18+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/session"
19+
)
20+
21+
// TestForwardProxy_SSE_StreamsWithoutResponder is the regression test for
22+
// issue #642: a generic (e.g. MCP Streamable HTTP) upstream returns
23+
// text/event-stream, but the outbound pipeline has NO StreamingResponder
24+
// plugin. Before the fix, such a response fell through to an unflushed
25+
// io.Copy, so intermittent SSE events never reached the client until the
26+
// upstream closed the connection — the agent timed out.
27+
//
28+
// Unlike TestForwardProxy_MCP_SSEResponse_RecordsObserve (which uses an
29+
// upstream that writes one frame then RETURNS, so io.Copy sees EOF
30+
// immediately and never exposes the buffering), this upstream flushes one
31+
// event and then holds the connection OPEN. A buffering proxy delivers
32+
// nothing until release; a flushing proxy delivers the event at once.
33+
func TestForwardProxy_SSE_StreamsWithoutResponder(t *testing.T) {
34+
release := make(chan struct{})
35+
closed := false
36+
defer func() {
37+
if !closed {
38+
close(release)
39+
}
40+
}()
41+
42+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
43+
f, ok := w.(http.Flusher)
44+
if !ok {
45+
t.Error("upstream ResponseWriter lacks http.Flusher")
46+
return
47+
}
48+
w.Header().Set("Content-Type", "text/event-stream")
49+
w.WriteHeader(http.StatusOK)
50+
// event: + id: exercise byte-faithful framing — the re-framing path
51+
// (handleStreamingResponse) would drop a non-allowlisted event name
52+
// and the id: line entirely.
53+
io.WriteString(w, "event: message\nid: 42\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"ok\":true}}\n\n")
54+
f.Flush()
55+
<-release // hold the stream open, like a live MCP server between events
56+
}))
57+
defer upstream.Close()
58+
59+
store := session.New(5*time.Minute, 100, 0)
60+
defer store.Close()
61+
62+
// Empty pipeline: HasStreamingResponders()==false and WritesBody()==false,
63+
// so serveOutbound routes to streamPassthrough — the reporter's plain-proxy
64+
// shape (their only outbound plugin, token-exchange, is likewise not a
65+
// StreamingResponder).
66+
pipe, err := pipeline.New(nil)
67+
if err != nil {
68+
t.Fatalf("pipeline.New: %v", err)
69+
}
70+
srv, err := NewServer(pipeline.NewHolder(pipe), store, nil)
71+
if err != nil {
72+
t.Fatalf("NewServer: %v", err)
73+
}
74+
proxy := httptest.NewServer(srv.Handler())
75+
defer proxy.Close()
76+
77+
req, _ := http.NewRequest("POST", upstream.URL+"/mcp", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)))
78+
req.Header.Set("Content-Type", "application/json")
79+
proxyClient := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(proxy.URL))}}
80+
resp, err := proxyClient.Do(req)
81+
if err != nil {
82+
t.Fatalf("request failed: %v", err)
83+
}
84+
defer resp.Body.Close()
85+
86+
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
87+
t.Errorf("Content-Type = %q, want text/event-stream", ct)
88+
}
89+
90+
// Read the first full SSE frame in a goroutine, WHILE the upstream is still
91+
// blocked on <-release. A buffering proxy delivers nothing until release, so
92+
// this read blocks and the select hits the timeout — that is the #642
93+
// regression.
94+
type frameResult struct {
95+
data []byte
96+
err error
97+
}
98+
got := make(chan frameResult, 1)
99+
go func() {
100+
br := bufio.NewReader(resp.Body)
101+
var acc []byte
102+
for {
103+
line, err := br.ReadBytes('\n')
104+
acc = append(acc, line...)
105+
if err != nil {
106+
got <- frameResult{acc, err}
107+
return
108+
}
109+
if bytes.HasSuffix(acc, []byte("\n\n")) {
110+
got <- frameResult{acc, nil}
111+
return
112+
}
113+
}
114+
}()
115+
116+
select {
117+
case fr := <-got:
118+
if fr.err != nil && fr.err != io.EOF {
119+
t.Fatalf("reading first SSE frame: %v", fr.err)
120+
}
121+
frame := string(fr.data)
122+
// Byte-faithful framing: event: and id: must survive verbatim.
123+
if !strings.Contains(frame, "event: message") {
124+
t.Errorf("first frame missing 'event: message' (framing not preserved): %q", frame)
125+
}
126+
if !strings.Contains(frame, "id: 42") {
127+
t.Errorf("first frame missing 'id: 42' (framing not preserved): %q", frame)
128+
}
129+
if !strings.Contains(frame, `data: {"jsonrpc":"2.0"`) {
130+
t.Errorf("first frame missing/garbled data line: %q", frame)
131+
}
132+
case <-time.After(2 * time.Second):
133+
t.Fatal("timed out waiting for the first SSE event while upstream held the connection open — proxy buffered the stream (regression of #642)")
134+
}
135+
136+
// Let the upstream handler exit cleanly.
137+
close(release)
138+
closed = true
139+
}
140+
141+
// responseProbePlugin is a minimal non-StreamingResponder plugin whose
142+
// OnResponse uses only status/headers (like opa / litellm-budgettrack). It
143+
// records that it ran and can deny — used to prove streamPassthrough still runs
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).
147+
type responseProbePlugin struct {
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
153+
}
154+
155+
func (p *responseProbePlugin) Name() string { return "response-probe" }
156+
func (p *responseProbePlugin) Capabilities() pipeline.PluginCapabilities {
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}
160+
}
161+
func (p *responseProbePlugin) OnRequest(context.Context, *pipeline.Context) pipeline.Action {
162+
return pipeline.Action{Type: pipeline.Continue}
163+
}
164+
func (p *responseProbePlugin) OnResponse(_ context.Context, pctx *pipeline.Context) pipeline.Action {
165+
p.ranResp.Store(true)
166+
p.sawBodyLen.Store(int64(len(pctx.ResponseBody)))
167+
if p.deny {
168+
return pipeline.DenyStatus(403, "test.denied", "denied by response probe")
169+
}
170+
return pipeline.Action{Type: pipeline.Continue}
171+
}
172+
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+
192+
// sseProxy wires an SSE upstream + a forward proxy with the given pipeline and
193+
// returns a client bound to the proxy. The upstream writes one event and
194+
// returns (closes) — enough to exercise the response-phase decision.
195+
func sseProxy(t *testing.T, plugins []pipeline.Plugin) (*http.Client, string) {
196+
t.Helper()
197+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
198+
w.Header().Set("Content-Type", "text/event-stream")
199+
w.WriteHeader(http.StatusOK)
200+
io.WriteString(w, "event: message\nid: 7\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n")
201+
if f, ok := w.(http.Flusher); ok {
202+
f.Flush()
203+
}
204+
}))
205+
t.Cleanup(upstream.Close)
206+
207+
store := session.New(5*time.Minute, 100, 0)
208+
t.Cleanup(store.Close)
209+
pipe, err := pipeline.New(plugins)
210+
if err != nil {
211+
t.Fatalf("pipeline.New: %v", err)
212+
}
213+
srv, err := NewServer(pipeline.NewHolder(pipe), store, nil)
214+
if err != nil {
215+
t.Fatalf("NewServer: %v", err)
216+
}
217+
proxy := httptest.NewServer(srv.Handler())
218+
t.Cleanup(proxy.Close)
219+
220+
client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(proxy.URL))}}
221+
return client, upstream.URL + "/mcp"
222+
}
223+
224+
// TestForwardProxy_SSE_ResponsePhaseDenyShortCircuits proves the fix keeps
225+
// response-phase enforcement on streamed responses: a plugin that denies in
226+
// OnResponse must short-circuit BEFORE any SSE byte is written.
227+
func TestForwardProxy_SSE_ResponsePhaseDenyShortCircuits(t *testing.T) {
228+
probe := &responseProbePlugin{deny: true}
229+
client, url := sseProxy(t, []pipeline.Plugin{probe})
230+
231+
req, _ := http.NewRequest("POST", url, bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)))
232+
req.Header.Set("Content-Type", "application/json")
233+
resp, err := client.Do(req)
234+
if err != nil {
235+
t.Fatalf("request failed: %v", err)
236+
}
237+
defer resp.Body.Close()
238+
body, _ := io.ReadAll(resp.Body)
239+
240+
if !probe.ranResp.Load() {
241+
t.Error("OnResponse did not run on the SSE response (RunResponse skipped)")
242+
}
243+
if resp.StatusCode != http.StatusForbidden {
244+
t.Errorf("status = %d, want 403 (response-phase deny not honored)", resp.StatusCode)
245+
}
246+
if strings.Contains(string(body), "event: message") {
247+
t.Errorf("SSE body was forwarded despite the deny: %q", body)
248+
}
249+
}
250+
251+
// TestForwardProxy_SSE_HeaderOnResponseRuns proves a non-denying, header-level
252+
// OnResponse (e.g. cost accounting) still fires on an SSE response and the
253+
// stream is delivered.
254+
func TestForwardProxy_SSE_HeaderOnResponseRuns(t *testing.T) {
255+
probe := &responseProbePlugin{deny: false}
256+
client, url := sseProxy(t, []pipeline.Plugin{probe})
257+
258+
req, _ := http.NewRequest("POST", url, bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)))
259+
req.Header.Set("Content-Type", "application/json")
260+
resp, err := client.Do(req)
261+
if err != nil {
262+
t.Fatalf("request failed: %v", err)
263+
}
264+
defer resp.Body.Close()
265+
body, _ := io.ReadAll(resp.Body)
266+
267+
if !probe.ranResp.Load() {
268+
t.Error("OnResponse did not run on the SSE response")
269+
}
270+
if resp.StatusCode != http.StatusOK {
271+
t.Errorf("status = %d, want 200", resp.StatusCode)
272+
}
273+
if !strings.Contains(string(body), "event: message") || !strings.Contains(string(body), "id: 7") {
274+
t.Errorf("SSE body not delivered verbatim: %q", body)
275+
}
276+
}
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+
}

0 commit comments

Comments
 (0)