v2.31.0-dev22 8d342c4

auth.go RawHistoryFolder

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}

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