fix: redact config and secure unsafe secrets
- commit
- 7f90f829f4f65229842ce219a54f0da70e1b61ee
- author
- brice · · 2026-04-25 09:54 UTC
- parent
- 3520c0f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +222 -96
M docs/cli/config.md +8 -7
| @@ -17,8 +17,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||
| 17 | 17 | ||
| 18 | 18 | ## gordon config show | |
| 19 | 19 | ||
| 20 | - | Display the full Gordon server configuration including server settings, | |
| 21 | - | auto-route, network isolation, routes, and external routes. | |
| 20 | + | Display the Gordon server configuration including server settings, | |
| 21 | + | auto-route, network isolation, routes, and external route domains. Sensitive filesystem paths and upstream external route targets are redacted by default. | |
| 22 | 22 | ||
| 23 | 23 | ```bash | |
| 24 | 24 | gordon config show | |
| @@ -39,8 +39,7 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |||
| 39 | 39 | "server": { | |
| 40 | 40 | "port": 1111, | |
| 41 | 41 | "registry_port": 5000, | |
| 42 | - | "registry_domain": "reg.example.com", | |
| 43 | - | "data_dir": "/var/lib/gordon" | |
| 42 | + | "registry_domain": "reg.example.com" | |
| 44 | 43 | }, | |
| 45 | 44 | "auto_route": { | |
| 46 | 45 | "enabled": true, | |
| @@ -53,12 +52,14 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |||
| 53 | 52 | "routes": [ | |
| 54 | 53 | {"domain": "app.example.com", "image": "myapp:latest"} | |
| 55 | 54 | ], | |
| 56 | - | "external_routes": { | |
| 57 | - | "reg.example.com": "localhost:5000" | |
| 58 | - | } | |
| 55 | + | "external_routes": [ | |
| 56 | + | {"domain": "reg.example.com"} | |
| 57 | + | ] | |
| 59 | 58 | } | |
| 60 | 59 | ``` | |
| 61 | 60 | ||
| 61 | + | External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout. | |
| 62 | + | ||
| 62 | 63 | ### Auto-Route Allowed Domains | |
| 63 | 64 | ||
| 64 | 65 | The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md). | |
| @@ -17,8 +17,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||||
| 17 | 17 | ||||
| 18 | ## gordon config show | 18 | ## gordon config show | ||
| 19 | 19 | ||||
| 20 | - | Display the full Gordon server configuration including server settings, | 20 | + | Display the Gordon server configuration including server settings, |
| 21 | - | auto-route, network isolation, routes, and external routes. | 21 | + | auto-route, network isolation, routes, and external route domains. Sensitive filesystem paths and upstream external route targets are redacted by default. |
| 22 | 22 | ||||
| 23 | ```bash | 23 | ```bash | ||
| 24 | gordon config show | 24 | gordon config show | ||
| @@ -39,8 +39,7 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |||||
| 39 | "server": { | 39 | "server": { | ||
| 40 | "port": 1111, | 40 | "port": 1111, | ||
| 41 | "registry_port": 5000, | 41 | "registry_port": 5000, | ||
| 42 | - | "registry_domain": "reg.example.com", | 42 | + | "registry_domain": "reg.example.com" |
| 43 | - | "data_dir": "/var/lib/gordon" | |||
| 44 | }, | 43 | }, | ||
| 45 | "auto_route": { | 44 | "auto_route": { | ||
| 46 | "enabled": true, | 45 | "enabled": true, | ||
| @@ -53,12 +52,14 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN | |||||
| 53 | "routes": [ | 52 | "routes": [ | ||
| 54 | {"domain": "app.example.com", "image": "myapp:latest"} | 53 | {"domain": "app.example.com", "image": "myapp:latest"} | ||
| 55 | ], | 54 | ], | ||
| 56 | - | "external_routes": { | 55 | + | "external_routes": [ |
| 57 | - | "reg.example.com": "localhost:5000" | 56 | + | {"domain": "reg.example.com"} |
| 58 | - | } | 57 | + | ] |
| 59 | } | 58 | } | ||
| 60 | ``` | 59 | ``` | ||
| 61 | 60 | ||||
| 61 | + | External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout. | |||
| 62 | + | ||||
| 62 | ### Auto-Route Allowed Domains | 63 | ### Auto-Route Allowed Domains | ||
| 63 | 64 | ||||
| 64 | The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md). | 65 | The `auto_route.allowed_domains` field lists domain patterns that auto-route may assign to containers. Manage this list with [`gordon autoroute allow`](./autoroute.md). | ||
M internal/adapters/dto/admin_config.go +2 -2
| @@ -14,7 +14,7 @@ type ServerConfig struct { | |||
| 14 | 14 | Port int `json:"port"` | |
| 15 | 15 | RegistryPort int `json:"registry_port"` | |
| 16 | 16 | RegistryDomain string `json:"registry_domain"` | |
| 17 | - | DataDir string `json:"data_dir"` | |
| 17 | + | DataDir string `json:"data_dir,omitempty"` | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | // AutoRouteConfig represents auto-route config details. | |
| @@ -31,5 +31,5 @@ type NetworkIsolationConfig struct { | |||
| 31 | 31 | // ExternalRoute represents an external route config entry. | |
| 32 | 32 | type ExternalRoute struct { | |
| 33 | 33 | Domain string `json:"domain"` | |
| 34 | - | Target string `json:"target"` | |
| 34 | + | Target string `json:"target,omitempty"` | |
| 35 | 35 | } | |
| @@ -14,7 +14,7 @@ type ServerConfig struct { | |||||
| 14 | Port int `json:"port"` | 14 | Port int `json:"port"` | ||
| 15 | RegistryPort int `json:"registry_port"` | 15 | RegistryPort int `json:"registry_port"` | ||
| 16 | RegistryDomain string `json:"registry_domain"` | 16 | RegistryDomain string `json:"registry_domain"` | ||
| 17 | - | DataDir string `json:"data_dir"` | 17 | + | DataDir string `json:"data_dir,omitempty"` |
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | // AutoRouteConfig represents auto-route config details. | 20 | // AutoRouteConfig represents auto-route config details. | ||
| @@ -31,5 +31,5 @@ type NetworkIsolationConfig struct { | |||||
| 31 | // ExternalRoute represents an external route config entry. | 31 | // ExternalRoute represents an external route config entry. | ||
| 32 | type ExternalRoute struct { | 32 | type ExternalRoute struct { | ||
| 33 | Domain string `json:"domain"` | 33 | Domain string `json:"domain"` | ||
| 34 | - | Target string `json:"target"` | 34 | + | Target string `json:"target,omitempty"` |
| 35 | } | 35 | } | ||
M internal/adapters/in/cli/config.go +13 -10
| @@ -94,8 +94,10 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error { | |||
| 94 | 94 | if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil { | |
| 95 | 95 | return err | |
| 96 | 96 | } | |
| 97 | - | if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil { | |
| 98 | - | return err | |
| 97 | + | if config.Server.DataDir != "" { | |
| 98 | + | if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 99 | 101 | } | |
| 100 | 102 | if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil { | |
| 101 | 103 | return err | |
| @@ -155,15 +157,16 @@ func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error { | |||
| 155 | 157 | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { | |
| 156 | 158 | return err | |
| 157 | 159 | } | |
| 158 | - | domains := make([]string, 0, len(config.ExternalRoutes)) | |
| 159 | - | for d := range config.ExternalRoutes { | |
| 160 | - | domains = append(domains, d) | |
| 161 | - | } | |
| 162 | - | sort.Strings(domains) | |
| 160 | + | routes := append([]remote.ExternalRoute(nil), config.ExternalRoutes...) | |
| 161 | + | sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain }) | |
| 163 | 162 | ||
| 164 | - | rows := make([][]string, 0, len(domains)) | |
| 165 | - | for _, d := range domains { | |
| 166 | - | rows = append(rows, []string{d, config.ExternalRoutes[d]}) | |
| 163 | + | rows := make([][]string, 0, len(routes)) | |
| 164 | + | for _, route := range routes { | |
| 165 | + | target := route.Target | |
| 166 | + | if target == "" { | |
| 167 | + | target = "[redacted]" | |
| 168 | + | } | |
| 169 | + | rows = append(rows, []string{route.Domain, target}) | |
| 167 | 170 | } | |
| 168 | 171 | table := components.NewTable( | |
| 169 | 172 | components.WithColumns([]components.TableColumn{ | |
| @@ -94,8 +94,10 @@ func renderConfigSummary(out io.Writer, config *remote.Config) error { | |||||
| 94 | if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil { | 94 | if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil { | ||
| 95 | return err | 95 | return err | ||
| 96 | } | 96 | } | ||
| 97 | - | if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil { | 97 | + | if config.Server.DataDir != "" { |
| 98 | - | return err | 98 | + | if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil { |
| 99 | + | return err | |||
| 100 | + | } | |||
| 99 | } | 101 | } | ||
| 100 | if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil { | 102 | if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil { | ||
| 101 | return err | 103 | return err | ||
| @@ -155,15 +157,16 @@ func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error { | |||||
| 155 | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { | 157 | if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil { | ||
| 156 | return err | 158 | return err | ||
| 157 | } | 159 | } | ||
| 158 | - | domains := make([]string, 0, len(config.ExternalRoutes)) | 160 | + | routes := append([]remote.ExternalRoute(nil), config.ExternalRoutes...) |
| 159 | - | for d := range config.ExternalRoutes { | 161 | + | sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain }) |
| 160 | - | domains = append(domains, d) | |||
| 161 | - | } | |||
| 162 | - | sort.Strings(domains) | |||
| 163 | 162 | ||||
| 164 | - | rows := make([][]string, 0, len(domains)) | 163 | + | rows := make([][]string, 0, len(routes)) |
| 165 | - | for _, d := range domains { | 164 | + | for _, route := range routes { |
| 166 | - | rows = append(rows, []string{d, config.ExternalRoutes[d]}) | 165 | + | target := route.Target |
| 166 | + | if target == "" { | |||
| 167 | + | target = "[redacted]" | |||
| 168 | + | } | |||
| 169 | + | rows = append(rows, []string{route.Domain, target}) | |||
| 167 | } | 170 | } | ||
| 168 | table := components.NewTable( | 171 | table := components.NewTable( | ||
| 169 | components.WithColumns([]components.TableColumn{ | 172 | components.WithColumns([]components.TableColumn{ | ||
M internal/adapters/in/cli/controlplane_local.go +6 -2
| @@ -372,14 +372,18 @@ func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, erro | |||
| 372 | 372 | if l.configSvc == nil { | |
| 373 | 373 | return nil, fmt.Errorf("config service unavailable") | |
| 374 | 374 | } | |
| 375 | + | externalRoutes := l.configSvc.GetExternalRoutes() | |
| 376 | + | externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes)) | |
| 377 | + | for domainName := range externalRoutes { | |
| 378 | + | externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName}) | |
| 379 | + | } | |
| 375 | 380 | cfg := &remote.Config{ | |
| 376 | 381 | Routes: l.configSvc.GetRoutes(ctx), | |
| 377 | - | ExternalRoutes: l.configSvc.GetExternalRoutes(), | |
| 382 | + | ExternalRoutes: externalResponses, | |
| 378 | 383 | } | |
| 379 | 384 | cfg.Server.Port = l.configSvc.GetServerPort() | |
| 380 | 385 | cfg.Server.RegistryPort = l.configSvc.GetRegistryPort() | |
| 381 | 386 | cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain() | |
| 382 | - | cfg.Server.DataDir = l.configSvc.GetDataDir() | |
| 383 | 387 | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | |
| 384 | 388 | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | |
| 385 | 389 | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | |
| @@ -372,14 +372,18 @@ func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, erro | |||||
| 372 | if l.configSvc == nil { | 372 | if l.configSvc == nil { | ||
| 373 | return nil, fmt.Errorf("config service unavailable") | 373 | return nil, fmt.Errorf("config service unavailable") | ||
| 374 | } | 374 | } | ||
| 375 | + | externalRoutes := l.configSvc.GetExternalRoutes() | |||
| 376 | + | externalResponses := make([]remote.ExternalRoute, 0, len(externalRoutes)) | |||
| 377 | + | for domainName := range externalRoutes { | |||
| 378 | + | externalResponses = append(externalResponses, remote.ExternalRoute{Domain: domainName}) | |||
| 379 | + | } | |||
| 375 | cfg := &remote.Config{ | 380 | cfg := &remote.Config{ | ||
| 376 | Routes: l.configSvc.GetRoutes(ctx), | 381 | Routes: l.configSvc.GetRoutes(ctx), | ||
| 377 | - | ExternalRoutes: l.configSvc.GetExternalRoutes(), | 382 | + | ExternalRoutes: externalResponses, |
| 378 | } | 383 | } | ||
| 379 | cfg.Server.Port = l.configSvc.GetServerPort() | 384 | cfg.Server.Port = l.configSvc.GetServerPort() | ||
| 380 | cfg.Server.RegistryPort = l.configSvc.GetRegistryPort() | 385 | cfg.Server.RegistryPort = l.configSvc.GetRegistryPort() | ||
| 381 | cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain() | 386 | cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain() | ||
| 382 | - | cfg.Server.DataDir = l.configSvc.GetDataDir() | |||
| 383 | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | 387 | cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled() | ||
| 384 | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | 388 | cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled() | ||
| 385 | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | 389 | cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix() | ||
M internal/adapters/in/cli/remote/client.go +9 -3
| @@ -826,7 +826,7 @@ type Config struct { | |||
| 826 | 826 | Port int `json:"port"` | |
| 827 | 827 | RegistryPort int `json:"registry_port"` | |
| 828 | 828 | RegistryDomain string `json:"registry_domain"` | |
| 829 | - | DataDir string `json:"data_dir"` | |
| 829 | + | DataDir string `json:"data_dir,omitempty"` | |
| 830 | 830 | } `json:"server"` | |
| 831 | 831 | AutoRoute struct { | |
| 832 | 832 | Enabled bool `json:"enabled"` | |
| @@ -835,8 +835,14 @@ type Config struct { | |||
| 835 | 835 | Enabled bool `json:"enabled"` | |
| 836 | 836 | Prefix string `json:"prefix"` | |
| 837 | 837 | } `json:"network_isolation"` | |
| 838 | - | Routes []domain.Route `json:"routes"` | |
| 839 | - | ExternalRoutes map[string]string `json:"external_routes"` | |
| 838 | + | Routes []domain.Route `json:"routes"` | |
| 839 | + | ExternalRoutes []ExternalRoute `json:"external_routes"` | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | // ExternalRoute represents a redacted external route config entry. | |
| 843 | + | type ExternalRoute struct { | |
| 844 | + | Domain string `json:"domain"` | |
| 845 | + | Target string `json:"target,omitempty"` | |
| 840 | 846 | } | |
| 841 | 847 | ||
| 842 | 848 | // GetConfig returns the Gordon configuration. | |
| @@ -826,7 +826,7 @@ type Config struct { | |||||
| 826 | Port int `json:"port"` | 826 | Port int `json:"port"` | ||
| 827 | RegistryPort int `json:"registry_port"` | 827 | RegistryPort int `json:"registry_port"` | ||
| 828 | RegistryDomain string `json:"registry_domain"` | 828 | RegistryDomain string `json:"registry_domain"` | ||
| 829 | - | DataDir string `json:"data_dir"` | 829 | + | DataDir string `json:"data_dir,omitempty"` |
| 830 | } `json:"server"` | 830 | } `json:"server"` | ||
| 831 | AutoRoute struct { | 831 | AutoRoute struct { | ||
| 832 | Enabled bool `json:"enabled"` | 832 | Enabled bool `json:"enabled"` | ||
| @@ -835,8 +835,14 @@ type Config struct { | |||||
| 835 | Enabled bool `json:"enabled"` | 835 | Enabled bool `json:"enabled"` | ||
| 836 | Prefix string `json:"prefix"` | 836 | Prefix string `json:"prefix"` | ||
| 837 | } `json:"network_isolation"` | 837 | } `json:"network_isolation"` | ||
| 838 | - | Routes []domain.Route `json:"routes"` | 838 | + | Routes []domain.Route `json:"routes"` |
| 839 | - | ExternalRoutes map[string]string `json:"external_routes"` | 839 | + | ExternalRoutes []ExternalRoute `json:"external_routes"` |
| 840 | + | } | |||
| 841 | + | ||||
| 842 | + | // ExternalRoute represents a redacted external route config entry. | |||
| 843 | + | type ExternalRoute struct { | |||
| 844 | + | Domain string `json:"domain"` | |||
| 845 | + | Target string `json:"target,omitempty"` | |||
| 840 | } | 846 | } | ||
| 841 | 847 | ||||
| 842 | // GetConfig returns the Gordon configuration. | 848 | // GetConfig returns the Gordon configuration. | ||
M internal/adapters/in/http/admin/handler.go +1 -3
| @@ -1250,10 +1250,9 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||
| 1250 | 1250 | ||
| 1251 | 1251 | externalRoutes := h.configSvc.GetExternalRoutes() | |
| 1252 | 1252 | externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes)) | |
| 1253 | - | for domain, target := range externalRoutes { | |
| 1253 | + | for domain := range externalRoutes { | |
| 1254 | 1254 | externalResponses = append(externalResponses, dto.ExternalRoute{ | |
| 1255 | 1255 | Domain: domain, | |
| 1256 | - | Target: target, | |
| 1257 | 1256 | }) | |
| 1258 | 1257 | } | |
| 1259 | 1258 | ||
| @@ -1262,7 +1261,6 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||
| 1262 | 1261 | Port: h.configSvc.GetServerPort(), | |
| 1263 | 1262 | RegistryPort: h.configSvc.GetRegistryPort(), | |
| 1264 | 1263 | RegistryDomain: h.configSvc.GetRegistryDomain(), | |
| 1265 | - | DataDir: h.configSvc.GetDataDir(), | |
| 1266 | 1264 | }, | |
| 1267 | 1265 | AutoRoute: dto.AutoRouteConfig{ | |
| 1268 | 1266 | Enabled: h.configSvc.IsAutoRouteEnabled(), | |
| @@ -1250,10 +1250,9 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| 1250 | 1250 | ||||
| 1251 | externalRoutes := h.configSvc.GetExternalRoutes() | 1251 | externalRoutes := h.configSvc.GetExternalRoutes() | ||
| 1252 | externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes)) | 1252 | externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes)) | ||
| 1253 | - | for domain, target := range externalRoutes { | 1253 | + | for domain := range externalRoutes { |
| 1254 | externalResponses = append(externalResponses, dto.ExternalRoute{ | 1254 | externalResponses = append(externalResponses, dto.ExternalRoute{ | ||
| 1255 | Domain: domain, | 1255 | Domain: domain, | ||
| 1256 | - | Target: target, | |||
| 1257 | }) | 1256 | }) | ||
| 1258 | } | 1257 | } | ||
| 1259 | 1258 | ||||
| @@ -1262,7 +1261,6 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| 1262 | Port: h.configSvc.GetServerPort(), | 1261 | Port: h.configSvc.GetServerPort(), | ||
| 1263 | RegistryPort: h.configSvc.GetRegistryPort(), | 1262 | RegistryPort: h.configSvc.GetRegistryPort(), | ||
| 1264 | RegistryDomain: h.configSvc.GetRegistryDomain(), | 1263 | RegistryDomain: h.configSvc.GetRegistryDomain(), | ||
| 1265 | - | DataDir: h.configSvc.GetDataDir(), | |||
| 1266 | }, | 1264 | }, | ||
| 1267 | AutoRoute: dto.AutoRouteConfig{ | 1265 | AutoRoute: dto.AutoRouteConfig{ | ||
| 1268 | Enabled: h.configSvc.IsAutoRouteEnabled(), | 1266 | Enabled: h.configSvc.IsAutoRouteEnabled(), | ||
M internal/adapters/in/http/admin/handler_test.go +29 -0
| @@ -1122,6 +1122,35 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 1122 | 1122 | ||
| 1123 | 1123 | // Config endpoint tests | |
| 1124 | 1124 | ||
| 1125 | + | func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) { | |
| 1126 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1127 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc }) | |
| 1128 | + | ||
| 1129 | + | configSvc.EXPECT().GetServerPort().Return(8080).Once() | |
| 1130 | + | configSvc.EXPECT().GetRegistryPort().Return(5000).Once() | |
| 1131 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once() | |
| 1132 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Once() | |
| 1133 | + | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Once() | |
| 1134 | + | configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Once() | |
| 1135 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once() | |
| 1136 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{"db.example.com": "http://10.0.0.5:5432"}).Once() | |
| 1137 | + | ||
| 1138 | + | req := httptest.NewRequest("GET", "/admin/config", nil) | |
| 1139 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |
| 1140 | + | rec := httptest.NewRecorder() | |
| 1141 | + | ||
| 1142 | + | handler.ServeHTTP(rec, req) | |
| 1143 | + | ||
| 1144 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 1145 | + | body := rec.Body.String() | |
| 1146 | + | var raw map[string]any | |
| 1147 | + | require.NoError(t, json.Unmarshal([]byte(body), &raw)) | |
| 1148 | + | server := raw["server"].(map[string]any) | |
| 1149 | + | assert.NotContains(t, server, "data_dir") | |
| 1150 | + | assert.NotContains(t, body, "/var/lib/gordon") | |
| 1151 | + | assert.NotContains(t, body, "10.0.0.5") | |
| 1152 | + | } | |
| 1153 | + | ||
| 1125 | 1154 | func TestHandler_Config_RequiresReadScope(t *testing.T) { | |
| 1126 | 1155 | configSvc := inmocks.NewMockConfigService(t) | |
| 1127 | 1156 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -1122,6 +1122,35 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||||
| 1122 | 1122 | ||||
| 1123 | // Config endpoint tests | 1123 | // Config endpoint tests | ||
| 1124 | 1124 | ||||
| 1125 | + | func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) { | |||
| 1126 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1127 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc }) | |||
| 1128 | + | ||||
| 1129 | + | configSvc.EXPECT().GetServerPort().Return(8080).Once() | |||
| 1130 | + | configSvc.EXPECT().GetRegistryPort().Return(5000).Once() | |||
| 1131 | + | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once() | |||
| 1132 | + | configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Once() | |||
| 1133 | + | configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Once() | |||
| 1134 | + | configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Once() | |||
| 1135 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once() | |||
| 1136 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{"db.example.com": "http://10.0.0.5:5432"}).Once() | |||
| 1137 | + | ||||
| 1138 | + | req := httptest.NewRequest("GET", "/admin/config", nil) | |||
| 1139 | + | req = req.WithContext(ctxWithScopes("admin:config:read")) | |||
| 1140 | + | rec := httptest.NewRecorder() | |||
| 1141 | + | ||||
| 1142 | + | handler.ServeHTTP(rec, req) | |||
| 1143 | + | ||||
| 1144 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 1145 | + | body := rec.Body.String() | |||
| 1146 | + | var raw map[string]any | |||
| 1147 | + | require.NoError(t, json.Unmarshal([]byte(body), &raw)) | |||
| 1148 | + | server := raw["server"].(map[string]any) | |||
| 1149 | + | assert.NotContains(t, server, "data_dir") | |||
| 1150 | + | assert.NotContains(t, body, "/var/lib/gordon") | |||
| 1151 | + | assert.NotContains(t, body, "10.0.0.5") | |||
| 1152 | + | } | |||
| 1153 | + | ||||
| 1125 | func TestHandler_Config_RequiresReadScope(t *testing.T) { | 1154 | func TestHandler_Config_RequiresReadScope(t *testing.T) { | ||
| 1126 | configSvc := inmocks.NewMockConfigService(t) | 1155 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1127 | authSvc := inmocks.NewMockAuthService(t) | 1156 | authSvc := inmocks.NewMockAuthService(t) | ||
M internal/app/migrate_env.go +22 -56
| @@ -84,54 +84,25 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log | |||
| 84 | 84 | return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err) | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | - | missing := missingSecrets(existingKeys, secrets) | |
| 88 | - | if len(missing) > 0 { | |
| 89 | - | if err := passStore.Set(domainName, missing); err != nil { | |
| 90 | - | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) | |
| 91 | - | } | |
| 87 | + | if len(existingKeys) > 0 { | |
| 88 | + | return fmt.Errorf("pass secrets already exist for %s; refusing to delete plaintext env file automatically", domainName) | |
| 89 | + | } | |
| 90 | + | if err := passStore.Set(domainName, secrets); err != nil { | |
| 91 | + | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | - | migratedPath := filePath + ".migrated" | |
| 95 | - | if err := os.Rename(filePath, migratedPath); err != nil { | |
| 96 | - | return fmt.Errorf("failed to rename env file %s: %w", filePath, err) | |
| 94 | + | if err := os.Remove(filePath); err != nil { | |
| 95 | + | return fmt.Errorf("failed to remove migrated env file %s: %w", filePath, err) | |
| 97 | 96 | } | |
| 98 | 97 | ||
| 99 | 98 | log.Info(). | |
| 100 | - | Int(zerowrap.FieldCount, len(missing)). | |
| 99 | + | Int(zerowrap.FieldCount, len(secrets)). | |
| 101 | 100 | Str("domain", domainName). | |
| 102 | - | Msg("migrated secrets for domain from plain text to pass") | |
| 103 | - | log.Info(). | |
| 104 | - | Str("file", migratedPath). | |
| 105 | - | Msg("original file renamed to .env.migrated - you can safely remove it") | |
| 101 | + | Msg("migrated secrets for domain from plain text to pass and removed original env file") | |
| 106 | 102 | ||
| 107 | 103 | return nil | |
| 108 | 104 | } | |
| 109 | 105 | ||
| 110 | - | func missingSecrets(existingKeys []string, secrets map[string]string) map[string]string { | |
| 111 | - | if len(secrets) == 0 { | |
| 112 | - | return nil | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | existingSet := make(map[string]struct{}, len(existingKeys)) | |
| 116 | - | for _, key := range existingKeys { | |
| 117 | - | existingSet[key] = struct{}{} | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | missing := make(map[string]string) | |
| 121 | - | for key, value := range secrets { | |
| 122 | - | if _, exists := existingSet[key]; exists { | |
| 123 | - | continue | |
| 124 | - | } | |
| 125 | - | missing[key] = value | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | if len(missing) == 0 { | |
| 129 | - | return nil | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | return missing | |
| 133 | - | } | |
| 134 | - | ||
| 135 | 106 | func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| 136 | 107 | entries, err := readEnvDir(envDir) | |
| 137 | 108 | if err != nil { | |
| @@ -190,29 +161,24 @@ func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.Pass | |||
| 190 | 161 | existingKeys = append(existingKeys, key) | |
| 191 | 162 | } | |
| 192 | 163 | ||
| 193 | - | missing := missingSecrets(existingKeys, secrets) | |
| 194 | - | if len(missing) > 0 { | |
| 195 | - | if err := passStore.SetAttachment(containerName, missing); err != nil { | |
| 196 | - | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) | |
| 197 | - | } | |
| 198 | - | log.Info(). | |
| 199 | - | Int(zerowrap.FieldCount, len(missing)). | |
| 200 | - | Str("container", containerName). | |
| 201 | - | Msg("migrated secrets for attachment container from plain text to pass") | |
| 202 | - | } else { | |
| 203 | - | log.Info(). | |
| 204 | - | Str("container", containerName). | |
| 205 | - | Msg("all attachment secrets already exist in pass; no migration needed") | |
| 164 | + | if len(existingKeys) > 0 { | |
| 165 | + | return fmt.Errorf("pass secrets already exist for attachment %s; refusing to delete plaintext env file automatically", containerName) | |
| 206 | 166 | } | |
| 167 | + | if err := passStore.SetAttachment(containerName, secrets); err != nil { | |
| 168 | + | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) | |
| 169 | + | } | |
| 170 | + | log.Info(). | |
| 171 | + | Int(zerowrap.FieldCount, len(secrets)). | |
| 172 | + | Str("container", containerName). | |
| 173 | + | Msg("migrated secrets for attachment container from plain text to pass") | |
| 207 | 174 | ||
| 208 | - | migratedPath := filePath + ".migrated" | |
| 209 | - | if err := os.Rename(filePath, migratedPath); err != nil { | |
| 210 | - | return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err) | |
| 175 | + | if err := os.Remove(filePath); err != nil { | |
| 176 | + | return fmt.Errorf("failed to remove migrated attachment env file %s: %w", filePath, err) | |
| 211 | 177 | } | |
| 212 | 178 | ||
| 213 | 179 | log.Info(). | |
| 214 | - | Str("file", migratedPath). | |
| 215 | - | Msg("original attachment file renamed to .env.migrated - you can safely remove it") | |
| 180 | + | Str("file", filePath). | |
| 181 | + | Msg("removed original attachment env file after migration to pass") | |
| 216 | 182 | ||
| 217 | 183 | return nil | |
| 218 | 184 | } | |
| @@ -84,54 +84,25 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log | |||||
| 84 | return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err) | 84 | return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err) | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | - | missing := missingSecrets(existingKeys, secrets) | 87 | + | if len(existingKeys) > 0 { |
| 88 | - | if len(missing) > 0 { | 88 | + | return fmt.Errorf("pass secrets already exist for %s; refusing to delete plaintext env file automatically", domainName) |
| 89 | - | if err := passStore.Set(domainName, missing); err != nil { | 89 | + | } |
| 90 | - | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) | 90 | + | if err := passStore.Set(domainName, secrets); err != nil { |
| 91 | - | } | 91 | + | return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err) |
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | - | migratedPath := filePath + ".migrated" | 94 | + | if err := os.Remove(filePath); err != nil { |
| 95 | - | if err := os.Rename(filePath, migratedPath); err != nil { | 95 | + | return fmt.Errorf("failed to remove migrated env file %s: %w", filePath, err) |
| 96 | - | return fmt.Errorf("failed to rename env file %s: %w", filePath, err) | |||
| 97 | } | 96 | } | ||
| 98 | 97 | ||||
| 99 | log.Info(). | 98 | log.Info(). | ||
| 100 | - | Int(zerowrap.FieldCount, len(missing)). | 99 | + | Int(zerowrap.FieldCount, len(secrets)). |
| 101 | Str("domain", domainName). | 100 | Str("domain", domainName). | ||
| 102 | - | Msg("migrated secrets for domain from plain text to pass") | 101 | + | Msg("migrated secrets for domain from plain text to pass and removed original env file") |
| 103 | - | log.Info(). | |||
| 104 | - | Str("file", migratedPath). | |||
| 105 | - | Msg("original file renamed to .env.migrated - you can safely remove it") | |||
| 106 | 102 | ||||
| 107 | return nil | 103 | return nil | ||
| 108 | } | 104 | } | ||
| 109 | 105 | ||||
| 110 | - | func missingSecrets(existingKeys []string, secrets map[string]string) map[string]string { | |||
| 111 | - | if len(secrets) == 0 { | |||
| 112 | - | return nil | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 116 | - | for _, key := range existingKeys { | |||
| 117 | - | existingSet[key] = struct{}{} | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | missing := make(map[string]string) | |||
| 121 | - | for key, value := range secrets { | |||
| 122 | - | if _, exists := existingSet[key]; exists { | |||
| 123 | - | continue | |||
| 124 | - | } | |||
| 125 | - | missing[key] = value | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | if len(missing) == 0 { | |||
| 129 | - | return nil | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | return missing | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | 106 | func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | ||
| 136 | entries, err := readEnvDir(envDir) | 107 | entries, err := readEnvDir(envDir) | ||
| 137 | if err != nil { | 108 | if err != nil { | ||
| @@ -190,29 +161,24 @@ func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.Pass | |||||
| 190 | existingKeys = append(existingKeys, key) | 161 | existingKeys = append(existingKeys, key) | ||
| 191 | } | 162 | } | ||
| 192 | 163 | ||||
| 193 | - | missing := missingSecrets(existingKeys, secrets) | 164 | + | if len(existingKeys) > 0 { |
| 194 | - | if len(missing) > 0 { | 165 | + | return fmt.Errorf("pass secrets already exist for attachment %s; refusing to delete plaintext env file automatically", containerName) |
| 195 | - | if err := passStore.SetAttachment(containerName, missing); err != nil { | |||
| 196 | - | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) | |||
| 197 | - | } | |||
| 198 | - | log.Info(). | |||
| 199 | - | Int(zerowrap.FieldCount, len(missing)). | |||
| 200 | - | Str("container", containerName). | |||
| 201 | - | Msg("migrated secrets for attachment container from plain text to pass") | |||
| 202 | - | } else { | |||
| 203 | - | log.Info(). | |||
| 204 | - | Str("container", containerName). | |||
| 205 | - | Msg("all attachment secrets already exist in pass; no migration needed") | |||
| 206 | } | 166 | } | ||
| 167 | + | if err := passStore.SetAttachment(containerName, secrets); err != nil { | |||
| 168 | + | return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err) | |||
| 169 | + | } | |||
| 170 | + | log.Info(). | |||
| 171 | + | Int(zerowrap.FieldCount, len(secrets)). | |||
| 172 | + | Str("container", containerName). | |||
| 173 | + | Msg("migrated secrets for attachment container from plain text to pass") | |||
| 207 | 174 | ||||
| 208 | - | migratedPath := filePath + ".migrated" | 175 | + | if err := os.Remove(filePath); err != nil { |
| 209 | - | if err := os.Rename(filePath, migratedPath); err != nil { | 176 | + | return fmt.Errorf("failed to remove migrated attachment env file %s: %w", filePath, err) |
| 210 | - | return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err) | |||
| 211 | } | 177 | } | ||
| 212 | 178 | ||||
| 213 | log.Info(). | 179 | log.Info(). | ||
| 214 | - | Str("file", migratedPath). | 180 | + | Str("file", filePath). |
| 215 | - | Msg("original attachment file renamed to .env.migrated - you can safely remove it") | 181 | + | Msg("removed original attachment env file after migration to pass") |
| 216 | 182 | ||||
| 217 | return nil | 183 | return nil | ||
| 218 | } | 184 | } | ||
M internal/app/migrate_env_test.go +4 -6
| @@ -71,12 +71,11 @@ func TestMigrateEnvFile(t *testing.T) { | |||
| 71 | 71 | err = migrateEnvFile(tmpDir, envFileName, store, logger) | |
| 72 | 72 | require.NoError(t, err) | |
| 73 | 73 | ||
| 74 | - | migratedPath := envFilePath + ".migrated" | |
| 75 | 74 | _, err = os.Stat(envFilePath) | |
| 76 | 75 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | |
| 77 | 76 | ||
| 78 | - | _, err = os.Stat(migratedPath) | |
| 79 | - | assert.NoError(t, err, ".env.migrated file should exist") | |
| 77 | + | _, err = os.Stat(envFilePath + ".migrated") | |
| 78 | + | assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default") | |
| 80 | 79 | ||
| 81 | 80 | passKeys, err := store.ListKeys(domainName) | |
| 82 | 81 | require.NoError(t, err) | |
| @@ -125,12 +124,11 @@ func TestMigrateAttachmentEnvFile(t *testing.T) { | |||
| 125 | 124 | err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger) | |
| 126 | 125 | require.NoError(t, err) | |
| 127 | 126 | ||
| 128 | - | migratedPath := envFilePath + ".migrated" | |
| 129 | 127 | _, err = os.Stat(envFilePath) | |
| 130 | 128 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | |
| 131 | 129 | ||
| 132 | - | _, err = os.Stat(migratedPath) | |
| 133 | - | assert.NoError(t, err, ".env.migrated file should exist") | |
| 130 | + | _, err = os.Stat(envFilePath + ".migrated") | |
| 131 | + | assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default") | |
| 134 | 132 | ||
| 135 | 133 | values, err := store.GetAllAttachment(storedContainerName) | |
| 136 | 134 | require.NoError(t, err) | |
| @@ -71,12 +71,11 @@ func TestMigrateEnvFile(t *testing.T) { | |||||
| 71 | err = migrateEnvFile(tmpDir, envFileName, store, logger) | 71 | err = migrateEnvFile(tmpDir, envFileName, store, logger) | ||
| 72 | require.NoError(t, err) | 72 | require.NoError(t, err) | ||
| 73 | 73 | ||||
| 74 | - | migratedPath := envFilePath + ".migrated" | |||
| 75 | _, err = os.Stat(envFilePath) | 74 | _, err = os.Stat(envFilePath) | ||
| 76 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | 75 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | ||
| 77 | 76 | ||||
| 78 | - | _, err = os.Stat(migratedPath) | 77 | + | _, err = os.Stat(envFilePath + ".migrated") |
| 79 | - | assert.NoError(t, err, ".env.migrated file should exist") | 78 | + | assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default") |
| 80 | 79 | ||||
| 81 | passKeys, err := store.ListKeys(domainName) | 80 | passKeys, err := store.ListKeys(domainName) | ||
| 82 | require.NoError(t, err) | 81 | require.NoError(t, err) | ||
| @@ -125,12 +124,11 @@ func TestMigrateAttachmentEnvFile(t *testing.T) { | |||||
| 125 | err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger) | 124 | err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger) | ||
| 126 | require.NoError(t, err) | 125 | require.NoError(t, err) | ||
| 127 | 126 | ||||
| 128 | - | migratedPath := envFilePath + ".migrated" | |||
| 129 | _, err = os.Stat(envFilePath) | 127 | _, err = os.Stat(envFilePath) | ||
| 130 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | 128 | assert.True(t, os.IsNotExist(err), "original .env file should be removed") | ||
| 131 | 129 | ||||
| 132 | - | _, err = os.Stat(migratedPath) | 130 | + | _, err = os.Stat(envFilePath + ".migrated") |
| 133 | - | assert.NoError(t, err, ".env.migrated file should exist") | 131 | + | assert.True(t, os.IsNotExist(err), ".env.migrated plaintext should not be preserved by default") |
| 134 | 132 | ||||
| 135 | values, err := store.GetAllAttachment(storedContainerName) | 133 | values, err := store.GetAllAttachment(storedContainerName) | ||
| 136 | require.NoError(t, err) | 134 | require.NoError(t, err) | ||
M internal/app/run.go +61 -7
| @@ -23,6 +23,7 @@ import ( | |||
| 23 | 23 | "github.com/bnema/zerowrap" | |
| 24 | 24 | zerowrapotel "github.com/bnema/zerowrap/otel" | |
| 25 | 25 | "github.com/spf13/viper" | |
| 26 | + | "golang.org/x/sys/unix" | |
| 26 | 27 | ||
| 27 | 28 | // Adapters - Output | |
| 28 | 29 | "github.com/bnema/gordon/internal/adapters/out/accesslog" | |
| @@ -1336,18 +1337,71 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||
| 1336 | 1337 | provider := secrets.NewSopsProvider(log) | |
| 1337 | 1338 | return provider.GetSecret(ctx, path) | |
| 1338 | 1339 | case domain.SecretsBackendUnsafe: | |
| 1339 | - | // For unsafe backend, path is relative to dataDir/secrets/ | |
| 1340 | - | secretFile := filepath.Join(dataDir, "secrets", path) | |
| 1341 | - | data, err := os.ReadFile(secretFile) | |
| 1342 | - | if err != nil { | |
| 1343 | - | return "", fmt.Errorf("failed to read secret file: %w", err) | |
| 1344 | - | } | |
| 1345 | - | return string(data), nil | |
| 1340 | + | // For unsafe backend, path is relative to dataDir/secrets/. | |
| 1341 | + | return readUnsafeSecret(dataDir, path) | |
| 1346 | 1342 | default: | |
| 1347 | 1343 | return "", fmt.Errorf("unknown secrets backend: %s", backend) | |
| 1348 | 1344 | } | |
| 1349 | 1345 | } | |
| 1350 | 1346 | ||
| 1347 | + | func readFileBeneath(root, cleanedRelPath string) ([]byte, error) { | |
| 1348 | + | rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |
| 1349 | + | if err != nil { | |
| 1350 | + | return nil, fmt.Errorf("failed to open secrets root: %w", err) | |
| 1351 | + | } | |
| 1352 | + | defer unix.Close(rootFD) | |
| 1353 | + | ||
| 1354 | + | parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/") | |
| 1355 | + | dirFD := rootFD | |
| 1356 | + | var closeDirFDs []int | |
| 1357 | + | defer func() { | |
| 1358 | + | for i := len(closeDirFDs) - 1; i >= 0; i-- { | |
| 1359 | + | _ = unix.Close(closeDirFDs[i]) | |
| 1360 | + | } | |
| 1361 | + | }() | |
| 1362 | + | ||
| 1363 | + | for i, part := range parts { | |
| 1364 | + | if part == "" || part == "." || part == ".." { | |
| 1365 | + | return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets") | |
| 1366 | + | } | |
| 1367 | + | last := i == len(parts)-1 | |
| 1368 | + | if last { | |
| 1369 | + | fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |
| 1370 | + | if err != nil { | |
| 1371 | + | return nil, fmt.Errorf("failed to read secret file: %w", err) | |
| 1372 | + | } | |
| 1373 | + | defer unix.Close(fd) | |
| 1374 | + | return os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd)) | |
| 1375 | + | } | |
| 1376 | + | ||
| 1377 | + | nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |
| 1378 | + | if err != nil { | |
| 1379 | + | return nil, fmt.Errorf("failed to open secret path component: %w", err) | |
| 1380 | + | } | |
| 1381 | + | closeDirFDs = append(closeDirFDs, nextFD) | |
| 1382 | + | dirFD = nextFD | |
| 1383 | + | } | |
| 1384 | + | ||
| 1385 | + | return nil, fmt.Errorf("invalid secret path: empty path") | |
| 1386 | + | } | |
| 1387 | + | ||
| 1388 | + | func readUnsafeSecret(dataDir, secretPath string) (string, error) { | |
| 1389 | + | if filepath.IsAbs(secretPath) { | |
| 1390 | + | return "", fmt.Errorf("invalid secret path: absolute paths are not allowed") | |
| 1391 | + | } | |
| 1392 | + | cleaned := filepath.Clean(secretPath) | |
| 1393 | + | if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { | |
| 1394 | + | return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets") | |
| 1395 | + | } | |
| 1396 | + | ||
| 1397 | + | root := filepath.Clean(filepath.Join(dataDir, "secrets")) | |
| 1398 | + | data, err := readFileBeneath(root, cleaned) | |
| 1399 | + | if err != nil { | |
| 1400 | + | return "", err | |
| 1401 | + | } | |
| 1402 | + | return string(data), nil | |
| 1403 | + | } | |
| 1404 | + | ||
| 1351 | 1405 | // proxyConfigResult holds parsed proxy and blob chunk size config. | |
| 1352 | 1406 | type proxyConfigResult struct { | |
| 1353 | 1407 | proxyConfig proxy.Config | |
| @@ -23,6 +23,7 @@ import ( | |||||
| 23 | "github.com/bnema/zerowrap" | 23 | "github.com/bnema/zerowrap" | ||
| 24 | zerowrapotel "github.com/bnema/zerowrap/otel" | 24 | zerowrapotel "github.com/bnema/zerowrap/otel" | ||
| 25 | "github.com/spf13/viper" | 25 | "github.com/spf13/viper" | ||
| 26 | + | "golang.org/x/sys/unix" | |||
| 26 | 27 | ||||
| 27 | // Adapters - Output | 28 | // Adapters - Output | ||
| 28 | "github.com/bnema/gordon/internal/adapters/out/accesslog" | 29 | "github.com/bnema/gordon/internal/adapters/out/accesslog" | ||
| @@ -1336,18 +1337,71 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||||
| 1336 | provider := secrets.NewSopsProvider(log) | 1337 | provider := secrets.NewSopsProvider(log) | ||
| 1337 | return provider.GetSecret(ctx, path) | 1338 | return provider.GetSecret(ctx, path) | ||
| 1338 | case domain.SecretsBackendUnsafe: | 1339 | case domain.SecretsBackendUnsafe: | ||
| 1339 | - | // For unsafe backend, path is relative to dataDir/secrets/ | 1340 | + | // For unsafe backend, path is relative to dataDir/secrets/. |
| 1340 | - | secretFile := filepath.Join(dataDir, "secrets", path) | 1341 | + | return readUnsafeSecret(dataDir, path) |
| 1341 | - | data, err := os.ReadFile(secretFile) | |||
| 1342 | - | if err != nil { | |||
| 1343 | - | return "", fmt.Errorf("failed to read secret file: %w", err) | |||
| 1344 | - | } | |||
| 1345 | - | return string(data), nil | |||
| 1346 | default: | 1342 | default: | ||
| 1347 | return "", fmt.Errorf("unknown secrets backend: %s", backend) | 1343 | return "", fmt.Errorf("unknown secrets backend: %s", backend) | ||
| 1348 | } | 1344 | } | ||
| 1349 | } | 1345 | } | ||
| 1350 | 1346 | ||||
| 1347 | + | func readFileBeneath(root, cleanedRelPath string) ([]byte, error) { | |||
| 1348 | + | rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |||
| 1349 | + | if err != nil { | |||
| 1350 | + | return nil, fmt.Errorf("failed to open secrets root: %w", err) | |||
| 1351 | + | } | |||
| 1352 | + | defer unix.Close(rootFD) | |||
| 1353 | + | ||||
| 1354 | + | parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/") | |||
| 1355 | + | dirFD := rootFD | |||
| 1356 | + | var closeDirFDs []int | |||
| 1357 | + | defer func() { | |||
| 1358 | + | for i := len(closeDirFDs) - 1; i >= 0; i-- { | |||
| 1359 | + | _ = unix.Close(closeDirFDs[i]) | |||
| 1360 | + | } | |||
| 1361 | + | }() | |||
| 1362 | + | ||||
| 1363 | + | for i, part := range parts { | |||
| 1364 | + | if part == "" || part == "." || part == ".." { | |||
| 1365 | + | return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets") | |||
| 1366 | + | } | |||
| 1367 | + | last := i == len(parts)-1 | |||
| 1368 | + | if last { | |||
| 1369 | + | fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |||
| 1370 | + | if err != nil { | |||
| 1371 | + | return nil, fmt.Errorf("failed to read secret file: %w", err) | |||
| 1372 | + | } | |||
| 1373 | + | defer unix.Close(fd) | |||
| 1374 | + | return os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd)) | |||
| 1375 | + | } | |||
| 1376 | + | ||||
| 1377 | + | nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) | |||
| 1378 | + | if err != nil { | |||
| 1379 | + | return nil, fmt.Errorf("failed to open secret path component: %w", err) | |||
| 1380 | + | } | |||
| 1381 | + | closeDirFDs = append(closeDirFDs, nextFD) | |||
| 1382 | + | dirFD = nextFD | |||
| 1383 | + | } | |||
| 1384 | + | ||||
| 1385 | + | return nil, fmt.Errorf("invalid secret path: empty path") | |||
| 1386 | + | } | |||
| 1387 | + | ||||
| 1388 | + | func readUnsafeSecret(dataDir, secretPath string) (string, error) { | |||
| 1389 | + | if filepath.IsAbs(secretPath) { | |||
| 1390 | + | return "", fmt.Errorf("invalid secret path: absolute paths are not allowed") | |||
| 1391 | + | } | |||
| 1392 | + | cleaned := filepath.Clean(secretPath) | |||
| 1393 | + | if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { | |||
| 1394 | + | return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets") | |||
| 1395 | + | } | |||
| 1396 | + | ||||
| 1397 | + | root := filepath.Clean(filepath.Join(dataDir, "secrets")) | |||
| 1398 | + | data, err := readFileBeneath(root, cleaned) | |||
| 1399 | + | if err != nil { | |||
| 1400 | + | return "", err | |||
| 1401 | + | } | |||
| 1402 | + | return string(data), nil | |||
| 1403 | + | } | |||
| 1404 | + | ||||
| 1351 | // proxyConfigResult holds parsed proxy and blob chunk size config. | 1405 | // proxyConfigResult holds parsed proxy and blob chunk size config. | ||
| 1352 | type proxyConfigResult struct { | 1406 | type proxyConfigResult struct { | ||
| 1353 | proxyConfig proxy.Config | 1407 | proxyConfig proxy.Config | ||
M internal/app/run_auth_config_test.go +67 -0
| @@ -2,6 +2,8 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/gordon/internal/domain" | |
| @@ -65,3 +67,68 @@ func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) { | |||
| 65 | 67 | require.NoError(t, err) | |
| 66 | 68 | assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend") | |
| 67 | 69 | } | |
| 70 | + | ||
| 71 | + | func TestLoadSecretUnsafeRejectsTraversalAndAbsolutePaths(t *testing.T) { | |
| 72 | + | dataDir := t.TempDir() | |
| 73 | + | secretsDir := filepath.Join(dataDir, "secrets") | |
| 74 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |
| 75 | + | require.NoError(t, os.WriteFile(filepath.Join(secretsDir, "token"), []byte("safe-secret"), 0600)) | |
| 76 | + | outside := filepath.Join(dataDir, "outside") | |
| 77 | + | require.NoError(t, os.WriteFile(outside, []byte("outside-secret"), 0600)) | |
| 78 | + | ||
| 79 | + | for _, path := range []string{"../outside", filepath.Join(dataDir, "outside")} { | |
| 80 | + | t.Run(path, func(t *testing.T) { | |
| 81 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, path, dataDir, zerowrap.Default()) | |
| 82 | + | require.Error(t, err) | |
| 83 | + | assert.Contains(t, err.Error(), "invalid secret path") | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestLoadSecretUnsafeAllowsRelativePathUnderSecretsDir(t *testing.T) { | |
| 89 | + | dataDir := t.TempDir() | |
| 90 | + | secretsDir := filepath.Join(dataDir, "secrets", "auth") | |
| 91 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |
| 92 | + | require.NoError(t, os.WriteFile(filepath.Join(secretsDir, "token"), []byte("safe-secret"), 0600)) | |
| 93 | + | ||
| 94 | + | secret, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "auth/token", dataDir, zerowrap.Default()) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | assert.Equal(t, "safe-secret", secret) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestLoadSecretUnsafeRejectsMissingCandidate(t *testing.T) { | |
| 100 | + | dataDir := t.TempDir() | |
| 101 | + | secretsDir := filepath.Join(dataDir, "secrets") | |
| 102 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |
| 103 | + | ||
| 104 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "missing", dataDir, zerowrap.Default()) | |
| 105 | + | require.Error(t, err) | |
| 106 | + | assert.Contains(t, err.Error(), "failed to read secret file") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestLoadSecretUnsafeRejectsIntermediateSymlinkEscape(t *testing.T) { | |
| 110 | + | dataDir := t.TempDir() | |
| 111 | + | secretsDir := filepath.Join(dataDir, "secrets") | |
| 112 | + | outsideDir := filepath.Join(dataDir, "outside") | |
| 113 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |
| 114 | + | require.NoError(t, os.MkdirAll(outsideDir, 0700)) | |
| 115 | + | require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "token"), []byte("outside-secret"), 0600)) | |
| 116 | + | require.NoError(t, os.Symlink(outsideDir, filepath.Join(secretsDir, "linkdir"))) | |
| 117 | + | ||
| 118 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "linkdir/token", dataDir, zerowrap.Default()) | |
| 119 | + | require.Error(t, err) | |
| 120 | + | assert.Contains(t, err.Error(), "failed to open secret path component") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestLoadSecretUnsafeRejectsSymlinkEscape(t *testing.T) { | |
| 124 | + | dataDir := t.TempDir() | |
| 125 | + | secretsDir := filepath.Join(dataDir, "secrets") | |
| 126 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |
| 127 | + | outside := filepath.Join(dataDir, "outside") | |
| 128 | + | require.NoError(t, os.WriteFile(outside, []byte("outside-secret"), 0600)) | |
| 129 | + | require.NoError(t, os.Symlink(outside, filepath.Join(secretsDir, "token"))) | |
| 130 | + | ||
| 131 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "token", dataDir, zerowrap.Default()) | |
| 132 | + | require.Error(t, err) | |
| 133 | + | assert.Contains(t, err.Error(), "failed to read secret file") | |
| 134 | + | } | |
| @@ -2,6 +2,8 @@ package app | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/gordon/internal/domain" | 9 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -65,3 +67,68 @@ func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) { | |||||
| 65 | require.NoError(t, err) | 67 | require.NoError(t, err) | ||
| 66 | assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend") | 68 | assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend") | ||
| 67 | } | 69 | } | ||
| 70 | + | ||||
| 71 | + | func TestLoadSecretUnsafeRejectsTraversalAndAbsolutePaths(t *testing.T) { | |||
| 72 | + | dataDir := t.TempDir() | |||
| 73 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 74 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 75 | + | require.NoError(t, os.WriteFile(filepath.Join(secretsDir, "token"), []byte("safe-secret"), 0600)) | |||
| 76 | + | outside := filepath.Join(dataDir, "outside") | |||
| 77 | + | require.NoError(t, os.WriteFile(outside, []byte("outside-secret"), 0600)) | |||
| 78 | + | ||||
| 79 | + | for _, path := range []string{"../outside", filepath.Join(dataDir, "outside")} { | |||
| 80 | + | t.Run(path, func(t *testing.T) { | |||
| 81 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, path, dataDir, zerowrap.Default()) | |||
| 82 | + | require.Error(t, err) | |||
| 83 | + | assert.Contains(t, err.Error(), "invalid secret path") | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestLoadSecretUnsafeAllowsRelativePathUnderSecretsDir(t *testing.T) { | |||
| 89 | + | dataDir := t.TempDir() | |||
| 90 | + | secretsDir := filepath.Join(dataDir, "secrets", "auth") | |||
| 91 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 92 | + | require.NoError(t, os.WriteFile(filepath.Join(secretsDir, "token"), []byte("safe-secret"), 0600)) | |||
| 93 | + | ||||
| 94 | + | secret, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "auth/token", dataDir, zerowrap.Default()) | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | assert.Equal(t, "safe-secret", secret) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestLoadSecretUnsafeRejectsMissingCandidate(t *testing.T) { | |||
| 100 | + | dataDir := t.TempDir() | |||
| 101 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 102 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 103 | + | ||||
| 104 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "missing", dataDir, zerowrap.Default()) | |||
| 105 | + | require.Error(t, err) | |||
| 106 | + | assert.Contains(t, err.Error(), "failed to read secret file") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestLoadSecretUnsafeRejectsIntermediateSymlinkEscape(t *testing.T) { | |||
| 110 | + | dataDir := t.TempDir() | |||
| 111 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 112 | + | outsideDir := filepath.Join(dataDir, "outside") | |||
| 113 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 114 | + | require.NoError(t, os.MkdirAll(outsideDir, 0700)) | |||
| 115 | + | require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "token"), []byte("outside-secret"), 0600)) | |||
| 116 | + | require.NoError(t, os.Symlink(outsideDir, filepath.Join(secretsDir, "linkdir"))) | |||
| 117 | + | ||||
| 118 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "linkdir/token", dataDir, zerowrap.Default()) | |||
| 119 | + | require.Error(t, err) | |||
| 120 | + | assert.Contains(t, err.Error(), "failed to open secret path component") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestLoadSecretUnsafeRejectsSymlinkEscape(t *testing.T) { | |||
| 124 | + | dataDir := t.TempDir() | |||
| 125 | + | secretsDir := filepath.Join(dataDir, "secrets") | |||
| 126 | + | require.NoError(t, os.MkdirAll(secretsDir, 0700)) | |||
| 127 | + | outside := filepath.Join(dataDir, "outside") | |||
| 128 | + | require.NoError(t, os.WriteFile(outside, []byte("outside-secret"), 0600)) | |||
| 129 | + | require.NoError(t, os.Symlink(outside, filepath.Join(secretsDir, "token"))) | |||
| 130 | + | ||||
| 131 | + | _, err := loadSecret(context.Background(), domain.SecretsBackendUnsafe, "token", dataDir, zerowrap.Default()) | |||
| 132 | + | require.Error(t, err) | |||
| 133 | + | assert.Contains(t, err.Error(), "failed to read secret file") | |||
| 134 | + | } | |||