detached 7f90f82

fix: redact config and secure unsafe secrets

commit
7f90f829f4f65229842ce219a54f0da70e1b61ee
author
brice · · 2026-04-25 09:54 UTC
parent
3520c0f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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).
1717
1818 ## gordon config show
1919
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.
2222
2323 ```bash
2424 gordon config show
@@ -39,8 +39,7 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
3939 "server": {
4040 "port": 1111,
4141 "registry_port": 5000,
42- "registry_domain": "reg.example.com",
43- "data_dir": "/var/lib/gordon"
42+ "registry_domain": "reg.example.com"
4443 },
4544 "auto_route": {
4645 "enabled": true,
@@ -53,12 +52,14 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
5352 "routes": [
5453 {"domain": "app.example.com", "image": "myapp:latest"}
5554 ],
56- "external_routes": {
57- "reg.example.com": "localhost:5000"
58- }
55+ "external_routes": [
56+ {"domain": "reg.example.com"}
57+ ]
5958 }
6059 ```
6160
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+
6263 ### Auto-Route Allowed Domains
6364
6465 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 show18 ## 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 ```bash23 ```bash
24 gordon config show24 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 Domains63 ### 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 {
1414 Port int `json:"port"`
1515 RegistryPort int `json:"registry_port"`
1616 RegistryDomain string `json:"registry_domain"`
17- DataDir string `json:"data_dir"`
17+ DataDir string `json:"data_dir,omitempty"`
1818 }
1919
2020 // AutoRouteConfig represents auto-route config details.
@@ -31,5 +31,5 @@ type NetworkIsolationConfig struct {
3131 // ExternalRoute represents an external route config entry.
3232 type ExternalRoute struct {
3333 Domain string `json:"domain"`
34- Target string `json:"target"`
34+ Target string `json:"target,omitempty"`
3535 }
@@ -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 {
9494 if err := cliWriteLine(out, cliRenderMeta("Registry Domain:", config.Server.RegistryDomain)); err != nil {
9595 return err
9696 }
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+ }
99101 }
100102 if err := cliWriteLine(out, cliRenderMeta("Auto-Route:", fmt.Sprintf("%v", config.AutoRoute.Enabled))); err != nil {
101103 return err
@@ -155,15 +157,16 @@ func renderConfigExternalRoutes(out io.Writer, config *remote.Config) error {
155157 if err := cliWriteLine(out, cliRenderTitle("External Routes")); err != nil {
156158 return err
157159 }
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 })
163162
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})
167170 }
168171 table := components.NewTable(
169172 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 err95 return err
96 }96 }
97- if err := cliWriteLine(out, cliRenderMeta("Data Directory:", config.Server.DataDir)); err != nil {97+ if config.Server.DataDir != "" {
98- return err98+ 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 err103 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 err158 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
372372 if l.configSvc == nil {
373373 return nil, fmt.Errorf("config service unavailable")
374374 }
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+ }
375380 cfg := &remote.Config{
376381 Routes: l.configSvc.GetRoutes(ctx),
377- ExternalRoutes: l.configSvc.GetExternalRoutes(),
382+ ExternalRoutes: externalResponses,
378383 }
379384 cfg.Server.Port = l.configSvc.GetServerPort()
380385 cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
381386 cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
382- cfg.Server.DataDir = l.configSvc.GetDataDir()
383387 cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
384388 cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
385389 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 {
826826 Port int `json:"port"`
827827 RegistryPort int `json:"registry_port"`
828828 RegistryDomain string `json:"registry_domain"`
829- DataDir string `json:"data_dir"`
829+ DataDir string `json:"data_dir,omitempty"`
830830 } `json:"server"`
831831 AutoRoute struct {
832832 Enabled bool `json:"enabled"`
@@ -835,8 +835,14 @@ type Config struct {
835835 Enabled bool `json:"enabled"`
836836 Prefix string `json:"prefix"`
837837 } `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"`
840846 }
841847
842848 // 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) {
12501250
12511251 externalRoutes := h.configSvc.GetExternalRoutes()
12521252 externalResponses := make([]dto.ExternalRoute, 0, len(externalRoutes))
1253- for domain, target := range externalRoutes {
1253+ for domain := range externalRoutes {
12541254 externalResponses = append(externalResponses, dto.ExternalRoute{
12551255 Domain: domain,
1256- Target: target,
12571256 })
12581257 }
12591258
@@ -1262,7 +1261,6 @@ func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) {
12621261 Port: h.configSvc.GetServerPort(),
12631262 RegistryPort: h.configSvc.GetRegistryPort(),
12641263 RegistryDomain: h.configSvc.GetRegistryDomain(),
1265- DataDir: h.configSvc.GetDataDir(),
12661264 },
12671265 AutoRoute: dto.AutoRouteConfig{
12681266 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) {
11221122
11231123 // Config endpoint tests
11241124
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+
11251154 func TestHandler_Config_RequiresReadScope(t *testing.T) {
11261155 configSvc := inmocks.NewMockConfigService(t)
11271156 authSvc := inmocks.NewMockAuthService(t)
@@ -1122,6 +1122,35 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
1122 1122
1123 // Config endpoint tests1123 // 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
8484 return fmt.Errorf("failed to read pass keys for %s: %w", domainName, err)
8585 }
8686
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)
9292 }
9393
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)
9796 }
9897
9998 log.Info().
100- Int(zerowrap.FieldCount, len(missing)).
99+ Int(zerowrap.FieldCount, len(secrets)).
101100 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")
106102
107103 return nil
108104 }
109105
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-
135106 func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
136107 entries, err := readEnvDir(envDir)
137108 if err != nil {
@@ -190,29 +161,24 @@ func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.Pass
190161 existingKeys = append(existingKeys, key)
191162 }
192163
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)
206166 }
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")
207174
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)
211177 }
212178
213179 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")
216182
217183 return nil
218184 }
@@ -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 nil103 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 nil183 return nil
218 }184 }
M internal/app/migrate_env_test.go +4 -6
@@ -71,12 +71,11 @@ func TestMigrateEnvFile(t *testing.T) {
7171 err = migrateEnvFile(tmpDir, envFileName, store, logger)
7272 require.NoError(t, err)
7373
74- migratedPath := envFilePath + ".migrated"
7574 _, err = os.Stat(envFilePath)
7675 assert.True(t, os.IsNotExist(err), "original .env file should be removed")
7776
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")
8079
8180 passKeys, err := store.ListKeys(domainName)
8281 require.NoError(t, err)
@@ -125,12 +124,11 @@ func TestMigrateAttachmentEnvFile(t *testing.T) {
125124 err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger)
126125 require.NoError(t, err)
127126
128- migratedPath := envFilePath + ".migrated"
129127 _, err = os.Stat(envFilePath)
130128 assert.True(t, os.IsNotExist(err), "original .env file should be removed")
131129
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")
134132
135133 values, err := store.GetAllAttachment(storedContainerName)
136134 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 (
2323 "github.com/bnema/zerowrap"
2424 zerowrapotel "github.com/bnema/zerowrap/otel"
2525 "github.com/spf13/viper"
26+ "golang.org/x/sys/unix"
2627
2728 // Adapters - Output
2829 "github.com/bnema/gordon/internal/adapters/out/accesslog"
@@ -1336,18 +1337,71 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
13361337 provider := secrets.NewSopsProvider(log)
13371338 return provider.GetSecret(ctx, path)
13381339 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)
13461342 default:
13471343 return "", fmt.Errorf("unknown secrets backend: %s", backend)
13481344 }
13491345 }
13501346
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+
13511405 // proxyConfigResult holds parsed proxy and blob chunk size config.
13521406 type proxyConfigResult struct {
13531407 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 - Output28 // 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.Config1407 proxyConfig proxy.Config
M internal/app/run_auth_config_test.go +67 -0
@@ -2,6 +2,8 @@ package app
22
33 import (
44 "context"
5+ "os"
6+ "path/filepath"
57 "testing"
68
79 "github.com/bnema/gordon/internal/domain"
@@ -65,3 +67,68 @@ func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
6567 require.NoError(t, err)
6668 assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend")
6769 }
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+}

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