v2.30.1 1983e1b

auth.go RawHistoryFolder

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}

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