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