detached 9b0b996

fix(security): gate admin host and disable local control-plane under auth

Restrict proxy /admin routes to the configured registry host (plus loopback) and block local in-process CLI control-plane usage when auth.enabled=true. Add focused regression tests for host gating and local resolver auth enforcement.
commit
9b0b996da0d455155c29129c7eeec907ba5331cd
author
brice · · 2026-02-09 06:29 UTC
parent
1dae7a9
signature
unsigned
5 files · +159 -5
M internal/adapters/in/cli/controlplane_resolver.go +4 -0
@@ -34,6 +34,10 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
3434 if err != nil {
3535 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
3636 }
37+ if kernel.AuthEnabled() {
38+ kernel.Close()
39+ return nil, fmt.Errorf("local control plane is disabled when auth.enabled=true; use --remote with token")
40+ }
3741
3842 return &controlPlaneHandle{
3943 plane: NewLocalControlPlane(kernel),
@@ -34,6 +34,10 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
34 if err != nil {34 if err != nil {
35 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)35 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
36 }36 }
37+ if kernel.AuthEnabled() {
38+ kernel.Close()
39+ return nil, fmt.Errorf("local control plane is disabled when auth.enabled=true; use --remote with token")
40+ }
37 41
38 return &controlPlaneHandle{42 return &controlPlaneHandle{
39 plane: NewLocalControlPlane(kernel),43 plane: NewLocalControlPlane(kernel),
A internal/adapters/in/cli/controlplane_resolver_test.go +74 -0
@@ -0,0 +1,74 @@
1+package cli
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) {
11+ tmpDir := t.TempDir()
12+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
13+ t.Setenv("GORDON_REMOTE", "")
14+ t.Setenv("GORDON_TOKEN", "")
15+
16+ remoteFlag = ""
17+ tokenFlag = ""
18+ insecureTLSFlag = false
19+
20+ configPath := filepath.Join(tmpDir, "gordon.toml")
21+ err := os.WriteFile(configPath, []byte(`[server]
22+gordon_domain = "gordon.local"
23+data_dir = "`+filepath.Join(tmpDir, "data")+`"
24+
25+[auth]
26+enabled = true
27+`), 0o600)
28+ if err != nil {
29+ t.Fatalf("failed to write config: %v", err)
30+ }
31+
32+ handle, err := resolveControlPlane(configPath)
33+ if err == nil {
34+ if handle != nil {
35+ handle.close()
36+ }
37+ t.Fatalf("expected error when auth.enabled=true and no remote target")
38+ }
39+ if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") {
40+ t.Fatalf("unexpected error: %v", err)
41+ }
42+}
43+
44+func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {
45+ tmpDir := t.TempDir()
46+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
47+ t.Setenv("GORDON_REMOTE", "")
48+ t.Setenv("GORDON_TOKEN", "")
49+
50+ remoteFlag = ""
51+ tokenFlag = ""
52+ insecureTLSFlag = false
53+
54+ configPath := filepath.Join(tmpDir, "gordon.toml")
55+ err := os.WriteFile(configPath, []byte(`[server]
56+gordon_domain = "gordon.local"
57+data_dir = "`+filepath.Join(tmpDir, "data")+`"
58+
59+[auth]
60+enabled = false
61+`), 0o600)
62+ if err != nil {
63+ t.Fatalf("failed to write config: %v", err)
64+ }
65+
66+ handle, err := resolveControlPlane(configPath)
67+ if err != nil {
68+ t.Fatalf("expected local control-plane handle, got error: %v", err)
69+ }
70+ if handle == nil || handle.plane == nil {
71+ t.Fatalf("expected non-nil local control-plane handle")
72+ }
73+ handle.close()
74+}
@@ -0,0 +1,74 @@
1+package cli
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) {
11+ tmpDir := t.TempDir()
12+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
13+ t.Setenv("GORDON_REMOTE", "")
14+ t.Setenv("GORDON_TOKEN", "")
15+
16+ remoteFlag = ""
17+ tokenFlag = ""
18+ insecureTLSFlag = false
19+
20+ configPath := filepath.Join(tmpDir, "gordon.toml")
21+ err := os.WriteFile(configPath, []byte(`[server]
22+gordon_domain = "gordon.local"
23+data_dir = "`+filepath.Join(tmpDir, "data")+`"
24+
25+[auth]
26+enabled = true
27+`), 0o600)
28+ if err != nil {
29+ t.Fatalf("failed to write config: %v", err)
30+ }
31+
32+ handle, err := resolveControlPlane(configPath)
33+ if err == nil {
34+ if handle != nil {
35+ handle.close()
36+ }
37+ t.Fatalf("expected error when auth.enabled=true and no remote target")
38+ }
39+ if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") {
40+ t.Fatalf("unexpected error: %v", err)
41+ }
42+}
43+
44+func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {
45+ tmpDir := t.TempDir()
46+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
47+ t.Setenv("GORDON_REMOTE", "")
48+ t.Setenv("GORDON_TOKEN", "")
49+
50+ remoteFlag = ""
51+ tokenFlag = ""
52+ insecureTLSFlag = false
53+
54+ configPath := filepath.Join(tmpDir, "gordon.toml")
55+ err := os.WriteFile(configPath, []byte(`[server]
56+gordon_domain = "gordon.local"
57+data_dir = "`+filepath.Join(tmpDir, "data")+`"
58+
59+[auth]
60+enabled = false
61+`), 0o600)
62+ if err != nil {
63+ t.Fatalf("failed to write config: %v", err)
64+ }
65+
66+ handle, err := resolveControlPlane(configPath)
67+ if err != nil {
68+ t.Fatalf("expected local control-plane handle, got error: %v", err)
69+ }
70+ if handle == nil || handle.plane == nil {
71+ t.Fatalf("expected non-nil local control-plane handle")
72+ }
73+ handle.close()
74+}
M internal/app/kernel.go +8 -3
@@ -15,6 +15,7 @@ import (
1515 //
1616 // It intentionally does not start HTTP servers or register signal handlers.
1717 type Kernel struct {
18+ authEnabled bool
1819 configSvc in.ConfigService
1920 secretSvc in.SecretService
2021 containerSvc in.ContainerService
@@ -47,6 +48,7 @@ func NewKernel(configPath string) (*Kernel, error) {
4748 // as remote mode without going through HTTP admin endpoints.
4849 if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil {
4950 return &Kernel{
51+ authEnabled: cfg.Auth.Enabled,
5052 configSvc: svc.configSvc,
5153 secretSvc: svc.secretSvc,
5254 containerSvc: svc.containerSvc,
@@ -75,9 +77,10 @@ func NewKernel(configPath string) (*Kernel, error) {
7577 secretSvc := secretsusecase.NewService(domainSecretStore, log)
7678
7779 return &Kernel{
78- configSvc: configSvc,
79- secretSvc: secretSvc,
80- cleanup: cleanup,
80+ authEnabled: cfg.Auth.Enabled,
81+ configSvc: configSvc,
82+ secretSvc: secretSvc,
83+ cleanup: cleanup,
8184 }, nil
8285 }
8386
@@ -102,3 +105,5 @@ func (k *Kernel) Registry() in.RegistryService { return k.registrySvc }
102105 func (k *Kernel) Health() in.HealthService { return k.healthSvc }
103106
104107 func (k *Kernel) Logs() in.LogService { return k.logSvc }
108+
109+func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled }
@@ -15,6 +15,7 @@ import (
15 //15 //
16 // It intentionally does not start HTTP servers or register signal handlers.16 // It intentionally does not start HTTP servers or register signal handlers.
17 type Kernel struct {17 type Kernel struct {
18+ authEnabled bool
18 configSvc in.ConfigService19 configSvc in.ConfigService
19 secretSvc in.SecretService20 secretSvc in.SecretService
20 containerSvc in.ContainerService21 containerSvc in.ContainerService
@@ -47,6 +48,7 @@ func NewKernel(configPath string) (*Kernel, error) {
47 // as remote mode without going through HTTP admin endpoints.48 // as remote mode without going through HTTP admin endpoints.
48 if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil {49 if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil {
49 return &Kernel{50 return &Kernel{
51+ authEnabled: cfg.Auth.Enabled,
50 configSvc: svc.configSvc,52 configSvc: svc.configSvc,
51 secretSvc: svc.secretSvc,53 secretSvc: svc.secretSvc,
52 containerSvc: svc.containerSvc,54 containerSvc: svc.containerSvc,
@@ -75,9 +77,10 @@ func NewKernel(configPath string) (*Kernel, error) {
75 secretSvc := secretsusecase.NewService(domainSecretStore, log)77 secretSvc := secretsusecase.NewService(domainSecretStore, log)
76 78
77 return &Kernel{79 return &Kernel{
78- configSvc: configSvc,80+ authEnabled: cfg.Auth.Enabled,
79- secretSvc: secretSvc,81+ configSvc: configSvc,
80- cleanup: cleanup,82+ secretSvc: secretSvc,
83+ cleanup: cleanup,
81 }, nil84 }, nil
82 }85 }
83 86
@@ -102,3 +105,5 @@ func (k *Kernel) Registry() in.RegistryService { return k.registrySvc }
102 func (k *Kernel) Health() in.HealthService { return k.healthSvc }105 func (k *Kernel) Health() in.HealthService { return k.healthSvc }
103 106
104 func (k *Kernel) Logs() in.LogService { return k.logSvc }107 func (k *Kernel) Logs() in.LogService { return k.logSvc }
108+
109+func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled }
M internal/app/run.go +48 -1
@@ -1504,7 +1504,54 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf
15041504
15051505 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
15061506 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1507- proxyMux.Handle("/admin/", adminWithMiddleware)
1507+ proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log))
1508+}
1509+
1510+func adminHostOnly(next http.Handler, allowedHost string, log zerowrap.Logger) http.Handler {
1511+ canonicalAllowedHost := canonicalHost(allowedHost)
1512+
1513+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1514+ host := canonicalHost(r.Host)
1515+ if host == "" {
1516+ host = canonicalHost(r.URL.Host)
1517+ }
1518+
1519+ if !isLoopbackHost(host) && (canonicalAllowedHost == "" || !strings.EqualFold(host, canonicalAllowedHost)) {
1520+ log.Warn().
1521+ Str("path", r.URL.Path).
1522+ Str("host", r.Host).
1523+ Str("allowed_host", canonicalAllowedHost).
1524+ Msg("blocked admin request on unauthorized host")
1525+ http.NotFound(w, r)
1526+ return
1527+ }
1528+
1529+ next.ServeHTTP(w, r)
1530+ })
1531+}
1532+
1533+func canonicalHost(rawHost string) string {
1534+ host := strings.TrimSpace(rawHost)
1535+ if host == "" {
1536+ return ""
1537+ }
1538+
1539+ if splitHost, _, err := net.SplitHostPort(host); err == nil {
1540+ host = splitHost
1541+ }
1542+
1543+ host = strings.TrimPrefix(host, "[")
1544+ host = strings.TrimSuffix(host, "]")
1545+ return strings.ToLower(host)
1546+}
1547+
1548+func isLoopbackHost(host string) bool {
1549+ if host == "localhost" {
1550+ return true
1551+ }
1552+
1553+ ip := net.ParseIP(host)
1554+ return ip != nil && ip.IsLoopback()
15081555 }
15091556
15101557 // runServers starts the HTTP servers and waits for shutdown.
@@ -1504,7 +1504,54 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf
1504 1504
1505 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)1505 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
1506 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))1506 registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log))
1507- proxyMux.Handle("/admin/", adminWithMiddleware)1507+ proxyMux.Handle("/admin/", adminHostOnly(adminWithMiddleware, cfg.Server.RegistryDomain, log))
1508+}
1509+
1510+func adminHostOnly(next http.Handler, allowedHost string, log zerowrap.Logger) http.Handler {
1511+ canonicalAllowedHost := canonicalHost(allowedHost)
1512+
1513+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1514+ host := canonicalHost(r.Host)
1515+ if host == "" {
1516+ host = canonicalHost(r.URL.Host)
1517+ }
1518+
1519+ if !isLoopbackHost(host) && (canonicalAllowedHost == "" || !strings.EqualFold(host, canonicalAllowedHost)) {
1520+ log.Warn().
1521+ Str("path", r.URL.Path).
1522+ Str("host", r.Host).
1523+ Str("allowed_host", canonicalAllowedHost).
1524+ Msg("blocked admin request on unauthorized host")
1525+ http.NotFound(w, r)
1526+ return
1527+ }
1528+
1529+ next.ServeHTTP(w, r)
1530+ })
1531+}
1532+
1533+func canonicalHost(rawHost string) string {
1534+ host := strings.TrimSpace(rawHost)
1535+ if host == "" {
1536+ return ""
1537+ }
1538+
1539+ if splitHost, _, err := net.SplitHostPort(host); err == nil {
1540+ host = splitHost
1541+ }
1542+
1543+ host = strings.TrimPrefix(host, "[")
1544+ host = strings.TrimSuffix(host, "]")
1545+ return strings.ToLower(host)
1546+}
1547+
1548+func isLoopbackHost(host string) bool {
1549+ if host == "localhost" {
1550+ return true
1551+ }
1552+
1553+ ip := net.ParseIP(host)
1554+ return ip != nil && ip.IsLoopback()
1508 }1555 }
1509 1556
1510 // runServers starts the HTTP servers and waits for shutdown.1557 // runServers starts the HTTP servers and waits for shutdown.
M internal/app/run_http_handlers_test.go +25 -1
@@ -67,16 +67,18 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
6767 }
6868 }
6969
70-func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxy(t *testing.T) {
70+func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxyForRegistryHost(t *testing.T) {
7171 t.Parallel()
7272
7373 cfg := Config{}
7474 cfg.Auth.Enabled = true
75+ cfg.Server.RegistryDomain = "gordon.local"
7576
7677 svc := &services{adminHandler: &adminhttp.Handler{}}
7778 _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
7879
7980 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
81+ req.Host = "gordon.local"
8082 req.RemoteAddr = "192.0.2.30:12345"
8183
8284 rec := httptest.NewRecorder()
@@ -87,6 +89,28 @@ func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxy(t *testing.T) {
8789 }
8890 }
8991
92+func TestCreateHTTPHandlers_AuthEnabled_BlocksAdminOnNonRegistryHost(t *testing.T) {
93+ t.Parallel()
94+
95+ cfg := Config{}
96+ cfg.Auth.Enabled = true
97+ cfg.Server.RegistryDomain = "gordon.local"
98+
99+ svc := &services{adminHandler: &adminhttp.Handler{}}
100+ _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
101+
102+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
103+ req.Host = "app.example.com"
104+ req.RemoteAddr = "192.0.2.30:12345"
105+
106+ rec := httptest.NewRecorder()
107+ proxyHandler.ServeHTTP(rec, req)
108+
109+ if rec.Code != http.StatusNotFound {
110+ t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
111+ }
112+}
113+
90114 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
91115 t.Parallel()
92116
@@ -67,16 +67,18 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
67 }67 }
68 }68 }
69 69
70-func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxy(t *testing.T) {70+func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxyForRegistryHost(t *testing.T) {
71 t.Parallel()71 t.Parallel()
72 72
73 cfg := Config{}73 cfg := Config{}
74 cfg.Auth.Enabled = true74 cfg.Auth.Enabled = true
75+ cfg.Server.RegistryDomain = "gordon.local"
75 76
76 svc := &services{adminHandler: &adminhttp.Handler{}}77 svc := &services{adminHandler: &adminhttp.Handler{}}
77 _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())78 _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
78 79
79 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)80 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
81+ req.Host = "gordon.local"
80 req.RemoteAddr = "192.0.2.30:12345"82 req.RemoteAddr = "192.0.2.30:12345"
81 83
82 rec := httptest.NewRecorder()84 rec := httptest.NewRecorder()
@@ -87,6 +89,28 @@ func TestCreateHTTPHandlers_AuthEnabled_ExposesAdminOnProxy(t *testing.T) {
87 }89 }
88 }90 }
89 91
92+func TestCreateHTTPHandlers_AuthEnabled_BlocksAdminOnNonRegistryHost(t *testing.T) {
93+ t.Parallel()
94+
95+ cfg := Config{}
96+ cfg.Auth.Enabled = true
97+ cfg.Server.RegistryDomain = "gordon.local"
98+
99+ svc := &services{adminHandler: &adminhttp.Handler{}}
100+ _, proxyHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
101+
102+ req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
103+ req.Host = "app.example.com"
104+ req.RemoteAddr = "192.0.2.30:12345"
105+
106+ rec := httptest.NewRecorder()
107+ proxyHandler.ServeHTTP(rec, req)
108+
109+ if rec.Code != http.StatusNotFound {
110+ t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
111+ }
112+}
113+
90 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {114 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
91 t.Parallel()115 t.Parallel()
92 116

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