Skip to content

Commit c45ccdc

Browse files
committed
Reject misrouted gateway credentials
1 parent 84c6d43 commit c45ccdc

3 files changed

Lines changed: 108 additions & 1 deletion

File tree

internal/proxy/api_key_gateway.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,66 @@ type apiKeyGatewaySpec struct {
4545
blockedAPIKeyPrefixes []string
4646
}
4747

48+
func (s Server) rejectMisroutedGatewayCredentials(next http.Handler) http.Handler {
49+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+
if s.requestUsesConfiguredGatewayCredential(r) {
51+
http.Error(w, "gateway credential used outside its gateway route", http.StatusBadRequest)
52+
return
53+
}
54+
next.ServeHTTP(w, r)
55+
})
56+
}
57+
58+
func (s Server) requestUsesConfiguredGatewayCredential(r *http.Request) bool {
59+
if r == nil {
60+
return false
61+
}
62+
var configured []string
63+
if s.Gemini != nil {
64+
configured = append(configured, strings.TrimSpace(s.Gemini.GatewayToken))
65+
}
66+
if s.AnthropicGateway != nil {
67+
configured = append(configured, strings.TrimSpace(s.AnthropicGateway.GatewayToken))
68+
}
69+
if s.OpenAIGateway != nil {
70+
configured = append(configured, strings.TrimSpace(s.OpenAIGateway.GatewayToken))
71+
}
72+
if s.Bedrock != nil {
73+
configured = append(configured, strings.TrimSpace(s.Bedrock.GatewayToken))
74+
}
75+
var presented []string
76+
presented = append(presented, r.Header.Values("X-Goog-Api-Key")...)
77+
presented = append(presented, r.Header.Values("X-Api-Key")...)
78+
for _, header := range r.Header.Values("Authorization") {
79+
if scheme, value, ok := strings.Cut(strings.TrimSpace(header), " "); ok && strings.EqualFold(scheme, "Bearer") {
80+
presented = append(presented, strings.TrimSpace(value))
81+
}
82+
}
83+
for _, value := range r.Header.Values("Sec-WebSocket-Protocol") {
84+
for _, protocol := range strings.Split(value, ",") {
85+
protocol = strings.TrimSpace(protocol)
86+
if strings.HasPrefix(protocol, openAIWebSocketCredentialPrefix) {
87+
presented = append(presented, strings.TrimPrefix(protocol, openAIWebSocketCredentialPrefix))
88+
}
89+
}
90+
}
91+
query := r.URL.Query()
92+
presented = append(presented, query["key"]...)
93+
presented = append(presented, query["api_key"]...)
94+
for _, candidate := range presented {
95+
candidate = strings.TrimSpace(candidate)
96+
if candidate == "" {
97+
continue
98+
}
99+
for _, token := range configured {
100+
if len(candidate) == len(token) && token != "" && subtle.ConstantTimeCompare([]byte(candidate), []byte(token)) == 1 {
101+
return true
102+
}
103+
}
104+
}
105+
return false
106+
}
107+
48108
func (s Server) apiKeyGatewayHandler(config *APIKeyGatewayConfig, spec apiKeyGatewaySpec) http.Handler {
49109
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50110
if config == nil || !config.configured() {

internal/proxy/api_key_gateway_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,3 +666,50 @@ func TestAPIKeyGatewayRoutesDoNotCaptureRootProxyPaths(t *testing.T) {
666666
t.Fatalf("gateway upstream requests = %d", got)
667667
}
668668
}
669+
670+
func TestRootProxyRejectsMisroutedGatewayCredentials(t *testing.T) {
671+
t.Parallel()
672+
673+
var rootRequests atomic.Int32
674+
root := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
675+
rootRequests.Add(1)
676+
}))
677+
defer root.Close()
678+
rootURL, err := url.Parse(root.URL)
679+
if err != nil {
680+
t.Fatal(err)
681+
}
682+
handler := Server{
683+
Upstream: rootURL,
684+
Gemini: &GeminiConfig{GatewayToken: "gemini-team"},
685+
AnthropicGateway: &APIKeyGatewayConfig{GatewayToken: "anthropic-team"},
686+
OpenAIGateway: &APIKeyGatewayConfig{GatewayToken: "openai-team"},
687+
}.Handler()
688+
tests := []struct {
689+
name string
690+
path string
691+
set func(http.Header)
692+
}{
693+
{"gemini header", "/v1/models", func(h http.Header) { h.Set("X-Goog-Api-Key", "gemini-team") }},
694+
{"gemini query", "/v1alpha/models?key=gemini-team", func(http.Header) {}},
695+
{"anthropic header", "/v1/messages", func(h http.Header) { h.Set("X-Api-Key", "anthropic-team") }},
696+
{"openai bearer", "/v1/responses", func(h http.Header) { h.Set("Authorization", "Bearer openai-team") }},
697+
{"openai websocket", "/v1/realtime", func(h http.Header) {
698+
h.Set("Sec-WebSocket-Protocol", "realtime, openai-insecure-api-key.openai-team")
699+
}},
700+
}
701+
for _, test := range tests {
702+
t.Run(test.name, func(t *testing.T) {
703+
req := httptest.NewRequest(http.MethodPost, test.path, nil)
704+
test.set(req.Header)
705+
rec := httptest.NewRecorder()
706+
handler.ServeHTTP(rec, req)
707+
if rec.Code != http.StatusBadRequest {
708+
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
709+
}
710+
})
711+
}
712+
if got := rootRequests.Load(); got != 0 {
713+
t.Fatalf("misrouted credentials reached root upstream: requests = %d", got)
714+
}
715+
}

internal/proxy/proxy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -951,7 +951,7 @@ func (s Server) Handler() http.Handler {
951951
mux.Handle("/api/v1/", openAIHandler)
952952
mux.Handle("/openai", openAIHandler)
953953
mux.Handle("/openai/", openAIHandler)
954-
mux.Handle("/", s.proxyHandler())
954+
mux.Handle("/", s.rejectMisroutedGatewayCredentials(s.proxyHandler()))
955955
return mux
956956
}
957957

0 commit comments

Comments
 (0)