Skip to content

Commit b33f840

Browse files
authored
fix SSE GET panic on HTTP-only backends (#102)
* fix SSE GET panic on HTTP-only backends by adding an http-streaming-only mode that returns HTTP 405 * Implemented Copilot’s feedback: - isSSEGetRequest now strips media type parameters before matching, so headers like text/event-stream; charset=utf-8 or with q-values are handled. - Expanded TestProxyRouter_HTTPStreamingOnlyRejectsSSE to cover parameterized Accept headers, multiple values, q-values, and to ensure POST + Accept: text/event-stream still passes through to the backend. * Improve SSE rejection messaging and coverage * add more test coverage
1 parent 3cccd04 commit b33f840

7 files changed

Lines changed: 388 additions & 14 deletions

File tree

docs/docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ You can use both exact matching and glob patterns for OIDC user authorization:
121121
| ---------------------- | -------------------- | ------- | ----------------------------------------------------------------------------------------------------- |
122122
| `--proxy-bearer-token` | `PROXY_BEARER_TOKEN` | - | Bearer token to add to Authorization header when proxying requests |
123123
| `--proxy-headers` | `PROXY_HEADERS` | - | Comma-separated list of headers to add when proxying requests (format: Header1:Value1,Header2:Value2) |
124+
| `--http-streaming-only` | `HTTP_STREAMING_ONLY` | `false` | Reject SSE (GET) requests and keep the backend operating in HTTP streaming-only mode |
124125
| `--trusted-proxies` | `TRUSTED_PROXIES` | - | Comma-separated list of trusted proxies (IP addresses or CIDR ranges) |
125126

126127
For practical configuration examples including environment variables, Docker Compose, and Kubernetes deployments, see the [Configuration Examples](./examples.md) page.

main.go

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,52 @@ func splitWithEscapes(s, delimiter string) []string {
6464
return result
6565
}
6666

67+
type proxyRunnerFunc func(
68+
listen string,
69+
tlsListen string,
70+
autoTLS bool,
71+
tlsHost string,
72+
tlsDirectoryURL string,
73+
tlsAcceptTOS bool,
74+
tlsCertFile string,
75+
tlsKeyFile string,
76+
dataPath string,
77+
repositoryBackend string,
78+
repositoryDSN string,
79+
externalURL string,
80+
googleClientID string,
81+
googleClientSecret string,
82+
googleAllowedUsers []string,
83+
googleAllowedWorkspaces []string,
84+
githubClientID string,
85+
githubClientSecret string,
86+
githubAllowedUsers []string,
87+
githubAllowedOrgs []string,
88+
oidcConfigurationURL string,
89+
oidcClientID string,
90+
oidcClientSecret string,
91+
oidcScopes []string,
92+
oidcUserIDField string,
93+
oidcProviderName string,
94+
oidcAllowedUsers []string,
95+
oidcAllowedUsersGlob []string,
96+
noProviderAutoSelect bool,
97+
password string,
98+
passwordHash string,
99+
trustedProxy []string,
100+
proxyHeaders []string,
101+
proxyBearerToken string,
102+
proxyTarget []string,
103+
httpStreamingOnly bool,
104+
) error
105+
67106
func main() {
107+
if err := newRootCommand(mcpproxy.Run).Execute(); err != nil {
108+
panic(err)
109+
}
110+
}
111+
112+
func newRootCommand(run proxyRunnerFunc) *cobra.Command {
68113
var listen string
69114
var tlsListen string
70115
var noAutoTLS bool
@@ -98,6 +143,7 @@ func main() {
98143
var passwordHash string
99144
var proxyBearerToken string
100145
var proxyHeaders string
146+
var httpStreamingOnly bool
101147
var trustedProxies string
102148

103149
rootCmd := &cobra.Command{
@@ -175,7 +221,7 @@ func main() {
175221
}
176222
}
177223

178-
if err := mcpproxy.Run(
224+
if err := run(
179225
listen,
180226
tlsListen,
181227
(!noAutoTLS) || tlsCertFile != "" || tlsKeyFile != "",
@@ -211,6 +257,7 @@ func main() {
211257
proxyHeadersList,
212258
proxyBearerToken,
213259
args,
260+
httpStreamingOnly,
214261
); err != nil {
215262
panic(err)
216263
}
@@ -261,8 +308,7 @@ func main() {
261308
rootCmd.Flags().StringVar(&proxyBearerToken, "proxy-bearer-token", getEnvWithDefault("PROXY_BEARER_TOKEN", ""), "Bearer token to add to Authorization header when proxying requests")
262309
rootCmd.Flags().StringVar(&trustedProxies, "trusted-proxies", getEnvWithDefault("TRUSTED_PROXIES", ""), "Comma-separated list of trusted proxies (IP addresses or CIDR ranges)")
263310
rootCmd.Flags().StringVar(&proxyHeaders, "proxy-headers", getEnvWithDefault("PROXY_HEADERS", ""), "Comma-separated list of headers to add when proxying requests (format: Header1:Value1,Header2:Value2)")
311+
rootCmd.Flags().BoolVar(&httpStreamingOnly, "http-streaming-only", getEnvBoolWithDefault("HTTP_STREAMING_ONLY", false), "Reject SSE (GET) requests and keep the backend in HTTP streaming-only mode")
264312

265-
if err := rootCmd.Execute(); err != nil {
266-
panic(err)
267-
}
313+
return rootCmd
268314
}

main_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,122 @@ func TestGetEnvBoolWithDefault(t *testing.T) {
217217
})
218218
}
219219
}
220+
221+
func TestNewRootCommand_HTTPStreamingOnlyFlag(t *testing.T) {
222+
t.Setenv("HTTP_STREAMING_ONLY", "")
223+
224+
var streamingOnly bool
225+
var receivedTargets []string
226+
runner := proxyRunnerFunc(func(listen string,
227+
tlsListen string,
228+
autoTLS bool,
229+
tlsHost string,
230+
tlsDirectoryURL string,
231+
tlsAcceptTOS bool,
232+
tlsCertFile string,
233+
tlsKeyFile string,
234+
dataPath string,
235+
repositoryBackend string,
236+
repositoryDSN string,
237+
externalURL string,
238+
googleClientID string,
239+
googleClientSecret string,
240+
googleAllowedUsers []string,
241+
googleAllowedWorkspaces []string,
242+
githubClientID string,
243+
githubClientSecret string,
244+
githubAllowedUsers []string,
245+
githubAllowedOrgs []string,
246+
oidcConfigurationURL string,
247+
oidcClientID string,
248+
oidcClientSecret string,
249+
oidcScopes []string,
250+
oidcUserIDField string,
251+
oidcProviderName string,
252+
oidcAllowedUsers []string,
253+
oidcAllowedUsersGlob []string,
254+
noProviderAutoSelect bool,
255+
password string,
256+
passwordHash string,
257+
trustedProxy []string,
258+
proxyHeaders []string,
259+
proxyBearerToken string,
260+
proxyTarget []string,
261+
httpStreamingOnly bool,
262+
) error {
263+
streamingOnly = httpStreamingOnly
264+
receivedTargets = proxyTarget
265+
return nil
266+
})
267+
268+
cmd := newRootCommand(runner)
269+
cmd.SetArgs([]string{"--http-streaming-only", "http://backend"})
270+
271+
if err := cmd.Execute(); err != nil {
272+
t.Fatalf("expected command to succeed, got error: %v", err)
273+
}
274+
275+
if !streamingOnly {
276+
t.Fatalf("expected httpStreamingOnly to be true when flag is set")
277+
}
278+
if len(receivedTargets) != 1 || receivedTargets[0] != "http://backend" {
279+
t.Fatalf("expected proxyTarget to receive CLI args, got %v", receivedTargets)
280+
}
281+
}
282+
283+
func TestNewRootCommand_HTTPStreamingOnlyFromEnv(t *testing.T) {
284+
t.Setenv("HTTP_STREAMING_ONLY", "true")
285+
286+
var streamingOnly bool
287+
runner := proxyRunnerFunc(func(listen string,
288+
tlsListen string,
289+
autoTLS bool,
290+
tlsHost string,
291+
tlsDirectoryURL string,
292+
tlsAcceptTOS bool,
293+
tlsCertFile string,
294+
tlsKeyFile string,
295+
dataPath string,
296+
repositoryBackend string,
297+
repositoryDSN string,
298+
externalURL string,
299+
googleClientID string,
300+
googleClientSecret string,
301+
googleAllowedUsers []string,
302+
googleAllowedWorkspaces []string,
303+
githubClientID string,
304+
githubClientSecret string,
305+
githubAllowedUsers []string,
306+
githubAllowedOrgs []string,
307+
oidcConfigurationURL string,
308+
oidcClientID string,
309+
oidcClientSecret string,
310+
oidcScopes []string,
311+
oidcUserIDField string,
312+
oidcProviderName string,
313+
oidcAllowedUsers []string,
314+
oidcAllowedUsersGlob []string,
315+
noProviderAutoSelect bool,
316+
password string,
317+
passwordHash string,
318+
trustedProxy []string,
319+
proxyHeaders []string,
320+
proxyBearerToken string,
321+
proxyTarget []string,
322+
httpStreamingOnly bool,
323+
) error {
324+
streamingOnly = httpStreamingOnly
325+
return nil
326+
})
327+
328+
cmd := newRootCommand(runner)
329+
cmd.SetArgs([]string{"http://backend"})
330+
331+
if err := cmd.Execute(); err != nil {
332+
t.Fatalf("expected command to succeed, got error: %v", err)
333+
}
334+
335+
if !streamingOnly {
336+
t.Fatalf("expected httpStreamingOnly to default to true from env var")
337+
}
338+
}

pkg/mcp-proxy/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import (
3434

3535
var ServerShutdownTimeout = 5 * time.Second
3636

37+
var newProxyRouter = proxy.NewProxyRouter
38+
3739
func Run(
3840
listen string,
3941
tlsListen string,
@@ -70,6 +72,7 @@ func Run(
7072
proxyHeaders []string,
7173
proxyBearerToken string,
7274
proxyTarget []string,
75+
httpStreamingOnly bool,
7376
) error {
7477
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
7578
defer stop()
@@ -265,7 +268,7 @@ func Run(
265268
if err != nil {
266269
return fmt.Errorf("failed to create IDP router: %w", err)
267270
}
268-
proxyRouter, err := proxy.NewProxyRouter(externalURL, beHandler, &privKey.PublicKey, proxyHeadersMap)
271+
proxyRouter, err := newProxyRouter(externalURL, beHandler, &privKey.PublicKey, proxyHeadersMap, httpStreamingOnly)
269272
if err != nil {
270273
return fmt.Errorf("failed to create proxy router: %w", err)
271274
}

pkg/mcp-proxy/main_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package mcpproxy
2+
3+
import (
4+
"crypto/rsa"
5+
"errors"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/sigbit/mcp-auth-proxy/pkg/proxy"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestRun_PassesHTTPStreamingOnlyToProxyRouter(t *testing.T) {
14+
originalNewProxyRouter := newProxyRouter
15+
t.Cleanup(func() {
16+
newProxyRouter = originalNewProxyRouter
17+
})
18+
19+
var streamingOnlyReceived bool
20+
newProxyRouter = func(externalURL string, proxyHandler http.Handler, publicKey *rsa.PublicKey, proxyHeaders http.Header, httpStreamingOnly bool) (*proxy.ProxyRouter, error) {
21+
streamingOnlyReceived = httpStreamingOnly
22+
return nil, errors.New("proxy router init failed")
23+
}
24+
25+
err := Run(
26+
":0",
27+
":0",
28+
false,
29+
"",
30+
"",
31+
false,
32+
"",
33+
"",
34+
t.TempDir(),
35+
"local",
36+
"",
37+
"http://localhost",
38+
"",
39+
"",
40+
nil,
41+
nil,
42+
"",
43+
"",
44+
nil,
45+
nil,
46+
"",
47+
"",
48+
"",
49+
nil,
50+
"",
51+
"",
52+
nil,
53+
nil,
54+
false,
55+
"",
56+
"",
57+
nil,
58+
nil,
59+
"",
60+
[]string{"http://example.com"},
61+
true,
62+
)
63+
64+
require.Error(t, err)
65+
require.Contains(t, err.Error(), "failed to create proxy router")
66+
require.True(t, streamingOnlyReceived, "httpStreamingOnly should be forwarded to proxy router")
67+
}

pkg/proxy/proxy.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,26 @@ import (
1111
)
1212

1313
type ProxyRouter struct {
14-
externalURL string
15-
proxy http.Handler
16-
publicKey *rsa.PublicKey
17-
proxyHeaders http.Header
14+
externalURL string
15+
proxy http.Handler
16+
publicKey *rsa.PublicKey
17+
proxyHeaders http.Header
18+
httpStreamingOnly bool
1819
}
1920

2021
func NewProxyRouter(
2122
externalURL string,
2223
proxy http.Handler,
2324
publicKey *rsa.PublicKey,
2425
proxyHeaders http.Header,
26+
httpStreamingOnly bool,
2527
) (*ProxyRouter, error) {
2628
return &ProxyRouter{
27-
externalURL: externalURL,
28-
proxy: proxy,
29-
publicKey: publicKey,
30-
proxyHeaders: proxyHeaders,
29+
externalURL: externalURL,
30+
proxy: proxy,
31+
publicKey: publicKey,
32+
proxyHeaders: proxyHeaders,
33+
httpStreamingOnly: httpStreamingOnly,
3134
}, nil
3235
}
3336

@@ -72,6 +75,11 @@ func (p *ProxyRouter) handleProxy(c *gin.Context) {
7275
return
7376
}
7477

78+
if p.httpStreamingOnly && isSSEGetRequest(c.Request) {
79+
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"error": "SSE (GET) streaming is not supported by this backend; use POST-based HTTP streaming instead"})
80+
return
81+
}
82+
7583
c.Request.Header.Del("Authorization")
7684
for key, values := range p.proxyHeaders {
7785
for _, value := range values {
@@ -81,3 +89,23 @@ func (p *ProxyRouter) handleProxy(c *gin.Context) {
8189

8290
p.proxy.ServeHTTP(c.Writer, c.Request)
8391
}
92+
93+
func isSSEGetRequest(r *http.Request) bool {
94+
if r.Method != http.MethodGet {
95+
return false
96+
}
97+
accept := r.Header.Get("Accept")
98+
if accept == "" {
99+
return false
100+
}
101+
for _, value := range strings.Split(accept, ",") {
102+
mediaType := strings.TrimSpace(strings.ToLower(value))
103+
if idx := strings.Index(mediaType, ";"); idx != -1 {
104+
mediaType = strings.TrimSpace(mediaType[:idx])
105+
}
106+
if mediaType == "text/event-stream" {
107+
return true
108+
}
109+
}
110+
return false
111+
}

0 commit comments

Comments
 (0)