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