Skip to content

Commit a2ac978

Browse files
fix(httpapi): match the Bearer auth scheme case-insensitively (#1754)
## Bug The REST auth middleware only recognises an `Authorization` header that starts with the exact bytes `Bearer `. The auth-scheme name is case-insensitive (RFC 7235 §2.1, RFC 6750 §2.1), so a client or proxy that sends `bearer <key>` or `BEARER <key>` is rejected with `401 Invalid or missing API key` even though the key is valid. Because precedence is decided by header *membership*, the request never falls back to `?apikey=` or the session cookie either. ## Trigger ``` curl -H 'Authorization: bearer <api-key>' http://127.0.0.1:8080/api/v1/status # 401 curl -H 'Authorization: Bearer <api-key>' http://127.0.0.1:8080/api/v1/status # 200 ``` ## Fix Add a small `bearerToken(header)` helper in `internal/httpapi` that compares the scheme with `strings.EqualFold`, and use it at the three places that parse the header: `authenticateBearer`, `ExtractToken`, and `sessionCredentialValue` (SSE re-check of a bearer JWT). The last one has to match the first, otherwise a JWT accepted with a lowercase scheme would read back as empty during the `/events` scope check. ## Test `TestBearerScheme_CaseInsensitive` sends the admin key with `Bearer`, `bearer` and `BEARER` through `ExtractToken` and through the full `ServeHTTP` middleware, and checks that `Basic` still yields no token. ## Commands run - `CGO_ENABLED=0 go test -count=1 ./internal/httpapi` — ok - `CGO_ENABLED=0 go vet ./internal/httpapi` — clean - With the fix reverted (`git stash` of `server.go` and `sse_scope.go`): `go test -count=1 -run TestBearerScheme ./internal/httpapi` — FAIL (the `bearer` and `BEARER` subtests: token mismatch and 401) - `gofmt -l` on the edited files — no output Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9c24b4e commit a2ac978

3 files changed

Lines changed: 50 additions & 15 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package httpapi
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"go.uber.org/zap"
10+
11+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
12+
)
13+
14+
// The Authorization scheme name is case-insensitive (RFC 7235 §2.1), so a
15+
// client that sends "bearer <key>" must be treated like "Bearer <key>".
16+
func TestBearerScheme_CaseInsensitive(t *testing.T) {
17+
const apiKey = "admin-key-12345"
18+
19+
for _, scheme := range []string{"Bearer", "bearer", "BEARER"} {
20+
t.Run(scheme, func(t *testing.T) {
21+
req := httptest.NewRequest("GET", "/api/v1/status", nil)
22+
req.Header.Set("Authorization", scheme+" "+apiKey)
23+
assert.Equal(t, apiKey, ExtractToken(req))
24+
25+
srv := NewServer(&testControllerWithConfig{cfg: &config.Config{APIKey: apiKey}}, zap.NewNop().Sugar(), nil)
26+
w := httptest.NewRecorder()
27+
srv.ServeHTTP(w, req)
28+
assert.Equal(t, http.StatusOK, w.Code, "admin key sent with %q scheme should authenticate", scheme)
29+
})
30+
}
31+
32+
req := httptest.NewRequest("GET", "/api/v1/status", nil)
33+
req.Header.Set("Authorization", "Basic "+apiKey)
34+
assert.Equal(t, "", ExtractToken(req), "non-Bearer schemes must not yield a token")
35+
}

‎internal/httpapi/server.go‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -679,10 +679,7 @@ func (s *Server) authenticateExplicitToken(w http.ResponseWriter, r *http.Reques
679679
// global admin key, or a user JWT resolved as a session principal
680680
// (kind=bearer_jwt) through the edition hook.
681681
func (s *Server) authenticateBearer(w http.ResponseWriter, r *http.Request, next http.Handler, cfg *config.Config, authHeader string) {
682-
var token string
683-
if strings.HasPrefix(authHeader, "Bearer ") {
684-
token = strings.TrimPrefix(authHeader, "Bearer ")
685-
}
682+
token := bearerToken(authHeader)
686683

687684
if token != "" && strings.HasPrefix(token, auth.ClientTokenPrefixStr) {
688685
s.rejectClientCredentialOnREST(w, r)
@@ -813,12 +810,8 @@ func ExtractToken(r *http.Request) string {
813810
}
814811

815812
// 2. Check Authorization: Bearer header
816-
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
817-
if strings.HasPrefix(authHeader, "Bearer ") {
818-
if token := strings.TrimPrefix(authHeader, "Bearer "); token != "" {
819-
return token
820-
}
821-
}
813+
if token := bearerToken(r.Header.Get("Authorization")); token != "" {
814+
return token
822815
}
823816

824817
// 3. Check query parameter (for SSE and Web UI initial load)
@@ -829,6 +822,17 @@ func ExtractToken(r *http.Request) string {
829822
return ""
830823
}
831824

825+
// bearerToken returns the credential from an Authorization header value that
826+
// uses the Bearer scheme, or "" otherwise. The scheme name is matched
827+
// case-insensitively (RFC 7235 §2.1, RFC 6750 §2.1).
828+
func bearerToken(authHeader string) string {
829+
const scheme = "Bearer "
830+
if len(authHeader) < len(scheme) || !strings.EqualFold(authHeader[:len(scheme)], scheme) {
831+
return ""
832+
}
833+
return authHeader[len(scheme):]
834+
}
835+
832836
// correlationIDMiddleware injects correlation ID and request source into context
833837
func (s *Server) correlationIDMiddleware() func(http.Handler) http.Handler {
834838
return func(next http.Handler) http.Handler {

‎internal/httpapi/sse_scope.go‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,7 @@ func sessionCredentialValue(r *http.Request, kind auth.CredentialKind) string {
8383
}
8484
return ""
8585
case auth.CredentialKindBearerJWT:
86-
authHeader := r.Header.Get("Authorization")
87-
if len(authHeader) > len("Bearer ") && authHeader[:7] == "Bearer " {
88-
return authHeader[7:]
89-
}
90-
return ""
86+
return bearerToken(r.Header.Get("Authorization"))
9187
default:
9288
return ""
9389
}

0 commit comments

Comments
 (0)