detached fc00677

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 (
1212 "github.com/bnema/zerowrap"
1313
1414 "github.com/bnema/gordon/internal/adapters/dto"
15+ "github.com/bnema/gordon/internal/adapters/in/http/httputil"
1516 "github.com/bnema/gordon/internal/boundaries/in"
1617 "github.com/bnema/gordon/internal/domain"
1718 )
@@ -269,7 +270,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
269270 }
270271
271272 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) {
273274 log.Debug().Str("username", username).Msg("internal registry auth accepted")
274275 return true, nil
275276 }
@@ -350,16 +351,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b
350351 return false
351352 }
352353
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-
363354 // parseRequestedScopes extracts and validates scope parameters from the request.
364355 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions
365356 // 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, nil275 return true, nil
275 }276 }
@@ -350,16 +351,6 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b
350 return false351 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:actions355 // 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:pull356 // 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 (
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/mock"
1414
15+ "github.com/bnema/gordon/internal/adapters/in/http/httputil"
1516 "github.com/bnema/gordon/internal/boundaries/in/mocks"
1617 "github.com/bnema/gordon/internal/domain"
1718 )
@@ -446,7 +447,7 @@ func TestIsLocalhostRequest(t *testing.T) {
446447 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
447448 req.RemoteAddr = tt.remoteAddr
448449
449- result := isLocalhostRequest(req)
450+ result := httputil.IsLocalhostRequest(req)
450451
451452 assert.Equal(t, tt.want, result)
452453 })
@@ -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.remoteAddr448 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 (
1010 "github.com/bnema/zerowrap"
1111
1212 "github.com/bnema/gordon/internal/adapters/dto"
13+ "github.com/bnema/gordon/internal/adapters/in/http/httputil"
1314 "github.com/bnema/gordon/internal/boundaries/in"
1415 "github.com/bnema/gordon/internal/domain"
1516 )
@@ -125,7 +126,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
125126 return func(next http.Handler) http.Handler {
126127 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
127128 // Allow localhost requests only with internal instance credentials.
128- if isLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
129+ if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) {
129130 log.Debug().
130131 Str(zerowrap.FieldLayer, "adapter").
131132 Str(zerowrap.FieldAdapter, "http").
@@ -296,19 +297,6 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
296297 return true, claims
297298 }
298299
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-
312300 func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
313301 if internalAuth.Username == "" || internalAuth.Password == "" {
314302 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, claims297 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 false302 return false

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