v2.30.0-pre 7341e0e

auth.go RawHistoryFolder

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}

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