1package middleware
2
3import (
4 "context"
5 "crypto/subtle"
6 "encoding/json"
7 "errors"
8 "net/http"
9 "strings"
10
11 "github.com/bnema/zerowrap"
12
13 "github.com/bnema/gordon/internal/adapters/dto"
14 "github.com/bnema/gordon/internal/adapters/in/http/httputil"
15 "github.com/bnema/gordon/internal/boundaries/in"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19// TokenClaimsKey is context key for storing token claims.
20// Using domain key for consistency across all auth flows.
21const TokenClaimsKey = domain.TokenClaimsKey
22
23// sanitizeHeaderValue removes characters that could enable header injection.
24// Only allows alphanumeric, dots, hyphens, colons, and square brackets
25// (sufficient for host:port and IPv6 addresses).
26func sanitizeHeaderValue(value string) string {
27 var b strings.Builder
28 b.Grow(len(value))
29 for _, r := range value {
30 if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
31 r == '.' || r == '-' || r == ':' || r == '[' || r == ']' {
32 b.WriteRune(r)
33 }
34 }
35 return b.String()
36}
37
38// redactUsername partially redacts a username for logging.
39// Shows first and last character with asterisks in between.
40// SECURITY: Prevents full username exposure in logs.
41func redactUsername(username string) string {
42 if len(username) <= 2 {
43 return "***"
44 }
45 return username[:1] + "***" + username[len(username)-1:]
46}
47
48// InternalRegistryAuth holds the credentials used for loopback-only registry access.
49// These are generated per Gordon instance and are never exposed in config.
50type InternalRegistryAuth struct {
51 Username string
52 Password string
53}
54
55// RegistryAuthV2 middleware provides enhanced Docker Registry authentication
56// supporting both password and token-based authentication.
57func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, log zerowrap.Logger) func(http.Handler) http.Handler {
58 return func(next http.Handler) http.Handler {
59 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60 // Allow localhost requests only with internal instance credentials.
61 if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
62 log.Debug().
63 Str(zerowrap.FieldLayer, "adapter").
64 Str(zerowrap.FieldAdapter, "http").
65 Str(zerowrap.FieldMethod, r.Method).
66 Str(zerowrap.FieldPath, r.URL.Path).
67 Str(zerowrap.FieldClientIP, r.RemoteAddr).
68 Msg("localhost request with internal auth - skipping auth")
69 next.ServeHTTP(w, r)
70 return
71 }
72
73 // Warn if not using TLS (skip for localhost — internal proxy traffic)
74 if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) {
75 log.Warn().
76 Str(zerowrap.FieldLayer, "adapter").
77 Str(zerowrap.FieldAdapter, "http").
78 Str(zerowrap.FieldMethod, r.Method).
79 Str(zerowrap.FieldPath, r.URL.Path).
80 Str(zerowrap.FieldClientIP, r.RemoteAddr).
81 Msg("registry auth over insecure HTTP connection")
82 }
83
84 // Check if auth is enabled
85 if !authSvc.IsEnabled() {
86 next.ServeHTTP(w, r)
87 return
88 }
89
90 ctx := r.Context()
91
92 // Authenticate based on auth type
93 // When auth type is "password", accept BOTH password and token auth
94 // (allows CI/CD tokens while still supporting interactive password login)
95 tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log)
96
97 if authErr != nil {
98 if errors.Is(authErr, domain.ErrLongLivedToken) {
99 sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error())
100 } else {
101 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
102 }
103 return
104 }
105
106 // SECURITY: Check scopes for token auth (per-repo access control)
107 if tokenClaims != nil {
108 if !checkScopeAccess(r, tokenClaims, log) {
109 sendForbidden(w, log, r)
110 return
111 }
112 // Store claims in context for downstream handlers that need access to token metadata
113 // (e.g., audit logging, rate limiting by subject, or future per-user quotas)
114 ctx = context.WithValue(ctx, TokenClaimsKey, tokenClaims)
115 r = r.WithContext(ctx)
116 }
117
118 next.ServeHTTP(w, r)
119 })
120 }
121}
122
123// authenticateToken handles token-based authentication.
124// It supports both Bearer token in Authorization header and token-as-password for CI.
125// Returns (tokenClaims, error). A nil error means authentication succeeded.
126// Returns domain.ErrLongLivedToken when a stored (non-ephemeral) token is used.
127func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) {
128 // First, check for Bearer token
129 authHeader := r.Header.Get("Authorization")
130 if strings.HasPrefix(authHeader, "Bearer ") {
131 token := strings.TrimPrefix(authHeader, "Bearer ")
132 claims, err := authSvc.ValidateToken(ctx, token)
133 if err != nil {
134 log.Debug().
135 Err(err).
136 Str(zerowrap.FieldMethod, r.Method).
137 Str(zerowrap.FieldPath, r.URL.Path).
138 Msg("bearer token validation failed")
139 return nil, err
140 }
141
142 if !claims.IsEphemeral {
143 log.Warn().
144 Str("subject", claims.Subject).
145 Str(zerowrap.FieldMethod, r.Method).
146 Str(zerowrap.FieldPath, r.URL.Path).
147 Msg("long-lived token rejected on registry endpoint")
148 return nil, domain.ErrLongLivedToken
149 }
150
151 log.Debug().
152 Str("subject", claims.Subject).
153 Str(zerowrap.FieldMethod, r.Method).
154 Str(zerowrap.FieldPath, r.URL.Path).
155 Msg("bearer token authentication successful")
156 return claims, nil
157 }
158
159 // Fall back to token-as-password (for CI/automation)
160 // Username is the subject, password is the JWT token
161 username, password, ok := r.BasicAuth()
162 if !ok {
163 log.Debug().
164 Str(zerowrap.FieldMethod, r.Method).
165 Str(zerowrap.FieldPath, r.URL.Path).
166 Msg("no auth credentials provided")
167 return nil, errors.New("no auth credentials provided")
168 }
169
170 // Try to validate the password as a JWT token
171 claims, err := authSvc.ValidateToken(ctx, password)
172 if err != nil {
173 log.Debug().
174 Err(err).
175 Str("provided_username", redactUsername(username)).
176 Str(zerowrap.FieldMethod, r.Method).
177 Str(zerowrap.FieldPath, r.URL.Path).
178 Msg("token-as-password validation failed")
179 return nil, err
180 }
181
182 // Verify the username matches the token subject
183 if claims.Subject != username {
184 log.Debug().
185 Str("provided_username", redactUsername(username)).
186 Str("token_subject", redactUsername(claims.Subject)).
187 Str(zerowrap.FieldMethod, r.Method).
188 Str(zerowrap.FieldPath, r.URL.Path).
189 Msg("username does not match token subject")
190 return nil, errors.New("username does not match token subject")
191 }
192
193 if !claims.IsEphemeral {
194 log.Warn().
195 Str("subject", claims.Subject).
196 Str(zerowrap.FieldMethod, r.Method).
197 Str(zerowrap.FieldPath, r.URL.Path).
198 Msg("long-lived token rejected on registry endpoint")
199 return nil, domain.ErrLongLivedToken
200 }
201
202 log.Debug().
203 Str("subject", claims.Subject).
204 Str(zerowrap.FieldMethod, r.Method).
205 Str(zerowrap.FieldPath, r.URL.Path).
206 Msg("token-as-password authentication successful")
207 return claims, nil
208}
209
210func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
211 if internalAuth.Username == "" || internalAuth.Password == "" {
212 return false
213 }
214
215 username, password, ok := r.BasicAuth()
216 if !ok {
217 return false
218 }
219
220 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(internalAuth.Username)) == 1
221 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(internalAuth.Password)) == 1
222 return usernameMatch && passwordMatch
223}
224
225// actionFromMethod returns the registry action for an HTTP method.
226func actionFromMethod(method string) string {
227 switch method {
228 case http.MethodGet, http.MethodHead:
229 return domain.ScopeActionPull
230 case http.MethodPut, http.MethodPost, http.MethodPatch, http.MethodDelete:
231 return domain.ScopeActionPush
232 default:
233 return domain.ScopeActionPull
234 }
235}
236
237// extractRepoName extracts the repository name from a registry path.
238// Returns empty string if the path is not a valid registry path or should be allowed.
239func extractRepoName(path string) (repoName string, shouldAllow bool) {
240 if !strings.HasPrefix(path, "/v2/") {
241 return "", true // Not a registry path, allow
242 }
243
244 pathParts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
245 if len(pathParts) < 2 {
246 return "", true // Malformed path or /v2/ root, allow
247 }
248
249 // Find the boundary between repo name and route (manifests, blobs, tags)
250 var repoNameParts []string
251 for i, part := range pathParts {
252 if part == "manifests" || part == "blobs" || part == "tags" || part == "_catalog" {
253 repoNameParts = pathParts[:i]
254 break
255 }
256 }
257 if len(repoNameParts) == 0 {
258 return "", true // Special route like /v2/token, allow
259 }
260
261 return strings.Join(repoNameParts, "/"), false
262}
263
264// checkScopeAccess verifies the token has permission for the requested operation.
265// Maps HTTP method to registry action and checks if any token scope grants access.
266func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.Logger) bool {
267 action := actionFromMethod(r.Method)
268
269 repoName, shouldAllow := extractRepoName(r.URL.Path)
270 if shouldAllow {
271 return true
272 }
273
274 // Delegate matching to domain layer
275 if domain.ScopesGrantRegistryAccess(claims.Scopes, repoName, action) {
276 log.Debug().
277 Str("repo", repoName).
278 Str("action", action).
279 Strs("scopes", claims.Scopes).
280 Msg("scope access granted")
281 return true
282 }
283
284 log.Debug().
285 Str("repo", repoName).
286 Str("action", action).
287 Strs("scopes", claims.Scopes).
288 Msg("no scope grants access")
289 return false
290}
291
292// sendForbidden sends an HTTP 403 response.
293func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) {
294 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
295 w.Header().Set("Content-Type", "application/json")
296 w.WriteHeader(http.StatusForbidden)
297 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden: insufficient scope"})
298
299 log.Warn().
300 Str(zerowrap.FieldLayer, "adapter").
301 Str(zerowrap.FieldAdapter, "http").
302 Str(zerowrap.FieldMethod, r.Method).
303 Str(zerowrap.FieldPath, r.URL.Path).
304 Str(zerowrap.FieldClientIP, r.RemoteAddr).
305 Msg("forbidden: insufficient scope for operation")
306}
307
308// sendUnauthorizedMsg sends an HTTP 401 response with a custom error message.
309func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request, msg string) {
310 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
311
312 switch authType {
313 case domain.AuthTypeToken:
314 realmHost := host
315 scheme := "http"
316 if r.TLS != nil {
317 scheme = "https"
318 }
319 realmHost = sanitizeHeaderValue(realmHost)
320 realm := scheme + "://" + realmHost + "/auth/token"
321 w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
322 default:
323 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
324 }
325
326 w.Header().Set("Content-Type", "application/json")
327 w.WriteHeader(http.StatusUnauthorized)
328 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: msg})
329
330 log.Warn().
331 Str(zerowrap.FieldLayer, "adapter").
332 Str(zerowrap.FieldAdapter, "http").
333 Str(zerowrap.FieldMethod, r.Method).
334 Str(zerowrap.FieldPath, r.URL.Path).
335 Str(zerowrap.FieldClientIP, r.RemoteAddr).
336 Msg("unauthorized registry access attempt")
337}
338
339// sendUnauthorized sends an HTTP 401 response with appropriate headers.
340func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
341 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
342
343 switch authType {
344 case domain.AuthTypeToken:
345 // For token auth, indicate the token server endpoint.
346 // SECURITY: Only use X-Forwarded-Host/Proto from trusted sources.
347 // The Host header from the request is used as the default.
348 // X-Forwarded-Host is NOT trusted here because this middleware doesn't
349 // have access to trusted proxy configuration, and an attacker could
350 // inject a malicious realm URL to phish for credentials.
351 realmHost := host
352
353 // Detect scheme from TLS state only (not from headers that can be spoofed)
354 scheme := "http"
355 if r.TLS != nil {
356 scheme = "https"
357 }
358
359 // SECURITY: Sanitize the host to prevent header injection.
360 // Remove any characters that could break the WWW-Authenticate header format.
361 realmHost = sanitizeHeaderValue(realmHost)
362
363 realm := scheme + "://" + realmHost + "/auth/token"
364 w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`)
365 default:
366 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
367 }
368
369 w.Header().Set("Content-Type", "application/json")
370 w.WriteHeader(http.StatusUnauthorized)
371 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
372
373 log.Warn().
374 Str(zerowrap.FieldLayer, "adapter").
375 Str(zerowrap.FieldAdapter, "http").
376 Str(zerowrap.FieldMethod, r.Method).
377 Str(zerowrap.FieldPath, r.URL.Path).
378 Str(zerowrap.FieldClientIP, r.RemoteAddr).
379 Msg("unauthorized registry access attempt")
380}