detached d044953

fix(security): require Bearer prefix for admin API tokens and document auth-disabled behavior

commit
d044953e1809907c0447ccd5aa334a98c0b9a1c6
author
bnema · · 2026-02-01 14:32 UTC
parent
9da36c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +19 -16
M internal/adapters/in/http/admin/middleware.go +14 -8
@@ -51,30 +51,36 @@ func AuthMiddleware(
5151 }
5252 }
5353
54- // Check if auth is enabled
54+ // Check if auth is enabled.
55+ // DESIGN NOTE: When auth is disabled, the admin API is intentionally
56+ // accessible without credentials. This is the expected behavior for
57+ // local/development setups where Gordon is not exposed to the internet.
58+ // Users who disable auth accept this tradeoff explicitly in their config.
59+ // For production deployments, auth should always be enabled.
5560 if !authSvc.IsEnabled() {
56- // Auth disabled, log warning and allow all requests
5761 log.Warn().
5862 Str("path", r.URL.Path).
5963 Str("method", r.Method).
6064 Str("remote_addr", r.RemoteAddr).
61- Msg("auth disabled - allowing unauthenticated admin API access")
65+ Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)")
6266 next.ServeHTTP(w, r)
6367 return
6468 }
6569
66- // Extract token from Authorization header
70+ // Extract token from Authorization header.
71+ // SECURITY: Require the standard "Bearer" prefix per RFC 6750
72+ // to prevent accidental token exposure in non-standard formats.
6773 auth := r.Header.Get("Authorization")
6874 if auth == "" {
6975 sendUnauthorized(w, "missing authorization header")
7076 return
7177 }
7278
73- // Support both "Bearer <token>" and direct token
74- token := auth
75- if strings.HasPrefix(auth, "Bearer ") {
76- token = strings.TrimPrefix(auth, "Bearer ")
79+ if !strings.HasPrefix(auth, "Bearer ") {
80+ sendUnauthorized(w, "authorization header must use Bearer scheme")
81+ return
7782 }
83+ token := strings.TrimPrefix(auth, "Bearer ")
7884
7985 // Validate token
8086 claims, err := authSvc.ValidateToken(ctx, token)
@@ -51,30 +51,36 @@ func AuthMiddleware(
51 }51 }
52 }52 }
53 53
54- // Check if auth is enabled54+ // Check if auth is enabled.
55+ // DESIGN NOTE: When auth is disabled, the admin API is intentionally
56+ // accessible without credentials. This is the expected behavior for
57+ // local/development setups where Gordon is not exposed to the internet.
58+ // Users who disable auth accept this tradeoff explicitly in their config.
59+ // For production deployments, auth should always be enabled.
55 if !authSvc.IsEnabled() {60 if !authSvc.IsEnabled() {
56- // Auth disabled, log warning and allow all requests
57 log.Warn().61 log.Warn().
58 Str("path", r.URL.Path).62 Str("path", r.URL.Path).
59 Str("method", r.Method).63 Str("method", r.Method).
60 Str("remote_addr", r.RemoteAddr).64 Str("remote_addr", r.RemoteAddr).
61- Msg("auth disabled - allowing unauthenticated admin API access")65+ Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)")
62 next.ServeHTTP(w, r)66 next.ServeHTTP(w, r)
63 return67 return
64 }68 }
65 69
66- // Extract token from Authorization header70+ // Extract token from Authorization header.
71+ // SECURITY: Require the standard "Bearer" prefix per RFC 6750
72+ // to prevent accidental token exposure in non-standard formats.
67 auth := r.Header.Get("Authorization")73 auth := r.Header.Get("Authorization")
68 if auth == "" {74 if auth == "" {
69 sendUnauthorized(w, "missing authorization header")75 sendUnauthorized(w, "missing authorization header")
70 return76 return
71 }77 }
72 78
73- // Support both "Bearer <token>" and direct token79+ if !strings.HasPrefix(auth, "Bearer ") {
74- token := auth80+ sendUnauthorized(w, "authorization header must use Bearer scheme")
75- if strings.HasPrefix(auth, "Bearer ") {81+ return
76- token = strings.TrimPrefix(auth, "Bearer ")
77 }82 }
83+ token := strings.TrimPrefix(auth, "Bearer ")
78 84
79 // Validate token85 // Validate token
80 claims, err := authSvc.ValidateToken(ctx, token)86 claims, err := authSvc.ValidateToken(ctx, token)
M internal/adapters/in/http/admin/middleware_test.go +5 -8
@@ -261,7 +261,7 @@ func TestAuthMiddleware_Success(t *testing.T) {
261261 assert.Equal(t, "admin", GetSubject(capturedCtx))
262262 }
263263
264-func TestAuthMiddleware_DirectToken(t *testing.T) {
264+func TestAuthMiddleware_DirectToken_Rejected(t *testing.T) {
265265 authSvc := inmocks.NewMockAuthService(t)
266266 globalLimiter := outmocks.NewMockRateLimiter(t)
267267 ipLimiter := outmocks.NewMockRateLimiter(t)
@@ -269,11 +269,6 @@ func TestAuthMiddleware_DirectToken(t *testing.T) {
269269 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
270270 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
271271 authSvc.EXPECT().IsEnabled().Return(true)
272- // Token without "Bearer " prefix
273- authSvc.EXPECT().ValidateToken(mock.Anything, "direct-token").Return(&domain.TokenClaims{
274- Subject: "admin",
275- Scopes: []string{"admin:routes:read"},
276- }, nil)
277272
278273 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
279274 w.WriteHeader(http.StatusOK)
@@ -282,13 +277,15 @@ func TestAuthMiddleware_DirectToken(t *testing.T) {
282277 mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
283278 wrappedHandler := mw(handler)
284279
280+ // Token without "Bearer " prefix should be rejected (RFC 6750)
285281 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
286282 req.RemoteAddr = "192.168.1.100:12345"
287- req.Header.Set("Authorization", "direct-token") // No "Bearer " prefix
283+ req.Header.Set("Authorization", "direct-token")
288284 rec := httptest.NewRecorder()
289285 wrappedHandler.ServeHTTP(rec, req)
290286
291- assert.Equal(t, http.StatusOK, rec.Code)
287+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
288+ assert.Contains(t, rec.Body.String(), "Bearer scheme")
292289 }
293290
294291 func TestRequireScope(t *testing.T) {
@@ -261,7 +261,7 @@ func TestAuthMiddleware_Success(t *testing.T) {
261 assert.Equal(t, "admin", GetSubject(capturedCtx))261 assert.Equal(t, "admin", GetSubject(capturedCtx))
262 }262 }
263 263
264-func TestAuthMiddleware_DirectToken(t *testing.T) {264+func TestAuthMiddleware_DirectToken_Rejected(t *testing.T) {
265 authSvc := inmocks.NewMockAuthService(t)265 authSvc := inmocks.NewMockAuthService(t)
266 globalLimiter := outmocks.NewMockRateLimiter(t)266 globalLimiter := outmocks.NewMockRateLimiter(t)
267 ipLimiter := outmocks.NewMockRateLimiter(t)267 ipLimiter := outmocks.NewMockRateLimiter(t)
@@ -269,11 +269,6 @@ func TestAuthMiddleware_DirectToken(t *testing.T) {
269 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)269 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
270 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)270 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
271 authSvc.EXPECT().IsEnabled().Return(true)271 authSvc.EXPECT().IsEnabled().Return(true)
272- // Token without "Bearer " prefix
273- authSvc.EXPECT().ValidateToken(mock.Anything, "direct-token").Return(&domain.TokenClaims{
274- Subject: "admin",
275- Scopes: []string{"admin:routes:read"},
276- }, nil)
277 272
278 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {273 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
279 w.WriteHeader(http.StatusOK)274 w.WriteHeader(http.StatusOK)
@@ -282,13 +277,15 @@ func TestAuthMiddleware_DirectToken(t *testing.T) {
282 mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())277 mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
283 wrappedHandler := mw(handler)278 wrappedHandler := mw(handler)
284 279
280+ // Token without "Bearer " prefix should be rejected (RFC 6750)
285 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)281 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
286 req.RemoteAddr = "192.168.1.100:12345"282 req.RemoteAddr = "192.168.1.100:12345"
287- req.Header.Set("Authorization", "direct-token") // No "Bearer " prefix283+ req.Header.Set("Authorization", "direct-token")
288 rec := httptest.NewRecorder()284 rec := httptest.NewRecorder()
289 wrappedHandler.ServeHTTP(rec, req)285 wrappedHandler.ServeHTTP(rec, req)
290 286
291- assert.Equal(t, http.StatusOK, rec.Code)287+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
288+ assert.Contains(t, rec.Body.String(), "Bearer scheme")
292 }289 }
293 290
294 func TestRequireScope(t *testing.T) {291 func TestRequireScope(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close