detached 18fc433

fix(security): enforce always-on auth and block scope/IP escalation

commit
18fc433c7f7ccbb51ff06f9e1589fa83dfe73009
author
bnema · · 2026-02-09 04:30 UTC
parent
21424aa
signature
unsigned
9 files · +287 -72
M docs/config/auth.md +3 -10
@@ -10,7 +10,6 @@ Authentication is enabled by default. Gordon will not start without a valid `tok
1010
1111 ```toml
1212 [auth]
13-enabled = true
1413 secrets_backend = "pass"
1514 token_secret = "gordon/auth/token_secret"
1615 token_expiry = "30d"
@@ -20,7 +19,6 @@ token_expiry = "30d"
2019
2120 ```toml
2221 [auth]
23-enabled = true
2422 secrets_backend = "pass"
2523 username = "deploy"
2624 password_hash = "gordon/auth/password_hash"
@@ -33,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive
3331
3432 | Option | Type | Default | Description |
3533 |--------|------|---------|-------------|
36-| `enabled` | bool | `true` | Enable authentication |
34+| `enabled` | bool | `true` | Deprecated. Authentication is always required; setting `false` is rejected |
3735 | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
3836 | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend |
3937 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
@@ -154,14 +152,9 @@ gordon auth token generate --subject temp --expiry 30d
154152 - Tokens from one Gordon instance won't work on another
155153 - Changing `token_secret` invalidates all existing tokens
156154
157-## Disable Auth (development only)
155+## Authentication Required
158156
159-```toml
160-[auth]
161-enabled = false
162-```
163-
164-> **Security Warning:** When authentication is disabled, the admin API is fully accessible without credentials. This includes endpoints to deploy containers, manage secrets, modify routes, and reload configuration. Only disable auth for local development where Gordon is not exposed to the internet.
157+Authentication is always required. `auth.enabled=false` is no longer supported.
165158
166159 ## Internal Registry Auth
167160
@@ -10,7 +10,6 @@ Authentication is enabled by default. Gordon will not start without a valid `tok
10 10
11 ```toml11 ```toml
12 [auth]12 [auth]
13-enabled = true
14 secrets_backend = "pass"13 secrets_backend = "pass"
15 token_secret = "gordon/auth/token_secret"14 token_secret = "gordon/auth/token_secret"
16 token_expiry = "30d"15 token_expiry = "30d"
@@ -20,7 +19,6 @@ token_expiry = "30d"
20 19
21 ```toml20 ```toml
22 [auth]21 [auth]
23-enabled = true
24 secrets_backend = "pass"22 secrets_backend = "pass"
25 username = "deploy"23 username = "deploy"
26 password_hash = "gordon/auth/password_hash"24 password_hash = "gordon/auth/password_hash"
@@ -33,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive
33 31
34 | Option | Type | Default | Description |32 | Option | Type | Default | Description |
35 |--------|------|---------|-------------|33 |--------|------|---------|-------------|
36-| `enabled` | bool | `true` | Enable authentication |34+| `enabled` | bool | `true` | Deprecated. Authentication is always required; setting `false` is rejected |
37 | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |35 | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
38 | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend |36 | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend |
39 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |37 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
@@ -154,14 +152,9 @@ gordon auth token generate --subject temp --expiry 30d
154 - Tokens from one Gordon instance won't work on another152 - Tokens from one Gordon instance won't work on another
155 - Changing `token_secret` invalidates all existing tokens153 - Changing `token_secret` invalidates all existing tokens
156 154
157-## Disable Auth (development only)155+## Authentication Required
158 156
159-```toml157+Authentication is always required. `auth.enabled=false` is no longer supported.
160-[auth]
161-enabled = false
162-```
163-
164-> **Security Warning:** When authentication is disabled, the admin API is fully accessible without credentials. This includes endpoints to deploy containers, manage secrets, modify routes, and reload configuration. Only disable auth for local development where Gordon is not exposed to the internet.
165 158
166 ## Internal Registry Auth159 ## Internal Registry Auth
167 160
M docs/config/server.md +4 -0
@@ -103,6 +103,10 @@ This domain is used for:
103103
104104 When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API).
105105
106+Security note:
107+- Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients.
108+- Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`).
109+
106110 > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility.
107111
108112 ## Data Directory
@@ -103,6 +103,10 @@ This domain is used for:
103 103
104 When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API).104 When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API).
105 105
106+Security note:
107+- Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients.
108+- Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`).
109+
106 > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility.110 > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility.
107 111
108 ## Data Directory112 ## Data Directory
M internal/adapters/in/http/admin/middleware.go +5 -9
@@ -51,19 +51,15 @@ func AuthMiddleware(
5151 }
5252 }
5353
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.
54+ // Authentication is always required for admin APIs.
55+ // Fail closed if auth is disabled or misconfigured.
6056 if !authSvc.IsEnabled() {
61- log.Warn().
57+ log.Error().
6258 Str("path", r.URL.Path).
6359 Str("method", r.Method).
6460 Str("remote_addr", r.RemoteAddr).
65- Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)")
66- next.ServeHTTP(w, r)
61+ Msg("authentication disabled/misconfigured - denying admin API request")
62+ sendUnauthorized(w, "authentication is required")
6763 return
6864 }
6965
@@ -51,19 +51,15 @@ func AuthMiddleware(
51 }51 }
52 }52 }
53 53
54- // Check if auth is enabled.54+ // Authentication is always required for admin APIs.
55- // DESIGN NOTE: When auth is disabled, the admin API is intentionally55+ // Fail closed if auth is disabled or misconfigured.
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.
60 if !authSvc.IsEnabled() {56 if !authSvc.IsEnabled() {
61- log.Warn().57+ log.Error().
62 Str("path", r.URL.Path).58 Str("path", r.URL.Path).
63 Str("method", r.Method).59 Str("method", r.Method).
64 Str("remote_addr", r.RemoteAddr).60 Str("remote_addr", r.RemoteAddr).
65- Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)")61+ Msg("authentication disabled/misconfigured - denying admin API request")
66- next.ServeHTTP(w, r)62+ sendUnauthorized(w, "authentication is required")
67 return63 return
68 }64 }
69 65
M internal/adapters/in/http/admin/middleware_test.go +15 -5
@@ -78,7 +78,11 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
7878
7979 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
8080 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
81- authSvc.EXPECT().IsEnabled().Return(false)
81+ authSvc.EXPECT().IsEnabled().Return(true)
82+ authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
83+ Subject: "admin",
84+ Scopes: []string{"admin:*:*"},
85+ }, nil)
8286
8387 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
8488 w.WriteHeader(http.StatusOK)
@@ -90,6 +94,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
9094 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
9195 req.RemoteAddr = "127.0.0.1:12345"
9296 req.Header.Set("X-Forwarded-For", "203.0.113.50")
97+ req.Header.Set("Authorization", "Bearer valid-admin-token")
9398 rec := httptest.NewRecorder()
9499 wrappedHandler.ServeHTTP(rec, req)
95100
@@ -99,8 +104,12 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
99104 func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
100105 authSvc := inmocks.NewMockAuthService(t)
101106
102- // Auth disabled, no rate limiters
103- authSvc.EXPECT().IsEnabled().Return(false)
107+ // No rate limiters, auth still required
108+ authSvc.EXPECT().IsEnabled().Return(true)
109+ authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
110+ Subject: "admin",
111+ Scopes: []string{"admin:*:*"},
112+ }, nil)
104113
105114 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106115 w.WriteHeader(http.StatusOK)
@@ -110,6 +119,7 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
110119 wrappedHandler := mw(handler)
111120
112121 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
122+ req.Header.Set("Authorization", "Bearer valid-admin-token")
113123 rec := httptest.NewRecorder()
114124 wrappedHandler.ServeHTTP(rec, req)
115125
@@ -139,8 +149,8 @@ func TestAuthMiddleware_AuthDisabled(t *testing.T) {
139149 rec := httptest.NewRecorder()
140150 wrappedHandler.ServeHTTP(rec, req)
141151
142- assert.Equal(t, http.StatusOK, rec.Code)
143- assert.True(t, handlerCalled)
152+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
153+ assert.False(t, handlerCalled)
144154 }
145155
146156 func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
@@ -78,7 +78,11 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
78 78
79 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)79 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
80 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)80 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
81- authSvc.EXPECT().IsEnabled().Return(false)81+ authSvc.EXPECT().IsEnabled().Return(true)
82+ authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
83+ Subject: "admin",
84+ Scopes: []string{"admin:*:*"},
85+ }, nil)
82 86
83 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {87 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
84 w.WriteHeader(http.StatusOK)88 w.WriteHeader(http.StatusOK)
@@ -90,6 +94,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
90 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)94 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
91 req.RemoteAddr = "127.0.0.1:12345"95 req.RemoteAddr = "127.0.0.1:12345"
92 req.Header.Set("X-Forwarded-For", "203.0.113.50")96 req.Header.Set("X-Forwarded-For", "203.0.113.50")
97+ req.Header.Set("Authorization", "Bearer valid-admin-token")
93 rec := httptest.NewRecorder()98 rec := httptest.NewRecorder()
94 wrappedHandler.ServeHTTP(rec, req)99 wrappedHandler.ServeHTTP(rec, req)
95 100
@@ -99,8 +104,12 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) {
99 func TestAuthMiddleware_NoRateLimiting(t *testing.T) {104 func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
100 authSvc := inmocks.NewMockAuthService(t)105 authSvc := inmocks.NewMockAuthService(t)
101 106
102- // Auth disabled, no rate limiters107+ // No rate limiters, auth still required
103- authSvc.EXPECT().IsEnabled().Return(false)108+ authSvc.EXPECT().IsEnabled().Return(true)
109+ authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
110+ Subject: "admin",
111+ Scopes: []string{"admin:*:*"},
112+ }, nil)
104 113
105 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {114 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106 w.WriteHeader(http.StatusOK)115 w.WriteHeader(http.StatusOK)
@@ -110,6 +119,7 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
110 wrappedHandler := mw(handler)119 wrappedHandler := mw(handler)
111 120
112 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)121 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
122+ req.Header.Set("Authorization", "Bearer valid-admin-token")
113 rec := httptest.NewRecorder()123 rec := httptest.NewRecorder()
114 wrappedHandler.ServeHTTP(rec, req)124 wrappedHandler.ServeHTTP(rec, req)
115 125
@@ -139,8 +149,8 @@ func TestAuthMiddleware_AuthDisabled(t *testing.T) {
139 rec := httptest.NewRecorder()149 rec := httptest.NewRecorder()
140 wrappedHandler.ServeHTTP(rec, req)150 wrappedHandler.ServeHTTP(rec, req)
141 151
142- assert.Equal(t, http.StatusOK, rec.Code)152+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
143- assert.True(t, handlerCalled)153+ assert.False(t, handlerCalled)
144 }154 }
145 155
146 func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {156 func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
M internal/adapters/in/http/auth/handler.go +101 -10
@@ -50,6 +50,13 @@ type PasswordResponse struct {
5050 IssuedAt string `json:"issued_at"`
5151 }
5252
53+const passwordSessionTTL = 24 * time.Hour
54+
55+func setNoStoreHeaders(w http.ResponseWriter) {
56+ w.Header().Set("Cache-Control", "no-store")
57+ w.Header().Set("Pragma", "no-cache")
58+}
59+
5360 // ServeHTTP routes requests to the appropriate handler.
5461 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5562 path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
@@ -67,9 +74,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6774 }
6875
6976 // handlePassword handles POST /auth/password requests.
70-// Validates username/password and returns a long-lived JWT (7 days).
77+// Validates username/password and returns a daily session JWT.
7178 // Only works when auth type is "password" - returns error for "token" auth type.
7279 func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
80+ setNoStoreHeaders(w)
81+
7382 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
7483 zerowrap.FieldLayer: "adapter",
7584 zerowrap.FieldAdapter: "http",
@@ -89,8 +98,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
8998 // Check if auth is enabled
9099 if h.authSvc == nil || !h.authSvc.IsEnabled() {
91100 w.Header().Set("Content-Type", "application/json")
92- w.WriteHeader(http.StatusBadRequest)
93- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"})
101+ w.WriteHeader(http.StatusServiceUnavailable)
102+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
94103 return
95104 }
96105
@@ -132,8 +141,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
132141 return
133142 }
134143
135- // Generate 7-day token with full access (push, pull, admin)
136- expiry := 7 * 24 * time.Hour
144+ // Generate daily session token with full access (push, pull, admin).
145+ // This enforces regular re-authentication similar to short-session UX.
146+ expiry := passwordSessionTTL
137147 scopes := []string{"push", "pull", "admin:*:*"}
138148
139149 token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
@@ -168,6 +178,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
168178 // This is the Docker Registry v2 token server endpoint.
169179 // Issues short-lived access tokens for registry access.
170180 func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
181+ setNoStoreHeaders(w)
182+
171183 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
172184 zerowrap.FieldLayer: "adapter",
173185 zerowrap.FieldAdapter: "http",
@@ -186,21 +198,27 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
186198
187199 // Check if auth is enabled
188200 if h.authSvc == nil || !h.authSvc.IsEnabled() {
189- // If auth is disabled, return an anonymous token
190- h.sendAnonymousToken(w, log)
201+ w.Header().Set("Content-Type", "application/json")
202+ w.WriteHeader(http.StatusServiceUnavailable)
203+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
191204 return
192205 }
193206
194207 // Get credentials from Basic Auth
195208 username, password, ok := r.BasicAuth()
196209 if !ok {
197- // No credentials provided - return anonymous token with limited scope
198- h.sendAnonymousToken(w, log)
210+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
211+ w.Header().Set("Content-Type", "application/json")
212+ w.WriteHeader(http.StatusUnauthorized)
213+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
199214 return
200215 }
201216
202217 // Check for internal registry auth (localhost requests with internal creds)
203- var authenticated bool
218+ var (
219+ authenticated bool
220+ parentClaims *domain.TokenClaims
221+ )
204222 if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
205223 authenticated = true
206224 log.Debug().Str("username", username).Msg("internal registry auth accepted")
@@ -214,6 +232,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
214232 claims, err := h.authSvc.ValidateToken(ctx, password)
215233 if err == nil && claims.Subject == username {
216234 authenticated = true
235+ parentClaims = claims
217236 }
218237 }
219238 }
@@ -231,6 +250,15 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
231250
232251 // Parse requested scopes from query parameter
233252 requestedScopes := h.parseRequestedScopes(r, log)
253+ if parentClaims != nil {
254+ requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log)
255+ if len(requestedScopes) == 0 {
256+ w.Header().Set("Content-Type", "application/json")
257+ w.WriteHeader(http.StatusForbidden)
258+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"})
259+ return
260+ }
261+ }
234262
235263 // Generate a short-lived access token (5 minutes) - not stored
236264 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
@@ -261,6 +289,69 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
261289 Msg("access token issued")
262290 }
263291
292+func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {
293+ var effective []string
294+
295+ for _, reqScopeStr := range requestedScopes {
296+ reqScope, err := domain.ParseScope(reqScopeStr)
297+ if err != nil || reqScope.Type != domain.ScopeTypeRepository {
298+ continue
299+ }
300+
301+ allowedActions := make([]string, 0, len(reqScope.Actions))
302+ for _, action := range reqScope.Actions {
303+ if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
304+ allowedActions = append(allowedActions, action)
305+ }
306+ }
307+
308+ if len(allowedActions) == 0 {
309+ continue
310+ }
311+
312+ effective = append(effective, (&domain.Scope{
313+ Type: reqScope.Type,
314+ Name: reqScope.Name,
315+ Actions: allowedActions,
316+ }).String())
317+ }
318+
319+ log.Debug().
320+ Strs("requested_scopes", requestedScopes).
321+ Strs("granted_scopes", grantedScopes).
322+ Strs("effective_scopes", effective).
323+ Msg("calculated effective scopes from parent token")
324+
325+ return effective
326+}
327+
328+func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool {
329+ for _, grantedScopeStr := range grantedScopes {
330+ scopeStr := strings.TrimSpace(grantedScopeStr)
331+ switch scopeStr {
332+ case domain.ScopeActionAll:
333+ return true
334+ case domain.ScopeActionPull, domain.ScopeActionPush:
335+ if scopeStr == action {
336+ return true
337+ }
338+ }
339+
340+ scope, err := domain.ParseScope(scopeStr)
341+ if err != nil {
342+ continue
343+ }
344+ if scope.Type != domain.ScopeTypeRepository {
345+ continue
346+ }
347+ if scope.CanAccess(repoName, action) {
348+ return true
349+ }
350+ }
351+
352+ return false
353+}
354+
264355 // sendAnonymousToken sends a token for anonymous/unauthenticated access.
265356 func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
266357 // For anonymous access, we issue a very short-lived token with limited scope
@@ -50,6 +50,13 @@ type PasswordResponse struct {
50 IssuedAt string `json:"issued_at"`50 IssuedAt string `json:"issued_at"`
51 }51 }
52 52
53+const passwordSessionTTL = 24 * time.Hour
54+
55+func setNoStoreHeaders(w http.ResponseWriter) {
56+ w.Header().Set("Cache-Control", "no-store")
57+ w.Header().Set("Pragma", "no-cache")
58+}
59+
53 // ServeHTTP routes requests to the appropriate handler.60 // ServeHTTP routes requests to the appropriate handler.
54 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {61 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
55 path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")62 path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
@@ -67,9 +74,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
67 }74 }
68 75
69 // handlePassword handles POST /auth/password requests.76 // handlePassword handles POST /auth/password requests.
70-// Validates username/password and returns a long-lived JWT (7 days).77+// Validates username/password and returns a daily session JWT.
71 // Only works when auth type is "password" - returns error for "token" auth type.78 // Only works when auth type is "password" - returns error for "token" auth type.
72 func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {79 func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
80+ setNoStoreHeaders(w)
81+
73 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{82 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
74 zerowrap.FieldLayer: "adapter",83 zerowrap.FieldLayer: "adapter",
75 zerowrap.FieldAdapter: "http",84 zerowrap.FieldAdapter: "http",
@@ -89,8 +98,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
89 // Check if auth is enabled98 // Check if auth is enabled
90 if h.authSvc == nil || !h.authSvc.IsEnabled() {99 if h.authSvc == nil || !h.authSvc.IsEnabled() {
91 w.Header().Set("Content-Type", "application/json")100 w.Header().Set("Content-Type", "application/json")
92- w.WriteHeader(http.StatusBadRequest)101+ w.WriteHeader(http.StatusServiceUnavailable)
93- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"})102+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
94 return103 return
95 }104 }
96 105
@@ -132,8 +141,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
132 return141 return
133 }142 }
134 143
135- // Generate 7-day token with full access (push, pull, admin)144+ // Generate daily session token with full access (push, pull, admin).
136- expiry := 7 * 24 * time.Hour145+ // This enforces regular re-authentication similar to short-session UX.
146+ expiry := passwordSessionTTL
137 scopes := []string{"push", "pull", "admin:*:*"}147 scopes := []string{"push", "pull", "admin:*:*"}
138 148
139 token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)149 token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
@@ -168,6 +178,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
168 // This is the Docker Registry v2 token server endpoint.178 // This is the Docker Registry v2 token server endpoint.
169 // Issues short-lived access tokens for registry access.179 // Issues short-lived access tokens for registry access.
170 func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {180 func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
181+ setNoStoreHeaders(w)
182+
171 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{183 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
172 zerowrap.FieldLayer: "adapter",184 zerowrap.FieldLayer: "adapter",
173 zerowrap.FieldAdapter: "http",185 zerowrap.FieldAdapter: "http",
@@ -186,21 +198,27 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
186 198
187 // Check if auth is enabled199 // Check if auth is enabled
188 if h.authSvc == nil || !h.authSvc.IsEnabled() {200 if h.authSvc == nil || !h.authSvc.IsEnabled() {
189- // If auth is disabled, return an anonymous token201+ w.Header().Set("Content-Type", "application/json")
190- h.sendAnonymousToken(w, log)202+ w.WriteHeader(http.StatusServiceUnavailable)
203+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
191 return204 return
192 }205 }
193 206
194 // Get credentials from Basic Auth207 // Get credentials from Basic Auth
195 username, password, ok := r.BasicAuth()208 username, password, ok := r.BasicAuth()
196 if !ok {209 if !ok {
197- // No credentials provided - return anonymous token with limited scope210+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
198- h.sendAnonymousToken(w, log)211+ w.Header().Set("Content-Type", "application/json")
212+ w.WriteHeader(http.StatusUnauthorized)
213+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
199 return214 return
200 }215 }
201 216
202 // Check for internal registry auth (localhost requests with internal creds)217 // Check for internal registry auth (localhost requests with internal creds)
203- var authenticated bool218+ var (
219+ authenticated bool
220+ parentClaims *domain.TokenClaims
221+ )
204 if isLocalhostRequest(r) && h.isInternalAuth(username, password) {222 if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
205 authenticated = true223 authenticated = true
206 log.Debug().Str("username", username).Msg("internal registry auth accepted")224 log.Debug().Str("username", username).Msg("internal registry auth accepted")
@@ -214,6 +232,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
214 claims, err := h.authSvc.ValidateToken(ctx, password)232 claims, err := h.authSvc.ValidateToken(ctx, password)
215 if err == nil && claims.Subject == username {233 if err == nil && claims.Subject == username {
216 authenticated = true234 authenticated = true
235+ parentClaims = claims
217 }236 }
218 }237 }
219 }238 }
@@ -231,6 +250,15 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
231 250
232 // Parse requested scopes from query parameter251 // Parse requested scopes from query parameter
233 requestedScopes := h.parseRequestedScopes(r, log)252 requestedScopes := h.parseRequestedScopes(r, log)
253+ if parentClaims != nil {
254+ requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log)
255+ if len(requestedScopes) == 0 {
256+ w.Header().Set("Content-Type", "application/json")
257+ w.WriteHeader(http.StatusForbidden)
258+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"})
259+ return
260+ }
261+ }
234 262
235 // Generate a short-lived access token (5 minutes) - not stored263 // Generate a short-lived access token (5 minutes) - not stored
236 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)264 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
@@ -261,6 +289,69 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
261 Msg("access token issued")289 Msg("access token issued")
262 }290 }
263 291
292+func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {
293+ var effective []string
294+
295+ for _, reqScopeStr := range requestedScopes {
296+ reqScope, err := domain.ParseScope(reqScopeStr)
297+ if err != nil || reqScope.Type != domain.ScopeTypeRepository {
298+ continue
299+ }
300+
301+ allowedActions := make([]string, 0, len(reqScope.Actions))
302+ for _, action := range reqScope.Actions {
303+ if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
304+ allowedActions = append(allowedActions, action)
305+ }
306+ }
307+
308+ if len(allowedActions) == 0 {
309+ continue
310+ }
311+
312+ effective = append(effective, (&domain.Scope{
313+ Type: reqScope.Type,
314+ Name: reqScope.Name,
315+ Actions: allowedActions,
316+ }).String())
317+ }
318+
319+ log.Debug().
320+ Strs("requested_scopes", requestedScopes).
321+ Strs("granted_scopes", grantedScopes).
322+ Strs("effective_scopes", effective).
323+ Msg("calculated effective scopes from parent token")
324+
325+ return effective
326+}
327+
328+func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool {
329+ for _, grantedScopeStr := range grantedScopes {
330+ scopeStr := strings.TrimSpace(grantedScopeStr)
331+ switch scopeStr {
332+ case domain.ScopeActionAll:
333+ return true
334+ case domain.ScopeActionPull, domain.ScopeActionPush:
335+ if scopeStr == action {
336+ return true
337+ }
338+ }
339+
340+ scope, err := domain.ParseScope(scopeStr)
341+ if err != nil {
342+ continue
343+ }
344+ if scope.Type != domain.ScopeTypeRepository {
345+ continue
346+ }
347+ if scope.CanAccess(repoName, action) {
348+ return true
349+ }
350+ }
351+
352+ return false
353+}
354+
264 // sendAnonymousToken sends a token for anonymous/unauthenticated access.355 // sendAnonymousToken sends a token for anonymous/unauthenticated access.
265 func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {356 func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
266 // For anonymous access, we issue a very short-lived token with limited scope357 // For anonymous access, we issue a very short-lived token with limited scope
M internal/adapters/in/http/auth/handler_test.go +58 -19
@@ -26,7 +26,7 @@ func TestHandler_Password_Success(t *testing.T) {
2626 authSvc.EXPECT().IsEnabled().Return(true)
2727 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
2828 authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
29- authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour).
29+ authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour).
3030 Return("jwt-token-here", nil)
3131
3232 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -44,7 +44,7 @@ func TestHandler_Password_Success(t *testing.T) {
4444 err := json.NewDecoder(rec.Body).Decode(&resp)
4545 assert.NoError(t, err)
4646 assert.Equal(t, "jwt-token-here", resp.Token)
47- assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds
47+ assert.Equal(t, 24*60*60, resp.ExpiresIn)
4848 assert.NotEmpty(t, resp.IssuedAt)
4949 }
5050
@@ -101,8 +101,8 @@ func TestHandler_Password_AuthDisabled(t *testing.T) {
101101
102102 handler.ServeHTTP(rec, req)
103103
104- assert.Equal(t, http.StatusBadRequest, rec.Code)
105- assert.Contains(t, rec.Body.String(), "authentication is disabled")
104+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
105+ assert.Contains(t, rec.Body.String(), "authentication is required")
106106 }
107107
108108 func TestHandler_Password_MissingFields(t *testing.T) {
@@ -179,7 +179,7 @@ func TestHandler_Token_Success(t *testing.T) {
179179 assert.Equal(t, float64(300), resp["expires_in"])
180180 }
181181
182-func TestHandler_Token_Anonymous(t *testing.T) {
182+func TestHandler_Token_MissingCredentials(t *testing.T) {
183183 authSvc := mocks.NewMockAuthService(t)
184184
185185 authSvc.EXPECT().IsEnabled().Return(true)
@@ -191,13 +191,7 @@ func TestHandler_Token_Anonymous(t *testing.T) {
191191
192192 handler.ServeHTTP(rec, req)
193193
194- assert.Equal(t, http.StatusOK, rec.Code)
195-
196- var resp map[string]any
197- err := json.NewDecoder(rec.Body).Decode(&resp)
198- assert.NoError(t, err)
199- assert.Equal(t, "", resp["token"])
200- assert.Equal(t, float64(60), resp["expires_in"])
194+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
201195 }
202196
203197 func TestHandler_Token_AuthDisabled(t *testing.T) {
@@ -212,13 +206,8 @@ func TestHandler_Token_AuthDisabled(t *testing.T) {
212206
213207 handler.ServeHTTP(rec, req)
214208
215- // Should return anonymous token when auth is disabled
216- assert.Equal(t, http.StatusOK, rec.Code)
217-
218- var resp map[string]any
219- err := json.NewDecoder(rec.Body).Decode(&resp)
220- assert.NoError(t, err)
221- assert.Equal(t, "", resp["token"])
209+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
210+ assert.Contains(t, rec.Body.String(), "authentication is required")
222211 }
223212
224213 func TestHandler_Token_InvalidCredentials(t *testing.T) {
@@ -274,6 +263,7 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
274263 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
275264 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
276265 Subject: "myuser",
266+ Scopes: []string{"repository:*:pull"},
277267 }, nil)
278268 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
279269 Return("short-lived-token", nil)
@@ -294,6 +284,55 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
294284 assert.Equal(t, "short-lived-token", resp["token"])
295285 }
296286
287+func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
288+ authSvc := mocks.NewMockAuthService(t)
289+
290+ authSvc.EXPECT().IsEnabled().Return(true)
291+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
292+ authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
293+ Subject: "myuser",
294+ Scopes: []string{"repository:myrepo:pull"},
295+ }, nil)
296+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute).
297+ Return("scoped-token", nil)
298+
299+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
300+
301+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil)
302+ req.SetBasicAuth("myuser", "existing-jwt-token")
303+ rec := httptest.NewRecorder()
304+
305+ handler.ServeHTTP(rec, req)
306+
307+ assert.Equal(t, http.StatusOK, rec.Code)
308+ var resp map[string]any
309+ err := json.NewDecoder(rec.Body).Decode(&resp)
310+ assert.NoError(t, err)
311+ assert.Equal(t, "scoped-token", resp["token"])
312+}
313+
314+func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) {
315+ authSvc := mocks.NewMockAuthService(t)
316+
317+ authSvc.EXPECT().IsEnabled().Return(true)
318+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
319+ authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
320+ Subject: "myuser",
321+ Scopes: []string{"repository:myrepo:pull"},
322+ }, nil)
323+
324+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
325+
326+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil)
327+ req.SetBasicAuth("myuser", "existing-jwt-token")
328+ rec := httptest.NewRecorder()
329+
330+ handler.ServeHTTP(rec, req)
331+
332+ assert.Equal(t, http.StatusForbidden, rec.Code)
333+ assert.Contains(t, rec.Body.String(), "insufficient scope")
334+}
335+
297336 func TestHandler_Token_MethodNotAllowed(t *testing.T) {
298337 handler := NewHandler(nil, InternalAuth{}, testLogger())
299338
@@ -26,7 +26,7 @@ func TestHandler_Password_Success(t *testing.T) {
26 authSvc.EXPECT().IsEnabled().Return(true)26 authSvc.EXPECT().IsEnabled().Return(true)
27 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)27 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
28 authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)28 authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
29- authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour).29+ authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour).
30 Return("jwt-token-here", nil)30 Return("jwt-token-here", nil)
31 31
32 handler := NewHandler(authSvc, InternalAuth{}, testLogger())32 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -44,7 +44,7 @@ func TestHandler_Password_Success(t *testing.T) {
44 err := json.NewDecoder(rec.Body).Decode(&resp)44 err := json.NewDecoder(rec.Body).Decode(&resp)
45 assert.NoError(t, err)45 assert.NoError(t, err)
46 assert.Equal(t, "jwt-token-here", resp.Token)46 assert.Equal(t, "jwt-token-here", resp.Token)
47- assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds47+ assert.Equal(t, 24*60*60, resp.ExpiresIn)
48 assert.NotEmpty(t, resp.IssuedAt)48 assert.NotEmpty(t, resp.IssuedAt)
49 }49 }
50 50
@@ -101,8 +101,8 @@ func TestHandler_Password_AuthDisabled(t *testing.T) {
101 101
102 handler.ServeHTTP(rec, req)102 handler.ServeHTTP(rec, req)
103 103
104- assert.Equal(t, http.StatusBadRequest, rec.Code)104+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
105- assert.Contains(t, rec.Body.String(), "authentication is disabled")105+ assert.Contains(t, rec.Body.String(), "authentication is required")
106 }106 }
107 107
108 func TestHandler_Password_MissingFields(t *testing.T) {108 func TestHandler_Password_MissingFields(t *testing.T) {
@@ -179,7 +179,7 @@ func TestHandler_Token_Success(t *testing.T) {
179 assert.Equal(t, float64(300), resp["expires_in"])179 assert.Equal(t, float64(300), resp["expires_in"])
180 }180 }
181 181
182-func TestHandler_Token_Anonymous(t *testing.T) {182+func TestHandler_Token_MissingCredentials(t *testing.T) {
183 authSvc := mocks.NewMockAuthService(t)183 authSvc := mocks.NewMockAuthService(t)
184 184
185 authSvc.EXPECT().IsEnabled().Return(true)185 authSvc.EXPECT().IsEnabled().Return(true)
@@ -191,13 +191,7 @@ func TestHandler_Token_Anonymous(t *testing.T) {
191 191
192 handler.ServeHTTP(rec, req)192 handler.ServeHTTP(rec, req)
193 193
194- assert.Equal(t, http.StatusOK, rec.Code)194+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
195-
196- var resp map[string]any
197- err := json.NewDecoder(rec.Body).Decode(&resp)
198- assert.NoError(t, err)
199- assert.Equal(t, "", resp["token"])
200- assert.Equal(t, float64(60), resp["expires_in"])
201 }195 }
202 196
203 func TestHandler_Token_AuthDisabled(t *testing.T) {197 func TestHandler_Token_AuthDisabled(t *testing.T) {
@@ -212,13 +206,8 @@ func TestHandler_Token_AuthDisabled(t *testing.T) {
212 206
213 handler.ServeHTTP(rec, req)207 handler.ServeHTTP(rec, req)
214 208
215- // Should return anonymous token when auth is disabled209+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
216- assert.Equal(t, http.StatusOK, rec.Code)210+ assert.Contains(t, rec.Body.String(), "authentication is required")
217-
218- var resp map[string]any
219- err := json.NewDecoder(rec.Body).Decode(&resp)
220- assert.NoError(t, err)
221- assert.Equal(t, "", resp["token"])
222 }211 }
223 212
224 func TestHandler_Token_InvalidCredentials(t *testing.T) {213 func TestHandler_Token_InvalidCredentials(t *testing.T) {
@@ -274,6 +263,7 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
274 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)263 authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
275 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{264 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
276 Subject: "myuser",265 Subject: "myuser",
266+ Scopes: []string{"repository:*:pull"},
277 }, nil)267 }, nil)
278 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).268 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
279 Return("short-lived-token", nil)269 Return("short-lived-token", nil)
@@ -294,6 +284,55 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
294 assert.Equal(t, "short-lived-token", resp["token"])284 assert.Equal(t, "short-lived-token", resp["token"])
295 }285 }
296 286
287+func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
288+ authSvc := mocks.NewMockAuthService(t)
289+
290+ authSvc.EXPECT().IsEnabled().Return(true)
291+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
292+ authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
293+ Subject: "myuser",
294+ Scopes: []string{"repository:myrepo:pull"},
295+ }, nil)
296+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute).
297+ Return("scoped-token", nil)
298+
299+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
300+
301+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil)
302+ req.SetBasicAuth("myuser", "existing-jwt-token")
303+ rec := httptest.NewRecorder()
304+
305+ handler.ServeHTTP(rec, req)
306+
307+ assert.Equal(t, http.StatusOK, rec.Code)
308+ var resp map[string]any
309+ err := json.NewDecoder(rec.Body).Decode(&resp)
310+ assert.NoError(t, err)
311+ assert.Equal(t, "scoped-token", resp["token"])
312+}
313+
314+func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) {
315+ authSvc := mocks.NewMockAuthService(t)
316+
317+ authSvc.EXPECT().IsEnabled().Return(true)
318+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
319+ authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
320+ Subject: "myuser",
321+ Scopes: []string{"repository:myrepo:pull"},
322+ }, nil)
323+
324+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
325+
326+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil)
327+ req.SetBasicAuth("myuser", "existing-jwt-token")
328+ rec := httptest.NewRecorder()
329+
330+ handler.ServeHTTP(rec, req)
331+
332+ assert.Equal(t, http.StatusForbidden, rec.Code)
333+ assert.Contains(t, rec.Body.String(), "insufficient scope")
334+}
335+
297 func TestHandler_Token_MethodNotAllowed(t *testing.T) {336 func TestHandler_Token_MethodNotAllowed(t *testing.T) {
298 handler := NewHandler(nil, InternalAuth{}, testLogger())337 handler := NewHandler(nil, InternalAuth{}, testLogger())
299 338
M internal/adapters/in/http/middleware/clientip.go +44 -6
@@ -6,6 +6,24 @@ import (
66 "strings"
77 )
88
9+func normalizeIP(raw string) string {
10+ ip := strings.TrimSpace(raw)
11+ if ip == "" {
12+ return ""
13+ }
14+
15+ if host, _, err := net.SplitHostPort(ip); err == nil {
16+ ip = host
17+ }
18+
19+ parsed := net.ParseIP(strings.TrimSpace(ip))
20+ if parsed == nil {
21+ return ""
22+ }
23+
24+ return parsed.String()
25+}
26+
927 // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.
1028 // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").
1129 // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks.
@@ -60,21 +78,41 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
6078 if err != nil {
6179 remoteIP = r.RemoteAddr
6280 }
81+ if normalized := normalizeIP(remoteIP); normalized != "" {
82+ remoteIP = normalized
83+ }
6384
6485 // Only honor proxy headers if the request comes from a trusted proxy
6586 if IsTrustedProxy(remoteIP, trustedNets) {
66- // Check X-Forwarded-For for proxied requests
87+ // Check X-Forwarded-For for proxied requests.
88+ // SECURITY: Parse from right to left and return the first untrusted IP.
89+ // This prevents spoofing when a trusted proxy appends to an attacker-
90+ // supplied XFF chain.
6791 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
68- // Take first IP in chain (original client)
69- if first, _, found := strings.Cut(xff, ","); found {
70- return strings.TrimSpace(first)
92+ parts := strings.Split(xff, ",")
93+ validChain := make([]string, 0, len(parts))
94+ for _, part := range parts {
95+ if ip := normalizeIP(part); ip != "" {
96+ validChain = append(validChain, ip)
97+ }
98+ }
99+
100+ for i := len(validChain) - 1; i >= 0; i-- {
101+ if !IsTrustedProxy(validChain[i], trustedNets) {
102+ return validChain[i]
103+ }
104+ }
105+
106+ if len(validChain) > 0 {
107+ return validChain[0]
71108 }
72- return strings.TrimSpace(xff)
73109 }
74110
75111 // Check X-Real-IP
76112 if xri := r.Header.Get("X-Real-IP"); xri != "" {
77- return xri
113+ if ip := normalizeIP(xri); ip != "" {
114+ return ip
115+ }
78116 }
79117 }
80118
@@ -6,6 +6,24 @@ import (
6 "strings"6 "strings"
7 )7 )
8 8
9+func normalizeIP(raw string) string {
10+ ip := strings.TrimSpace(raw)
11+ if ip == "" {
12+ return ""
13+ }
14+
15+ if host, _, err := net.SplitHostPort(ip); err == nil {
16+ ip = host
17+ }
18+
19+ parsed := net.ParseIP(strings.TrimSpace(ip))
20+ if parsed == nil {
21+ return ""
22+ }
23+
24+ return parsed.String()
25+}
26+
9 // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.27 // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.
10 // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").28 // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").
11 // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks.29 // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks.
@@ -60,21 +78,41 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
60 if err != nil {78 if err != nil {
61 remoteIP = r.RemoteAddr79 remoteIP = r.RemoteAddr
62 }80 }
81+ if normalized := normalizeIP(remoteIP); normalized != "" {
82+ remoteIP = normalized
83+ }
63 84
64 // Only honor proxy headers if the request comes from a trusted proxy85 // Only honor proxy headers if the request comes from a trusted proxy
65 if IsTrustedProxy(remoteIP, trustedNets) {86 if IsTrustedProxy(remoteIP, trustedNets) {
66- // Check X-Forwarded-For for proxied requests87+ // Check X-Forwarded-For for proxied requests.
88+ // SECURITY: Parse from right to left and return the first untrusted IP.
89+ // This prevents spoofing when a trusted proxy appends to an attacker-
90+ // supplied XFF chain.
67 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {91 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
68- // Take first IP in chain (original client)92+ parts := strings.Split(xff, ",")
69- if first, _, found := strings.Cut(xff, ","); found {93+ validChain := make([]string, 0, len(parts))
70- return strings.TrimSpace(first)94+ for _, part := range parts {
95+ if ip := normalizeIP(part); ip != "" {
96+ validChain = append(validChain, ip)
97+ }
98+ }
99+
100+ for i := len(validChain) - 1; i >= 0; i-- {
101+ if !IsTrustedProxy(validChain[i], trustedNets) {
102+ return validChain[i]
103+ }
104+ }
105+
106+ if len(validChain) > 0 {
107+ return validChain[0]
71 }108 }
72- return strings.TrimSpace(xff)
73 }109 }
74 110
75 // Check X-Real-IP111 // Check X-Real-IP
76 if xri := r.Header.Get("X-Real-IP"); xri != "" {112 if xri := r.Header.Get("X-Real-IP"); xri != "" {
77- return xri113+ if ip := normalizeIP(xri); ip != "" {
114+ return ip
115+ }
78 }116 }
79 }117 }
80 118
M internal/adapters/in/http/middleware/clientip_test.go +8 -1
@@ -58,7 +58,14 @@ func TestGetClientIP(t *testing.T) {
5858 {
5959 name: "XFF honored from trusted proxy (multiple IPs)",
6060 remoteAddr: "127.0.0.1:12345",
61- xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",
61+ xff: "203.0.113.50, 10.0.0.1",
62+ trustedNets: trustedNets,
63+ wantIP: "203.0.113.50",
64+ },
65+ {
66+ name: "XFF spoof chain returns first untrusted from right",
67+ remoteAddr: "127.0.0.1:12345",
68+ xff: "198.51.100.123, 203.0.113.50, 10.0.0.9",
6269 trustedNets: trustedNets,
6370 wantIP: "203.0.113.50",
6471 },
@@ -58,7 +58,14 @@ func TestGetClientIP(t *testing.T) {
58 {58 {
59 name: "XFF honored from trusted proxy (multiple IPs)",59 name: "XFF honored from trusted proxy (multiple IPs)",
60 remoteAddr: "127.0.0.1:12345",60 remoteAddr: "127.0.0.1:12345",
61- xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",61+ xff: "203.0.113.50, 10.0.0.1",
62+ trustedNets: trustedNets,
63+ wantIP: "203.0.113.50",
64+ },
65+ {
66+ name: "XFF spoof chain returns first untrusted from right",
67+ remoteAddr: "127.0.0.1:12345",
68+ xff: "198.51.100.123, 203.0.113.50, 10.0.0.9",
62 trustedNets: trustedNets,69 trustedNets: trustedNets,
63 wantIP: "203.0.113.50",70 wantIP: "203.0.113.50",
64 },71 },
M internal/app/run.go +49 -12
@@ -41,6 +41,7 @@ import (
4141 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
4242
4343 // Adapters - Input
44+ "github.com/bnema/gordon/internal/adapters/dto"
4445 "github.com/bnema/gordon/internal/adapters/in/http/admin"
4546 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
4647 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
@@ -487,11 +488,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
487488 return svc, nil
488489 }
489490
490-func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error {
491- if !cfg.Auth.Enabled {
492- return nil
493- }
494-
491+func setupInternalRegistryAuth(_ Config, svc *services, log zerowrap.Logger) error {
495492 var err error
496493 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
497494 if err != nil {
@@ -761,7 +758,7 @@ func GetInternalCredentials() (*InternalCredentials, error) {
761758 // createAuthService creates the authentication service and token store.
762759 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
763760 if !cfg.Auth.Enabled {
764- return nil, nil, nil
761+ return nil, nil, fmt.Errorf("auth.enabled=false is no longer supported; authentication is always required")
765762 }
766763
767764 authType := resolveAuthType(cfg)
@@ -850,7 +847,7 @@ func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowra
850847
851848 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
852849 authConfig := auth.Config{
853- Enabled: cfg.Auth.Enabled,
850+ Enabled: true,
854851 AuthType: authType,
855852 Username: cfg.Auth.Username,
856853 }
@@ -1045,7 +1042,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
10451042 // createContainerService creates the container service with configuration.
10461043 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
10471044 containerConfig := container.Config{
1048- RegistryAuthEnabled: cfg.Auth.Enabled,
1045+ RegistryAuthEnabled: true,
10491046 RegistryDomain: cfg.Server.RegistryDomain,
10501047 RegistryPort: cfg.Server.RegistryPort,
10511048 InternalRegistryUsername: svc.internalRegUser,
@@ -1071,7 +1068,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
10711068 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
10721069 }
10731070
1074- if cfg.Auth.Enabled && svc.authSvc != nil {
1071+ if svc.authSvc != nil {
10751072 expiry, err := resolveServiceTokenExpiry(cfg)
10761073 if err != nil {
10771074 return nil, log.WrapErr(err, "failed to resolve service token expiry")
@@ -1088,6 +1085,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
10881085 Msg("generated service token for container registry access")
10891086 containerConfig.ServiceTokenUsername = serviceTokenSubject
10901087 containerConfig.ServiceToken = serviceToken
1088+ } else {
1089+ return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
10911090 }
10921091
10931092 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
@@ -1242,6 +1241,28 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
12421241 }
12431242 }
12441243 }
1244+func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
1245+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1246+ host, _, err := net.SplitHostPort(r.RemoteAddr)
1247+ if err != nil {
1248+ host = r.RemoteAddr
1249+ }
1250+
1251+ ip := net.ParseIP(host)
1252+ if ip == nil || !ip.IsLoopback() {
1253+ log.Warn().
1254+ Str("path", r.URL.Path).
1255+ Str("remote_addr", r.RemoteAddr).
1256+ Msg("blocked non-loopback access on internal admin route")
1257+ w.Header().Set("Content-Type", "application/json")
1258+ w.WriteHeader(http.StatusForbidden)
1259+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
1260+ return
1261+ }
1262+
1263+ next.ServeHTTP(w, r)
1264+ })
1265+}
12451266
12461267 // createHTTPHandlers creates HTTP handlers with middleware.
12471268 func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
@@ -1294,13 +1315,22 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
12941315 }
12951316 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
12961317
1297- if cfg.Auth.Enabled && svc.authSvc != nil {
1318+ if svc.authSvc != nil {
12981319 internalAuth := middleware.InternalRegistryAuth{
12991320 Username: svc.internalRegUser,
13001321 Password: svc.internalRegPass,
13011322 }
13021323 registryMiddlewares = append(registryMiddlewares,
13031324 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1325+ } else {
1326+ log.Error().Msg("authentication service unavailable; registry requests will be denied")
1327+ registryMiddlewares = append(registryMiddlewares, func(next http.Handler) http.Handler {
1328+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1329+ w.Header().Set("Content-Type", "application/json")
1330+ w.WriteHeader(http.StatusServiceUnavailable)
1331+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1332+ })
1333+ })
13041334 }
13051335
13061336 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
@@ -1334,7 +1364,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
13341364 middleware.RequestLogger(log, trustedNets),
13351365 middleware.SecurityHeaders,
13361366 }
1337- // Add admin auth middleware if auth is enabled
13381367 if svc.authSvc != nil {
13391368 // Create rate limiters for admin API - uses same config as registry
13401369 var globalLimiter, ipLimiter out.RateLimiter
@@ -1343,9 +1372,17 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
13431372 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
13441373 }
13451374 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1375+ } else {
1376+ adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
1377+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1378+ w.Header().Set("Content-Type", "application/json")
1379+ w.WriteHeader(http.StatusServiceUnavailable)
1380+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1381+ })
1382+ })
13461383 }
13471384 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1348- registryMux.Handle("/admin/", adminWithMiddleware)
1385+ registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
13491386 }
13501387
13511388 // SECURITY: No CORS middleware on the proxy chain. Backend applications
@@ -41,6 +41,7 @@ import (
41 "github.com/bnema/gordon/internal/adapters/out/tokenstore"41 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
42 42
43 // Adapters - Input43 // Adapters - Input
44+ "github.com/bnema/gordon/internal/adapters/dto"
44 "github.com/bnema/gordon/internal/adapters/in/http/admin"45 "github.com/bnema/gordon/internal/adapters/in/http/admin"
45 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"46 authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth"
46 "github.com/bnema/gordon/internal/adapters/in/http/middleware"47 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
@@ -487,11 +488,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
487 return svc, nil488 return svc, nil
488 }489 }
489 490
490-func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error {491+func setupInternalRegistryAuth(_ Config, svc *services, log zerowrap.Logger) error {
491- if !cfg.Auth.Enabled {
492- return nil
493- }
494-
495 var err error492 var err error
496 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()493 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
497 if err != nil {494 if err != nil {
@@ -761,7 +758,7 @@ func GetInternalCredentials() (*InternalCredentials, error) {
761 // createAuthService creates the authentication service and token store.758 // createAuthService creates the authentication service and token store.
762 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {759 func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
763 if !cfg.Auth.Enabled {760 if !cfg.Auth.Enabled {
764- return nil, nil, nil761+ return nil, nil, fmt.Errorf("auth.enabled=false is no longer supported; authentication is always required")
765 }762 }
766 763
767 authType := resolveAuthType(cfg)764 authType := resolveAuthType(cfg)
@@ -850,7 +847,7 @@ func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowra
850 847
851 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {848 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
852 authConfig := auth.Config{849 authConfig := auth.Config{
853- Enabled: cfg.Auth.Enabled,850+ Enabled: true,
854 AuthType: authType,851 AuthType: authType,
855 Username: cfg.Auth.Username,852 Username: cfg.Auth.Username,
856 }853 }
@@ -1045,7 +1042,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1045 // createContainerService creates the container service with configuration.1042 // createContainerService creates the container service with configuration.
1046 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {1043 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1047 containerConfig := container.Config{1044 containerConfig := container.Config{
1048- RegistryAuthEnabled: cfg.Auth.Enabled,1045+ RegistryAuthEnabled: true,
1049 RegistryDomain: cfg.Server.RegistryDomain,1046 RegistryDomain: cfg.Server.RegistryDomain,
1050 RegistryPort: cfg.Server.RegistryPort,1047 RegistryPort: cfg.Server.RegistryPort,
1051 InternalRegistryUsername: svc.internalRegUser,1048 InternalRegistryUsername: svc.internalRegUser,
@@ -1071,7 +1068,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1071 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")1068 containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
1072 }1069 }
1073 1070
1074- if cfg.Auth.Enabled && svc.authSvc != nil {1071+ if svc.authSvc != nil {
1075 expiry, err := resolveServiceTokenExpiry(cfg)1072 expiry, err := resolveServiceTokenExpiry(cfg)
1076 if err != nil {1073 if err != nil {
1077 return nil, log.WrapErr(err, "failed to resolve service token expiry")1074 return nil, log.WrapErr(err, "failed to resolve service token expiry")
@@ -1088,6 +1085,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1088 Msg("generated service token for container registry access")1085 Msg("generated service token for container registry access")
1089 containerConfig.ServiceTokenUsername = serviceTokenSubject1086 containerConfig.ServiceTokenUsername = serviceTokenSubject
1090 containerConfig.ServiceToken = serviceToken1087 containerConfig.ServiceToken = serviceToken
1088+ } else {
1089+ return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token")
1091 }1090 }
1092 1091
1093 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil1092 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
@@ -1242,6 +1241,28 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
1242 }1241 }
1243 }1242 }
1244 }1243 }
1244+func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
1245+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1246+ host, _, err := net.SplitHostPort(r.RemoteAddr)
1247+ if err != nil {
1248+ host = r.RemoteAddr
1249+ }
1250+
1251+ ip := net.ParseIP(host)
1252+ if ip == nil || !ip.IsLoopback() {
1253+ log.Warn().
1254+ Str("path", r.URL.Path).
1255+ Str("remote_addr", r.RemoteAddr).
1256+ Msg("blocked non-loopback access on internal admin route")
1257+ w.Header().Set("Content-Type", "application/json")
1258+ w.WriteHeader(http.StatusForbidden)
1259+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
1260+ return
1261+ }
1262+
1263+ next.ServeHTTP(w, r)
1264+ })
1265+}
1245 1266
1246 // createHTTPHandlers creates HTTP handlers with middleware.1267 // createHTTPHandlers creates HTTP handlers with middleware.
1247 func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {1268 func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
@@ -1294,13 +1315,22 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1294 }1315 }
1295 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)1316 registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
1296 1317
1297- if cfg.Auth.Enabled && svc.authSvc != nil {1318+ if svc.authSvc != nil {
1298 internalAuth := middleware.InternalRegistryAuth{1319 internalAuth := middleware.InternalRegistryAuth{
1299 Username: svc.internalRegUser,1320 Username: svc.internalRegUser,
1300 Password: svc.internalRegPass,1321 Password: svc.internalRegPass,
1301 }1322 }
1302 registryMiddlewares = append(registryMiddlewares,1323 registryMiddlewares = append(registryMiddlewares,
1303 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))1324 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
1325+ } else {
1326+ log.Error().Msg("authentication service unavailable; registry requests will be denied")
1327+ registryMiddlewares = append(registryMiddlewares, func(next http.Handler) http.Handler {
1328+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1329+ w.Header().Set("Content-Type", "application/json")
1330+ w.WriteHeader(http.StatusServiceUnavailable)
1331+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1332+ })
1333+ })
1304 }1334 }
1305 1335
1306 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)1336 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
@@ -1334,7 +1364,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1334 middleware.RequestLogger(log, trustedNets),1364 middleware.RequestLogger(log, trustedNets),
1335 middleware.SecurityHeaders,1365 middleware.SecurityHeaders,
1336 }1366 }
1337- // Add admin auth middleware if auth is enabled
1338 if svc.authSvc != nil {1367 if svc.authSvc != nil {
1339 // Create rate limiters for admin API - uses same config as registry1368 // Create rate limiters for admin API - uses same config as registry
1340 var globalLimiter, ipLimiter out.RateLimiter1369 var globalLimiter, ipLimiter out.RateLimiter
@@ -1343,9 +1372,17 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1343 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)1372 ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log)
1344 }1373 }
1345 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))1374 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log))
1375+ } else {
1376+ adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler {
1377+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1378+ w.Header().Set("Content-Type", "application/json")
1379+ w.WriteHeader(http.StatusServiceUnavailable)
1380+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"})
1381+ })
1382+ })
1346 }1383 }
1347 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)1384 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1348- registryMux.Handle("/admin/", adminWithMiddleware)1385+ registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1349 }1386 }
1350 1387
1351 // SECURITY: No CORS middleware on the proxy chain. Backend applications1388 // SECURITY: No CORS middleware on the proxy chain. Backend applications

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 7

Tags 106

↑↓ moveenter openesc close