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