diff --git a/internal/mcp/http.go b/internal/mcp/http.go new file mode 100644 index 0000000000000000000000000000000000000000..fdbbe33711b5bbbf6d0f82b152ad2a89bd226188 --- /dev/null +++ b/internal/mcp/http.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: LicenseRef-MutuaL-1.2 + +package mcp + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +func requireBearerToken(token string, next http.Handler) http.Handler { + if token == "" { + return next + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scheme, value, ok := strings.Cut(r.Header.Get("Authorization"), " ") + if !ok || !strings.EqualFold(scheme, "Bearer") || + subtle.ConstantTimeCompare([]byte(value), []byte(token)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/mcp/http_test.go b/internal/mcp/http_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3f4ab4214ea148de91c6b4fd6d1ad6a313f2b3e2 --- /dev/null +++ b/internal/mcp/http_test.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: LicenseRef-MutuaL-1.2 + +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// server.SECURITY.3 server.SECURITY.6 +func TestHTTPBearerAuthACIDServerSecurity3And6AllowsMatchingBearerToken(t *testing.T) { + calls := 0 + handler := requireBearerToken("secret-token", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.WriteHeader(http.StatusNoContent) + })) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil) + request.Header.Set("Authorization", "Bearer secret-token") + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent) + } + if calls != 1 { + t.Fatalf("next calls = %d, want 1", calls) + } +} + +// server.SECURITY.7 +func TestHTTPBearerAuthACIDServerSecurity7RejectsInvalidTokens(t *testing.T) { + tests := []struct { + name string + header string + }{ + {name: "missing", header: ""}, + {name: "wrong", header: "Bearer wrong-token"}, + {name: "basic", header: "Basic secret-token"}, + {name: "malformed", header: "Bearer"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + handler := requireBearerToken("secret-token", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + calls++ + })) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil) + if tt.header != "" { + request.Header.Set("Authorization", tt.header) + } + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized) + } + if got := recorder.Header().Get("WWW-Authenticate"); got != "Bearer" { + t.Fatalf("WWW-Authenticate = %q, want Bearer", got) + } + if calls != 0 { + t.Fatalf("next calls = %d, want 0", calls) + } + }) + } +} + +func TestHTTPBearerAuthAllowsRequestsWhenNoTokenConfigured(t *testing.T) { + calls := 0 + handler := requireBearerToken("", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.WriteHeader(http.StatusNoContent) + })) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil) + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent) + } + if calls != 1 { + t.Fatalf("next calls = %d, want 1", calls) + } +} + +// server.SECURITY.2 +func TestHTTPBearerAuthACIDServerSecurity2DoesNotExposeTokensInUnauthorizedResponse(t *testing.T) { + handler := requireBearerToken("secret-token", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("next handler was called") + })) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", nil) + request.Header.Set("Authorization", "Bearer submitted-token") + handler.ServeHTTP(recorder, request) + + body := recorder.Body.String() + if strings.Contains(body, "secret-token") { + t.Fatalf("response body exposed configured token: %q", body) + } + if strings.Contains(body, "submitted-token") { + t.Fatalf("response body exposed submitted token: %q", body) + } +}