refactor(http): consolidate isLocalhostRequest into shared httputil package
- commit
- fc00677614d8b05e574e36579c52007fca2fbdaa
- author
- bnema · · 2026-02-24 21:47 UTC
- parent
- 61c0290
- signature
- unsigned
5 files · +56 -26
M internal/adapters/in/http/auth/handler.go +2 -11
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 15 | 16 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 16 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 17 | 18 | ) | |
| @@ -269,7 +270,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 269 | 270 | } | |
| 270 | 271 | ||
| 271 | 272 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | |
| 272 | - | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 273 | + | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 273 | 274 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |
| 274 | 275 | return true, nil | |
| 275 | 276 | } | |
| @@ -350,16 +351,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b | |||
| 350 | 351 | return false | |
| 351 | 352 | } | |
| 352 | 353 | ||
| 353 | - | // isLocalhostRequest checks if the request originates from localhost. | |
| 354 | - | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |
| 355 | - | func isLocalhostRequest(r *http.Request) bool { | |
| 356 | - | host := r.RemoteAddr | |
| 357 | - | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345" | |
| 358 | - | return strings.HasPrefix(host, "127.") || | |
| 359 | - | strings.HasPrefix(host, "[::1]") || | |
| 360 | - | strings.HasPrefix(host, "::1") | |
| 361 | - | } | |
| 362 | - | ||
| 363 | 354 | // parseRequestedScopes extracts and validates scope parameters from the request. | |
| 364 | 355 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | |
| 365 | 356 | // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/gordon/internal/adapters/dto" | 14 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |||
| 15 | "github.com/bnema/gordon/internal/boundaries/in" | 16 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 16 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 17 | ) | 18 | ) | ||
| @@ -269,7 +270,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 269 | } | 270 | } | ||
| 270 | 271 | ||||
| 271 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | 272 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | ||
| 272 | - | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | 273 | + | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { |
| 273 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | 274 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | ||
| 274 | return true, nil | 275 | return true, nil | ||
| 275 | } | 276 | } | ||
| @@ -350,16 +351,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b | |||||
| 350 | return false | 351 | return false | ||
| 351 | } | 352 | } | ||
| 352 | 353 | ||||
| 353 | - | // isLocalhostRequest checks if the request originates from localhost. | |||
| 354 | - | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |||
| 355 | - | func isLocalhostRequest(r *http.Request) bool { | |||
| 356 | - | host := r.RemoteAddr | |||
| 357 | - | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345" | |||
| 358 | - | return strings.HasPrefix(host, "127.") || | |||
| 359 | - | strings.HasPrefix(host, "[::1]") || | |||
| 360 | - | strings.HasPrefix(host, "::1") | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | // parseRequestedScopes extracts and validates scope parameters from the request. | 354 | // parseRequestedScopes extracts and validates scope parameters from the request. | ||
| 364 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | 355 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | ||
| 365 | // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | 356 | // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull | ||
M internal/adapters/in/http/auth/handler_test.go +2 -1
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/assert" | |
| 13 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 15 | 16 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 16 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 17 | 18 | ) | |
| @@ -446,7 +447,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||
| 446 | 447 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 447 | 448 | req.RemoteAddr = tt.remoteAddr | |
| 448 | 449 | ||
| 449 | - | result := isLocalhostRequest(req) | |
| 450 | + | result := httputil.IsLocalhostRequest(req) | |
| 450 | 451 | ||
| 451 | 452 | assert.Equal(t, tt.want, result) | |
| 452 | 453 | }) | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | 14 | ||||
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |||
| 15 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | 16 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 16 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 17 | ) | 18 | ) | ||
| @@ -446,7 +447,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||||
| 446 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | 447 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | ||
| 447 | req.RemoteAddr = tt.remoteAddr | 448 | req.RemoteAddr = tt.remoteAddr | ||
| 448 | 449 | ||||
| 449 | - | result := isLocalhostRequest(req) | 450 | + | result := httputil.IsLocalhostRequest(req) |
| 450 | 451 | ||||
| 451 | assert.Equal(t, tt.want, result) | 452 | assert.Equal(t, tt.want, result) | ||
| 452 | }) | 453 | }) | ||
A internal/adapters/in/http/httputil/localhost.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package httputil | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // IsLocalhostRequest reports whether the request originates from localhost. | |
| 9 | + | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |
| 10 | + | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345". | |
| 11 | + | func IsLocalhostRequest(r *http.Request) bool { | |
| 12 | + | host := r.RemoteAddr | |
| 13 | + | return strings.HasPrefix(host, "127.") || | |
| 14 | + | strings.HasPrefix(host, "[::1]") || | |
| 15 | + | strings.HasPrefix(host, "::1") | |
| 16 | + | } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package httputil | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // IsLocalhostRequest reports whether the request originates from localhost. | |||
| 9 | + | // SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable). | |||
| 10 | + | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345". | |||
| 11 | + | func IsLocalhostRequest(r *http.Request) bool { | |||
| 12 | + | host := r.RemoteAddr | |||
| 13 | + | return strings.HasPrefix(host, "127.") || | |||
| 14 | + | strings.HasPrefix(host, "[::1]") || | |||
| 15 | + | strings.HasPrefix(host, "::1") | |||
| 16 | + | } | |||
A internal/adapters/in/http/httputil/localhost_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package httputil_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "net/http/httptest" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestIsLocalhostRequest(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | remoteAddr string | |
| 15 | + | want bool | |
| 16 | + | }{ | |
| 17 | + | {"ipv4 loopback", "127.0.0.1:12345", true}, | |
| 18 | + | {"ipv4 loopback other", "127.0.0.2:9000", true}, | |
| 19 | + | {"ipv6 loopback bracketed", "[::1]:12345", true}, | |
| 20 | + | {"ipv6 loopback plain", "::1:12345", true}, | |
| 21 | + | {"external ipv4", "192.168.1.1:12345", false}, | |
| 22 | + | {"external ipv6", "[2001:db8::1]:12345", false}, | |
| 23 | + | {"empty", "", false}, | |
| 24 | + | } | |
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 28 | + | req.RemoteAddr = tt.remoteAddr | |
| 29 | + | if got := httputil.IsLocalhostRequest(req); got != tt.want { | |
| 30 | + | t.Errorf("IsLocalhostRequest(%q) = %v, want %v", tt.remoteAddr, got, tt.want) | |
| 31 | + | } | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package httputil_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | "net/http/httptest" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestIsLocalhostRequest(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | remoteAddr string | |||
| 15 | + | want bool | |||
| 16 | + | }{ | |||
| 17 | + | {"ipv4 loopback", "127.0.0.1:12345", true}, | |||
| 18 | + | {"ipv4 loopback other", "127.0.0.2:9000", true}, | |||
| 19 | + | {"ipv6 loopback bracketed", "[::1]:12345", true}, | |||
| 20 | + | {"ipv6 loopback plain", "::1:12345", true}, | |||
| 21 | + | {"external ipv4", "192.168.1.1:12345", false}, | |||
| 22 | + | {"external ipv6", "[2001:db8::1]:12345", false}, | |||
| 23 | + | {"empty", "", false}, | |||
| 24 | + | } | |||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 28 | + | req.RemoteAddr = tt.remoteAddr | |||
| 29 | + | if got := httputil.IsLocalhostRequest(req); got != tt.want { | |||
| 30 | + | t.Errorf("IsLocalhostRequest(%q) = %v, want %v", tt.remoteAddr, got, tt.want) | |||
| 31 | + | } | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/adapters/in/http/middleware/auth.go +2 -14
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/zerowrap" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 13 | 14 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 14 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 16 | ) | |
| @@ -125,7 +126,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 125 | 126 | return func(next http.Handler) http.Handler { | |
| 126 | 127 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 127 | 128 | // Allow localhost requests only with internal instance credentials. | |
| 128 | - | if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | |
| 129 | + | if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | |
| 129 | 130 | log.Debug(). | |
| 130 | 131 | Str(zerowrap.FieldLayer, "adapter"). | |
| 131 | 132 | Str(zerowrap.FieldAdapter, "http"). | |
| @@ -296,19 +297,6 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 296 | 297 | return true, claims | |
| 297 | 298 | } | |
| 298 | 299 | ||
| 299 | - | // isLocalhostRequest checks if the request originates from localhost. | |
| 300 | - | // This is used to allow Gordon to pull from its own registry with internal auth. | |
| 301 | - | func isLocalhostRequest(r *http.Request) bool { | |
| 302 | - | host := r.RemoteAddr | |
| 303 | - | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345" | |
| 304 | - | if strings.HasPrefix(host, "127.") || | |
| 305 | - | strings.HasPrefix(host, "[::1]") || | |
| 306 | - | strings.HasPrefix(host, "::1") { | |
| 307 | - | return true | |
| 308 | - | } | |
| 309 | - | return false | |
| 310 | - | } | |
| 311 | - | ||
| 312 | 300 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | |
| 313 | 301 | if internalAuth.Username == "" || internalAuth.Password == "" { | |
| 314 | 302 | return false | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/gordon/internal/adapters/dto" | 12 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |||
| 13 | "github.com/bnema/gordon/internal/boundaries/in" | 14 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 14 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| 15 | ) | 16 | ) | ||
| @@ -125,7 +126,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 125 | return func(next http.Handler) http.Handler { | 126 | return func(next http.Handler) http.Handler { | ||
| 126 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 127 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 127 | // Allow localhost requests only with internal instance credentials. | 128 | // Allow localhost requests only with internal instance credentials. | ||
| 128 | - | if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | 129 | + | if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { |
| 129 | log.Debug(). | 130 | log.Debug(). | ||
| 130 | Str(zerowrap.FieldLayer, "adapter"). | 131 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 131 | Str(zerowrap.FieldAdapter, "http"). | 132 | Str(zerowrap.FieldAdapter, "http"). | ||
| @@ -296,19 +297,6 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 296 | return true, claims | 297 | return true, claims | ||
| 297 | } | 298 | } | ||
| 298 | 299 | ||||
| 299 | - | // isLocalhostRequest checks if the request originates from localhost. | |||
| 300 | - | // This is used to allow Gordon to pull from its own registry with internal auth. | |||
| 301 | - | func isLocalhostRequest(r *http.Request) bool { | |||
| 302 | - | host := r.RemoteAddr | |||
| 303 | - | // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345" | |||
| 304 | - | if strings.HasPrefix(host, "127.") || | |||
| 305 | - | strings.HasPrefix(host, "[::1]") || | |||
| 306 | - | strings.HasPrefix(host, "::1") { | |||
| 307 | - | return true | |||
| 308 | - | } | |||
| 309 | - | return false | |||
| 310 | - | } | |||
| 311 | - | ||||
| 312 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | 300 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | ||
| 313 | if internalAuth.Username == "" || internalAuth.Password == "" { | 301 | if internalAuth.Username == "" || internalAuth.Password == "" { | ||
| 314 | return false | 302 | return false | ||