detached ec63ba9

Harden auth secret handling and admin caching

commit
ec63ba92dcebcea95e4b53a2b16082461043c3b6
author
brice · · 2026-03-05 21:31 UTC
parent
36dfce1
signature
unsigned
13 files · +162 -50
M internal/adapters/in/http/admin/middleware.go +9 -0
@@ -106,6 +106,11 @@ func AuthMiddleware(
106106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
107107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
108108
109+ // Prevent intermediaries from storing admin API responses, especially
110+ // when a rotated bearer token is returned in X-Gordon-Token.
111+ w.Header().Set("Cache-Control", "no-store")
112+ w.Header().Set("Pragma", "no-cache")
113+
109114 // Attempt to slide token expiry. Non-fatal if it fails.
110115 // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
111116 if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
@@ -163,6 +168,8 @@ func HasAccess(ctx context.Context, resource, action string) bool {
163168
164169 func sendUnauthorized(w http.ResponseWriter, message string) {
165170 w.Header().Set("Content-Type", "application/json")
171+ w.Header().Set("Cache-Control", "no-store")
172+ w.Header().Set("Pragma", "no-cache")
166173 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
167174 w.WriteHeader(http.StatusUnauthorized)
168175 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
@@ -170,6 +177,8 @@ func sendUnauthorized(w http.ResponseWriter, message string) {
170177
171178 func sendForbidden(w http.ResponseWriter, message string) {
172179 w.Header().Set("Content-Type", "application/json")
180+ w.Header().Set("Cache-Control", "no-store")
181+ w.Header().Set("Pragma", "no-cache")
173182 w.WriteHeader(http.StatusForbidden)
174183 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
175184 }
@@ -106,6 +106,11 @@ func AuthMiddleware(
106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)106 ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject)
107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)107 ctx = context.WithValue(ctx, domain.TokenClaimsKey, claims)
108 108
109+ // Prevent intermediaries from storing admin API responses, especially
110+ // when a rotated bearer token is returned in X-Gordon-Token.
111+ w.Header().Set("Cache-Control", "no-store")
112+ w.Header().Set("Pragma", "no-cache")
113+
109 // Attempt to slide token expiry. Non-fatal if it fails.114 // Attempt to slide token expiry. Non-fatal if it fails.
110 // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.115 // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
111 if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {116 if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
@@ -163,6 +168,8 @@ func HasAccess(ctx context.Context, resource, action string) bool {
163 168
164 func sendUnauthorized(w http.ResponseWriter, message string) {169 func sendUnauthorized(w http.ResponseWriter, message string) {
165 w.Header().Set("Content-Type", "application/json")170 w.Header().Set("Content-Type", "application/json")
171+ w.Header().Set("Cache-Control", "no-store")
172+ w.Header().Set("Pragma", "no-cache")
166 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)173 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
167 w.WriteHeader(http.StatusUnauthorized)174 w.WriteHeader(http.StatusUnauthorized)
168 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})175 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
@@ -170,6 +177,8 @@ func sendUnauthorized(w http.ResponseWriter, message string) {
170 177
171 func sendForbidden(w http.ResponseWriter, message string) {178 func sendForbidden(w http.ResponseWriter, message string) {
172 w.Header().Set("Content-Type", "application/json")179 w.Header().Set("Content-Type", "application/json")
180+ w.Header().Set("Cache-Control", "no-store")
181+ w.Header().Set("Pragma", "no-cache")
173 w.WriteHeader(http.StatusForbidden)182 w.WriteHeader(http.StatusForbidden)
174 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})183 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
175 }184 }
M internal/adapters/in/http/admin/middleware_test.go +6 -0
@@ -181,6 +181,8 @@ func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
181181
182182 assert.Equal(t, http.StatusUnauthorized, rec.Code)
183183 assert.Contains(t, rec.Body.String(), "missing authorization header")
184+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
185+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
184186 }
185187
186188 func TestAuthMiddleware_InvalidToken(t *testing.T) {
@@ -390,6 +392,8 @@ func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
390392
391393 assert.Equal(t, http.StatusOK, rec.Code)
392394 assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
395+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
396+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
393397 }
394398
395399 func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
@@ -417,6 +421,8 @@ func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
417421
418422 assert.Equal(t, http.StatusOK, rec.Code)
419423 assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
424+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
425+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
420426 }
421427
422428 func TestHasAccess(t *testing.T) {
@@ -181,6 +181,8 @@ func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
181 181
182 assert.Equal(t, http.StatusUnauthorized, rec.Code)182 assert.Equal(t, http.StatusUnauthorized, rec.Code)
183 assert.Contains(t, rec.Body.String(), "missing authorization header")183 assert.Contains(t, rec.Body.String(), "missing authorization header")
184+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
185+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
184 }186 }
185 187
186 func TestAuthMiddleware_InvalidToken(t *testing.T) {188 func TestAuthMiddleware_InvalidToken(t *testing.T) {
@@ -390,6 +392,8 @@ func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
390 392
391 assert.Equal(t, http.StatusOK, rec.Code)393 assert.Equal(t, http.StatusOK, rec.Code)
392 assert.Empty(t, rec.Header().Get("X-Gordon-Token"))394 assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
395+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
396+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
393 }397 }
394 398
395 func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {399 func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
@@ -417,6 +421,8 @@ func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
417 421
418 assert.Equal(t, http.StatusOK, rec.Code)422 assert.Equal(t, http.StatusOK, rec.Code)
419 assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))423 assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
424+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
425+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
420 }426 }
421 427
422 func TestHasAccess(t *testing.T) {428 func TestHasAccess(t *testing.T) {
M internal/adapters/out/domainsecrets/store_test.go +23 -5
@@ -101,7 +101,7 @@ func TestFileStore_PathContainment(t *testing.T) {
101101 require.NoError(t, err)
102102
103103 // Verify file exists inside tmpDir
104- expectedFile := filepath.Join(tmpDir, "example_com.env")
104+ expectedFile := filepath.Join(tmpDir, "example__com.env")
105105 _, err = os.Stat(expectedFile)
106106 assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
107107
@@ -116,6 +116,24 @@ func TestFileStore_PathContainment(t *testing.T) {
116116 }
117117 }
118118
119+func TestFileStore_DistinctDomainsUseDistinctFiles(t *testing.T) {
120+ store, err := NewFileStore(t.TempDir(), testLogger())
121+ require.NoError(t, err)
122+
123+ dotPath, err := store.validateEnvFilePath("app.example.com")
124+ require.NoError(t, err)
125+
126+ portPath, err := store.validateEnvFilePath("app:example:com")
127+ require.NoError(t, err)
128+
129+ pathPath, err := store.validateEnvFilePath("app/example/com")
130+ require.NoError(t, err)
131+
132+ assert.NotEqual(t, dotPath, portPath)
133+ assert.NotEqual(t, dotPath, pathPath)
134+ assert.NotEqual(t, portPath, pathPath)
135+}
136+
119137 func TestSanitizeDomainForContainer(t *testing.T) {
120138 tests := []struct {
121139 domain string
@@ -389,10 +407,10 @@ func TestGetEnvFilePath(t *testing.T) {
389407 wantEmpty bool
390408 wantFilename string
391409 }{
392- {"example.com", false, "example_com.env"},
393- {"sub.example.com", false, "sub_example_com.env"},
394- {"example.com:8080", false, "example_com_8080.env"},
395- {"example.com/path", false, "example_com_path.env"},
410+ {"example.com", false, "example__com.env"},
411+ {"sub.example.com", false, "sub__example__com.env"},
412+ {"example.com:8080", false, "example__com-_8080.env"},
413+ {"example.com/path", false, "example__com--path.env"},
396414 {"../evil", true, ""},
397415 {"", true, ""},
398416 }
@@ -101,7 +101,7 @@ func TestFileStore_PathContainment(t *testing.T) {
101 require.NoError(t, err)101 require.NoError(t, err)
102 102
103 // Verify file exists inside tmpDir103 // Verify file exists inside tmpDir
104- expectedFile := filepath.Join(tmpDir, "example_com.env")104+ expectedFile := filepath.Join(tmpDir, "example__com.env")
105 _, err = os.Stat(expectedFile)105 _, err = os.Stat(expectedFile)
106 assert.NoError(t, err, "expected env file to exist at %s", expectedFile)106 assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
107 107
@@ -116,6 +116,24 @@ func TestFileStore_PathContainment(t *testing.T) {
116 }116 }
117 }117 }
118 118
119+func TestFileStore_DistinctDomainsUseDistinctFiles(t *testing.T) {
120+ store, err := NewFileStore(t.TempDir(), testLogger())
121+ require.NoError(t, err)
122+
123+ dotPath, err := store.validateEnvFilePath("app.example.com")
124+ require.NoError(t, err)
125+
126+ portPath, err := store.validateEnvFilePath("app:example:com")
127+ require.NoError(t, err)
128+
129+ pathPath, err := store.validateEnvFilePath("app/example/com")
130+ require.NoError(t, err)
131+
132+ assert.NotEqual(t, dotPath, portPath)
133+ assert.NotEqual(t, dotPath, pathPath)
134+ assert.NotEqual(t, portPath, pathPath)
135+}
136+
119 func TestSanitizeDomainForContainer(t *testing.T) {137 func TestSanitizeDomainForContainer(t *testing.T) {
120 tests := []struct {138 tests := []struct {
121 domain string139 domain string
@@ -389,10 +407,10 @@ func TestGetEnvFilePath(t *testing.T) {
389 wantEmpty bool407 wantEmpty bool
390 wantFilename string408 wantFilename string
391 }{409 }{
392- {"example.com", false, "example_com.env"},410+ {"example.com", false, "example__com.env"},
393- {"sub.example.com", false, "sub_example_com.env"},411+ {"sub.example.com", false, "sub__example__com.env"},
394- {"example.com:8080", false, "example_com_8080.env"},412+ {"example.com:8080", false, "example__com-_8080.env"},
395- {"example.com/path", false, "example_com_path.env"},413+ {"example.com/path", false, "example__com--path.env"},
396 {"../evil", true, ""},414 {"../evil", true, ""},
397 {"", true, ""},415 {"", true, ""},
398 }416 }
M internal/adapters/out/envloader/file.go +6 -5
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/zerowrap"
1313
1414 "github.com/bnema/gordon/internal/boundaries/out"
15+ "github.com/bnema/gordon/internal/domain"
1516 )
1617
1718 // FileLoader implements the EnvLoader interface using filesystem-based env files.
@@ -222,10 +223,10 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
222223 return result, nil
223224 }
224225
225-func (l *FileLoader) getEnvFilePath(domain string) string {
226- // Create domain-safe filename (replace dots and other chars with underscores)
227- safeDomain := strings.ReplaceAll(domain, ".", "_")
228- safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
229- safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
226+func (l *FileLoader) getEnvFilePath(domainName string) string {
227+ safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
228+ if err != nil {
229+ return filepath.Join(l.envDir, domainName+".env")
230+ }
230231 return filepath.Join(l.envDir, safeDomain+".env")
231232 }
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13 13
14 "github.com/bnema/gordon/internal/boundaries/out"14 "github.com/bnema/gordon/internal/boundaries/out"
15+ "github.com/bnema/gordon/internal/domain"
15 )16 )
16 17
17 // FileLoader implements the EnvLoader interface using filesystem-based env files.18 // FileLoader implements the EnvLoader interface using filesystem-based env files.
@@ -222,10 +223,10 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
222 return result, nil223 return result, nil
223 }224 }
224 225
225-func (l *FileLoader) getEnvFilePath(domain string) string {226+func (l *FileLoader) getEnvFilePath(domainName string) string {
226- // Create domain-safe filename (replace dots and other chars with underscores)227+ safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
227- safeDomain := strings.ReplaceAll(domain, ".", "_")228+ if err != nil {
228- safeDomain = strings.ReplaceAll(safeDomain, ":", "_")229+ return filepath.Join(l.envDir, domainName+".env")
229- safeDomain = strings.ReplaceAll(safeDomain, "/", "_")230+ }
230 return filepath.Join(l.envDir, safeDomain+".env")231 return filepath.Join(l.envDir, safeDomain+".env")
231 }232 }
M internal/adapters/out/envloader/file_test.go +23 -7
@@ -121,7 +121,7 @@ func TestFileLoader_LoadEnv(t *testing.T) {
121121 NODE_ENV=production
122122 API_URL=https://api.example.com
123123 `
124- envFile := filepath.Join(tmpDir, "app_example_com.env")
124+ envFile := filepath.Join(tmpDir, "app__example__com.env")
125125 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126126
127127 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -146,7 +146,7 @@ API_URL=https://api.example.com
146146 envContent := `NODE_ENV=production
147147 API_KEY=${pass:app/api-key}
148148 `
149- envFile := filepath.Join(tmpDir, "app_example_com.env")
149+ envFile := filepath.Join(tmpDir, "app__example__com.env")
150150 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151151
152152 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -164,7 +164,7 @@ API_KEY=${pass:app/api-key}
164164 envContent := `DOUBLE_QUOTED="hello world"
165165 SINGLE_QUOTED='hello world'
166166 `
167- envFile := filepath.Join(tmpDir, "app_example_com.env")
167+ envFile := filepath.Join(tmpDir, "app__example__com.env")
168168 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169169
170170 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -183,7 +183,7 @@ SINGLE_QUOTED='hello world'
183183 invalid line without equals
184184 ALSO_VALID=another
185185 `
186- envFile := filepath.Join(tmpDir, "app_example_com.env")
186+ envFile := filepath.Join(tmpDir, "app__example__com.env")
187187 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188188
189189 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -205,9 +205,9 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) {
205205 domain string
206206 expected string
207207 }{
208- {"app.example.com", "app_example_com.env"},
209- {"api.sub.example.com", "api_sub_example_com.env"},
210- {"localhost:8080", "localhost_8080.env"},
208+ {"app.example.com", "app__example__com.env"},
209+ {"api.sub.example.com", "api__sub__example__com.env"},
210+ {"localhost:8080", "localhost-_8080.env"},
211211 }
212212
213213 for _, tt := range tests {
@@ -217,3 +217,19 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) {
217217 })
218218 }
219219 }
220+
221+func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) {
222+ tmpDir := t.TempDir()
223+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
224+
225+ loader, err := NewFileLoader(tmpDir, log)
226+ require.NoError(t, err)
227+
228+ dotPath := loader.getEnvFilePath("app.example.com")
229+ portPath := loader.getEnvFilePath("app:example:com")
230+ slashPath := loader.getEnvFilePath("app/example/com")
231+
232+ assert.NotEqual(t, dotPath, portPath)
233+ assert.NotEqual(t, dotPath, slashPath)
234+ assert.NotEqual(t, portPath, slashPath)
235+}
@@ -121,7 +121,7 @@ func TestFileLoader_LoadEnv(t *testing.T) {
121 NODE_ENV=production121 NODE_ENV=production
122 API_URL=https://api.example.com122 API_URL=https://api.example.com
123 `123 `
124- envFile := filepath.Join(tmpDir, "app_example_com.env")124+ envFile := filepath.Join(tmpDir, "app__example__com.env")
125 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))125 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126 126
127 envVars, err := loader.LoadEnv(ctx, "app.example.com")127 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -146,7 +146,7 @@ API_URL=https://api.example.com
146 envContent := `NODE_ENV=production146 envContent := `NODE_ENV=production
147 API_KEY=${pass:app/api-key}147 API_KEY=${pass:app/api-key}
148 `148 `
149- envFile := filepath.Join(tmpDir, "app_example_com.env")149+ envFile := filepath.Join(tmpDir, "app__example__com.env")
150 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))150 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151 151
152 envVars, err := loader.LoadEnv(ctx, "app.example.com")152 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -164,7 +164,7 @@ API_KEY=${pass:app/api-key}
164 envContent := `DOUBLE_QUOTED="hello world"164 envContent := `DOUBLE_QUOTED="hello world"
165 SINGLE_QUOTED='hello world'165 SINGLE_QUOTED='hello world'
166 `166 `
167- envFile := filepath.Join(tmpDir, "app_example_com.env")167+ envFile := filepath.Join(tmpDir, "app__example__com.env")
168 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))168 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169 169
170 envVars, err := loader.LoadEnv(ctx, "app.example.com")170 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -183,7 +183,7 @@ SINGLE_QUOTED='hello world'
183 invalid line without equals183 invalid line without equals
184 ALSO_VALID=another184 ALSO_VALID=another
185 `185 `
186- envFile := filepath.Join(tmpDir, "app_example_com.env")186+ envFile := filepath.Join(tmpDir, "app__example__com.env")
187 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))187 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188 188
189 envVars, err := loader.LoadEnv(ctx, "app.example.com")189 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -205,9 +205,9 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) {
205 domain string205 domain string
206 expected string206 expected string
207 }{207 }{
208- {"app.example.com", "app_example_com.env"},208+ {"app.example.com", "app__example__com.env"},
209- {"api.sub.example.com", "api_sub_example_com.env"},209+ {"api.sub.example.com", "api__sub__example__com.env"},
210- {"localhost:8080", "localhost_8080.env"},210+ {"localhost:8080", "localhost-_8080.env"},
211 }211 }
212 212
213 for _, tt := range tests {213 for _, tt := range tests {
@@ -217,3 +217,19 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) {
217 })217 })
218 }218 }
219 }219 }
220+
221+func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) {
222+ tmpDir := t.TempDir()
223+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
224+
225+ loader, err := NewFileLoader(tmpDir, log)
226+ require.NoError(t, err)
227+
228+ dotPath := loader.getEnvFilePath("app.example.com")
229+ portPath := loader.getEnvFilePath("app:example:com")
230+ slashPath := loader.getEnvFilePath("app/example/com")
231+
232+ assert.NotEqual(t, dotPath, portPath)
233+ assert.NotEqual(t, dotPath, slashPath)
234+ assert.NotEqual(t, portPath, slashPath)
235+}
M internal/app/run.go +16 -8
@@ -585,7 +585,10 @@ func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
585585
586586 func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
587587 envDir := resolveEnvDir(cfg)
588- backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
588+ backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
589+ if err != nil {
590+ return "", "", nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
591+ }
589592
590593 switch backend {
591594 case domain.SecretsBackendPass:
@@ -896,7 +899,10 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou
896899 }
897900
898901 authType := resolveAuthType(cfg)
899- backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)
902+ backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
903+ if err != nil {
904+ return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
905+ }
900906 dataDir := resolveDataDir(cfg.Server.DataDir)
901907
902908 store, err := createTokenStore(backend, dataDir, log)
@@ -941,16 +947,18 @@ func resolveAuthType(cfg Config) domain.AuthType {
941947 return domain.AuthTypeToken
942948 }
943949
944-func resolveSecretsBackend(backend string) domain.SecretsBackend {
950+func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {
945951 switch backend {
946952 case "pass":
947- return domain.SecretsBackendPass
953+ return domain.SecretsBackendPass, nil
948954 case "sops":
949- return domain.SecretsBackendSops
950- case "unsafe", "":
951- return domain.SecretsBackendUnsafe
955+ return domain.SecretsBackendSops, nil
956+ case "unsafe":
957+ return domain.SecretsBackendUnsafe, nil
958+ case "":
959+ return "", fmt.Errorf("auth.secrets_backend is required")
952960 default:
953- return domain.SecretsBackendUnsafe
961+ return "", fmt.Errorf("unsupported auth.secrets_backend %q", backend)
954962 }
955963 }
956964
@@ -585,7 +585,10 @@ func setupInternalRegistryAuth(svc *services, log zerowrap.Logger) error {
585 585
586 func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {586 func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) {
587 envDir := resolveEnvDir(cfg)587 envDir := resolveEnvDir(cfg)
588- backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)588+ backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
589+ if err != nil {
590+ return "", "", nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
591+ }
589 592
590 switch backend {593 switch backend {
591 case domain.SecretsBackendPass:594 case domain.SecretsBackendPass:
@@ -896,7 +899,10 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou
896 }899 }
897 900
898 authType := resolveAuthType(cfg)901 authType := resolveAuthType(cfg)
899- backend := resolveSecretsBackend(cfg.Auth.SecretsBackend)902+ backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
903+ if err != nil {
904+ return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
905+ }
900 dataDir := resolveDataDir(cfg.Server.DataDir)906 dataDir := resolveDataDir(cfg.Server.DataDir)
901 907
902 store, err := createTokenStore(backend, dataDir, log)908 store, err := createTokenStore(backend, dataDir, log)
@@ -941,16 +947,18 @@ func resolveAuthType(cfg Config) domain.AuthType {
941 return domain.AuthTypeToken947 return domain.AuthTypeToken
942 }948 }
943 949
944-func resolveSecretsBackend(backend string) domain.SecretsBackend {950+func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {
945 switch backend {951 switch backend {
946 case "pass":952 case "pass":
947- return domain.SecretsBackendPass953+ return domain.SecretsBackendPass, nil
948 case "sops":954 case "sops":
949- return domain.SecretsBackendSops955+ return domain.SecretsBackendSops, nil
950- case "unsafe", "":956+ case "unsafe":
951- return domain.SecretsBackendUnsafe957+ return domain.SecretsBackendUnsafe, nil
958+ case "":
959+ return "", fmt.Errorf("auth.secrets_backend is required")
952 default:960 default:
953- return domain.SecretsBackendUnsafe961+ return "", fmt.Errorf("unsupported auth.secrets_backend %q", backend)
954 }962 }
955 }963 }
956 964
M internal/app/run_auth_config_test.go +21 -0
@@ -2,6 +2,7 @@ package app
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/gordon/internal/domain"
@@ -23,3 +24,23 @@ func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
2324 t.Fatalf("expected auth config enabled=false, got true")
2425 }
2526 }
27+
28+func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) {
29+ _, err := resolveSecretsBackend("")
30+ if err == nil {
31+ t.Fatal("expected error for missing secrets backend")
32+ }
33+ if !strings.Contains(err.Error(), "auth.secrets_backend is required") {
34+ t.Fatalf("unexpected error: %v", err)
35+ }
36+}
37+
38+func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {
39+ _, err := resolveSecretsBackend("vault")
40+ if err == nil {
41+ t.Fatal("expected error for unsupported secrets backend")
42+ }
43+ if !strings.Contains(err.Error(), `unsupported auth.secrets_backend "vault"`) {
44+ t.Fatalf("unexpected error: %v", err)
45+ }
46+}
@@ -2,6 +2,7 @@ package app
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/gordon/internal/domain"8 "github.com/bnema/gordon/internal/domain"
@@ -23,3 +24,23 @@ func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
23 t.Fatalf("expected auth config enabled=false, got true")24 t.Fatalf("expected auth config enabled=false, got true")
24 }25 }
25 }26 }
27+
28+func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) {
29+ _, err := resolveSecretsBackend("")
30+ if err == nil {
31+ t.Fatal("expected error for missing secrets backend")
32+ }
33+ if !strings.Contains(err.Error(), "auth.secrets_backend is required") {
34+ t.Fatalf("unexpected error: %v", err)
35+ }
36+}
37+
38+func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {
39+ _, err := resolveSecretsBackend("vault")
40+ if err == nil {
41+ t.Fatal("expected error for unsupported secrets backend")
42+ }
43+ if !strings.Contains(err.Error(), `unsupported auth.secrets_backend "vault"`) {
44+ t.Fatalf("unexpected error: %v", err)
45+ }
46+}
M internal/domain/env.go +7 -5
@@ -9,13 +9,15 @@ import (
99
1010 // envDomainRegex validates domain names used for env secrets.
1111 // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
12-var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
12+// Underscores are intentionally disallowed because they would collide with the
13+// on-disk filename encoding used for dots/ports/path separators.
14+var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
1315 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
1416 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
1517
1618 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
1719 // Returns a safe domain string suitable for filenames.
18-// This function is idempotent: calling it on already-sanitized input returns the same value.
20+// Uses distinct replacements to avoid collisions between ".", ":", and "/".
1921 func SanitizeDomainForEnvFile(domainName string) (string, error) {
2022 if domainName == "" {
2123 return "", ErrPathTraversal
@@ -29,9 +31,9 @@ func SanitizeDomainForEnvFile(domainName string) (string, error) {
2931 return "", ErrPathTraversal
3032 }
3133
32- safeDomain := strings.ReplaceAll(domainName, ".", "_")
33- safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
34- safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
34+ safeDomain := strings.ReplaceAll(domainName, ".", "__")
35+ safeDomain = strings.ReplaceAll(safeDomain, ":", "-_")
36+ safeDomain = strings.ReplaceAll(safeDomain, "/", "--")
3537 return safeDomain, nil
3638 }
3739
@@ -9,13 +9,15 @@ import (
9 9
10 // envDomainRegex validates domain names used for env secrets.10 // envDomainRegex validates domain names used for env secrets.
11 // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).11 // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
12-var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)12+// Underscores are intentionally disallowed because they would collide with the
13+// on-disk filename encoding used for dots/ports/path separators.
14+var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
13 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)15 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
14 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)16 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
15 17
16 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.18 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
17 // Returns a safe domain string suitable for filenames.19 // Returns a safe domain string suitable for filenames.
18-// This function is idempotent: calling it on already-sanitized input returns the same value.20+// Uses distinct replacements to avoid collisions between ".", ":", and "/".
19 func SanitizeDomainForEnvFile(domainName string) (string, error) {21 func SanitizeDomainForEnvFile(domainName string) (string, error) {
20 if domainName == "" {22 if domainName == "" {
21 return "", ErrPathTraversal23 return "", ErrPathTraversal
@@ -29,9 +31,9 @@ func SanitizeDomainForEnvFile(domainName string) (string, error) {
29 return "", ErrPathTraversal31 return "", ErrPathTraversal
30 }32 }
31 33
32- safeDomain := strings.ReplaceAll(domainName, ".", "_")34+ safeDomain := strings.ReplaceAll(domainName, ".", "__")
33- safeDomain = strings.ReplaceAll(safeDomain, ":", "_")35+ safeDomain = strings.ReplaceAll(safeDomain, ":", "-_")
34- safeDomain = strings.ReplaceAll(safeDomain, "/", "_")36+ safeDomain = strings.ReplaceAll(safeDomain, "/", "--")
35 return safeDomain, nil37 return safeDomain, nil
36 }38 }
37 39
M internal/domain/env_test.go +21 -7
@@ -15,12 +15,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
1515 want string
1616 wantErr bool
1717 }{
18- {name: "simple domain", domain: "example.com", want: "example_com"},
19- {name: "subdomain", domain: "app.example.com", want: "app_example_com"},
20- {name: "domain with port", domain: "example.com:8080", want: "example_com_8080"},
21- {name: "domain with path", domain: "example.com/path", want: "example_com_path"},
18+ {name: "simple domain", domain: "example.com", want: "example__com"},
19+ {name: "subdomain", domain: "app.example.com", want: "app__example__com"},
20+ {name: "domain with port", domain: "example.com:8080", want: "example__com-_8080"},
21+ {name: "domain with path", domain: "example.com/path", want: "example__com--path"},
2222 {name: "single char", domain: "a", want: "a"},
23- {name: "hyphenated", domain: "my-app.example.com", want: "my-app_example_com"},
23+ {name: "hyphenated", domain: "my-app.example.com", want: "my-app__example__com"},
2424 {name: "empty", domain: "", wantErr: true},
2525 {name: "path traversal", domain: "../etc/passwd", wantErr: true},
2626 {name: "double dots", domain: "foo..bar", wantErr: true},
@@ -28,8 +28,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
2828 {name: "ends with dot", domain: "trailing.", wantErr: true},
2929 {name: "space", domain: "has space", wantErr: true},
3030 {name: "special chars", domain: "bad$domain", wantErr: true},
31- {name: "idempotent simple", domain: "example_com", want: "example_com"},
32- {name: "idempotent complex", domain: "my-app_example_com_8080", want: "my-app_example_com_8080"},
31+ {name: "underscore rejected to avoid filename collision", domain: "example_com", wantErr: true},
3332 }
3433
3534 for _, tt := range tests {
@@ -45,6 +44,21 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
4544 }
4645 }
4746
47+func TestSanitizeDomainForEnvFile_DistinguishesSeparators(t *testing.T) {
48+ gotDot, err := SanitizeDomainForEnvFile("app.example.com")
49+ require.NoError(t, err)
50+
51+ gotPort, err := SanitizeDomainForEnvFile("app:example:com")
52+ require.NoError(t, err)
53+
54+ gotPath, err := SanitizeDomainForEnvFile("app/example/com")
55+ require.NoError(t, err)
56+
57+ assert.NotEqual(t, gotDot, gotPort)
58+ assert.NotEqual(t, gotDot, gotPath)
59+ assert.NotEqual(t, gotPort, gotPath)
60+}
61+
4862 func TestParseEnvData(t *testing.T) {
4963 tests := []struct {
5064 name string
@@ -15,12 +15,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
15 want string15 want string
16 wantErr bool16 wantErr bool
17 }{17 }{
18- {name: "simple domain", domain: "example.com", want: "example_com"},18+ {name: "simple domain", domain: "example.com", want: "example__com"},
19- {name: "subdomain", domain: "app.example.com", want: "app_example_com"},19+ {name: "subdomain", domain: "app.example.com", want: "app__example__com"},
20- {name: "domain with port", domain: "example.com:8080", want: "example_com_8080"},20+ {name: "domain with port", domain: "example.com:8080", want: "example__com-_8080"},
21- {name: "domain with path", domain: "example.com/path", want: "example_com_path"},21+ {name: "domain with path", domain: "example.com/path", want: "example__com--path"},
22 {name: "single char", domain: "a", want: "a"},22 {name: "single char", domain: "a", want: "a"},
23- {name: "hyphenated", domain: "my-app.example.com", want: "my-app_example_com"},23+ {name: "hyphenated", domain: "my-app.example.com", want: "my-app__example__com"},
24 {name: "empty", domain: "", wantErr: true},24 {name: "empty", domain: "", wantErr: true},
25 {name: "path traversal", domain: "../etc/passwd", wantErr: true},25 {name: "path traversal", domain: "../etc/passwd", wantErr: true},
26 {name: "double dots", domain: "foo..bar", wantErr: true},26 {name: "double dots", domain: "foo..bar", wantErr: true},
@@ -28,8 +28,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
28 {name: "ends with dot", domain: "trailing.", wantErr: true},28 {name: "ends with dot", domain: "trailing.", wantErr: true},
29 {name: "space", domain: "has space", wantErr: true},29 {name: "space", domain: "has space", wantErr: true},
30 {name: "special chars", domain: "bad$domain", wantErr: true},30 {name: "special chars", domain: "bad$domain", wantErr: true},
31- {name: "idempotent simple", domain: "example_com", want: "example_com"},31+ {name: "underscore rejected to avoid filename collision", domain: "example_com", wantErr: true},
32- {name: "idempotent complex", domain: "my-app_example_com_8080", want: "my-app_example_com_8080"},
33 }32 }
34 33
35 for _, tt := range tests {34 for _, tt := range tests {
@@ -45,6 +44,21 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
45 }44 }
46 }45 }
47 46
47+func TestSanitizeDomainForEnvFile_DistinguishesSeparators(t *testing.T) {
48+ gotDot, err := SanitizeDomainForEnvFile("app.example.com")
49+ require.NoError(t, err)
50+
51+ gotPort, err := SanitizeDomainForEnvFile("app:example:com")
52+ require.NoError(t, err)
53+
54+ gotPath, err := SanitizeDomainForEnvFile("app/example/com")
55+ require.NoError(t, err)
56+
57+ assert.NotEqual(t, gotDot, gotPort)
58+ assert.NotEqual(t, gotDot, gotPath)
59+ assert.NotEqual(t, gotPort, gotPath)
60+}
61+
48 func TestParseEnvData(t *testing.T) {62 func TestParseEnvData(t *testing.T) {
49 tests := []struct {63 tests := []struct {
50 name string64 name string
M internal/usecase/container/autoroute.go +4 -4
@@ -468,10 +468,10 @@ func parseEnvFile(data []byte) (map[string]string, error) {
468468 // e.g., "app.mydomain.com" -> "app_mydomain_com.env"
469469 // Must match the naming convention in envloader.FileLoader.getEnvFilePath
470470 func domainToEnvFileName(domainName string) string {
471- // Replace special characters with underscores (matches envloader)
472- name := strings.ReplaceAll(domainName, ".", "_")
473- name = strings.ReplaceAll(name, ":", "_")
474- name = strings.ReplaceAll(name, "/", "_")
471+ name, err := domain.SanitizeDomainForEnvFile(domainName)
472+ if err != nil {
473+ return domainName + ".env"
474+ }
475475 return name + ".env"
476476 }
477477
@@ -468,10 +468,10 @@ func parseEnvFile(data []byte) (map[string]string, error) {
468 // e.g., "app.mydomain.com" -> "app_mydomain_com.env"468 // e.g., "app.mydomain.com" -> "app_mydomain_com.env"
469 // Must match the naming convention in envloader.FileLoader.getEnvFilePath469 // Must match the naming convention in envloader.FileLoader.getEnvFilePath
470 func domainToEnvFileName(domainName string) string {470 func domainToEnvFileName(domainName string) string {
471- // Replace special characters with underscores (matches envloader)471+ name, err := domain.SanitizeDomainForEnvFile(domainName)
472- name := strings.ReplaceAll(domainName, ".", "_")472+ if err != nil {
473- name = strings.ReplaceAll(name, ":", "_")473+ return domainName + ".env"
474- name = strings.ReplaceAll(name, "/", "_")474+ }
475 return name + ".env"475 return name + ".env"
476 }476 }
477 477
M internal/usecase/container/autoroute_test.go +4 -4
@@ -28,22 +28,22 @@ func TestDomainToEnvFileName(t *testing.T) {
2828 {
2929 name: "simple domain",
3030 domain: "app.example.com",
31- expected: "app_example_com.env",
31+ expected: "app__example__com.env",
3232 },
3333 {
3434 name: "domain with port",
3535 domain: "app.example.com:8080",
36- expected: "app_example_com_8080.env",
36+ expected: "app__example__com-_8080.env",
3737 },
3838 {
3939 name: "domain with path",
4040 domain: "app.example.com/api",
41- expected: "app_example_com_api.env",
41+ expected: "app__example__com--api.env",
4242 },
4343 {
4444 name: "complex domain",
4545 domain: "sub.app.example.com:443/v1",
46- expected: "sub_app_example_com_443_v1.env",
46+ expected: "sub__app__example__com-_443--v1.env",
4747 },
4848 {
4949 name: "single word",
@@ -28,22 +28,22 @@ func TestDomainToEnvFileName(t *testing.T) {
28 {28 {
29 name: "simple domain",29 name: "simple domain",
30 domain: "app.example.com",30 domain: "app.example.com",
31- expected: "app_example_com.env",31+ expected: "app__example__com.env",
32 },32 },
33 {33 {
34 name: "domain with port",34 name: "domain with port",
35 domain: "app.example.com:8080",35 domain: "app.example.com:8080",
36- expected: "app_example_com_8080.env",36+ expected: "app__example__com-_8080.env",
37 },37 },
38 {38 {
39 name: "domain with path",39 name: "domain with path",
40 domain: "app.example.com/api",40 domain: "app.example.com/api",
41- expected: "app_example_com_api.env",41+ expected: "app__example__com--api.env",
42 },42 },
43 {43 {
44 name: "complex domain",44 name: "complex domain",
45 domain: "sub.app.example.com:443/v1",45 domain: "sub.app.example.com:443/v1",
46- expected: "sub_app_example_com_443_v1.env",46+ expected: "sub__app__example__com-_443--v1.env",
47 },47 },
48 {48 {
49 name: "single word",49 name: "single word",
M internal/usecase/secrets/service.go +11 -5
@@ -278,24 +278,30 @@ func resolveContainerName(domainName, service string) string {
278278
279279 // ValidateDomain validates that a domain is safe to use for secret storage.
280280 // This prevents path traversal attacks and ensures the domain is valid.
281-func ValidateDomain(domain string) error {
281+func ValidateDomain(domainName string) error {
282282 // Check for empty domain
283- if domain == "" {
283+ if domainName == "" {
284284 return ErrDomainEmpty
285285 }
286286
287287 // Check domain length (max DNS name length is 253)
288- if len(domain) > 253 {
288+ if len(domainName) > 253 {
289289 return ErrDomainTooLong
290290 }
291291
292292 // Check for path traversal attempts
293- if strings.Contains(domain, "..") {
293+ if strings.Contains(domainName, "..") {
294294 return ErrDomainPathTraversal
295295 }
296296
297297 // Check for null bytes (can cause issues in file paths)
298- if strings.ContainsRune(domain, '\x00') {
298+ if strings.ContainsRune(domainName, '\x00') {
299+ return ErrDomainInvalidChars
300+ }
301+
302+ // Reuse the env-file sanitizer so validation matches the on-disk naming
303+ // scheme and rejects ambiguous names that would collide after sanitization.
304+ if _, err := domain.SanitizeDomainForEnvFile(domainName); err != nil {
299305 return ErrDomainInvalidChars
300306 }
301307
@@ -278,24 +278,30 @@ func resolveContainerName(domainName, service string) string {
278 278
279 // ValidateDomain validates that a domain is safe to use for secret storage.279 // ValidateDomain validates that a domain is safe to use for secret storage.
280 // This prevents path traversal attacks and ensures the domain is valid.280 // This prevents path traversal attacks and ensures the domain is valid.
281-func ValidateDomain(domain string) error {281+func ValidateDomain(domainName string) error {
282 // Check for empty domain282 // Check for empty domain
283- if domain == "" {283+ if domainName == "" {
284 return ErrDomainEmpty284 return ErrDomainEmpty
285 }285 }
286 286
287 // Check domain length (max DNS name length is 253)287 // Check domain length (max DNS name length is 253)
288- if len(domain) > 253 {288+ if len(domainName) > 253 {
289 return ErrDomainTooLong289 return ErrDomainTooLong
290 }290 }
291 291
292 // Check for path traversal attempts292 // Check for path traversal attempts
293- if strings.Contains(domain, "..") {293+ if strings.Contains(domainName, "..") {
294 return ErrDomainPathTraversal294 return ErrDomainPathTraversal
295 }295 }
296 296
297 // Check for null bytes (can cause issues in file paths)297 // Check for null bytes (can cause issues in file paths)
298- if strings.ContainsRune(domain, '\x00') {298+ if strings.ContainsRune(domainName, '\x00') {
299+ return ErrDomainInvalidChars
300+ }
301+
302+ // Reuse the env-file sanitizer so validation matches the on-disk naming
303+ // scheme and rejects ambiguous names that would collide after sanitization.
304+ if _, err := domain.SanitizeDomainForEnvFile(domainName); err != nil {
299 return ErrDomainInvalidChars305 return ErrDomainInvalidChars
300 }306 }
301 307
M internal/usecase/secrets/service_test.go +11 -0
@@ -108,6 +108,11 @@ func TestValidateDomain(t *testing.T) {
108108 domain: "\x00example.com",
109109 wantErr: ErrDomainInvalidChars,
110110 },
111+ {
112+ name: "underscore rejected to avoid env filename collision",
113+ domain: "app_example.com",
114+ wantErr: ErrDomainInvalidChars,
115+ },
111116 }
112117
113118 for _, tt := range tests {
@@ -122,6 +127,12 @@ func TestValidateDomain(t *testing.T) {
122127 }
123128 }
124129
130+func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) {
131+ for _, domain := range []string{"app.example.com", "app:example:com", "app/example/com"} {
132+ assert.NoError(t, ValidateDomain(domain), "expected %q to remain valid", domain)
133+ }
134+}
135+
125136 func TestService_ListKeys_ValidationErrors(t *testing.T) {
126137 store := outmocks.NewMockDomainSecretStore(t)
127138 svc := NewService(store, testLogger())
@@ -108,6 +108,11 @@ func TestValidateDomain(t *testing.T) {
108 domain: "\x00example.com",108 domain: "\x00example.com",
109 wantErr: ErrDomainInvalidChars,109 wantErr: ErrDomainInvalidChars,
110 },110 },
111+ {
112+ name: "underscore rejected to avoid env filename collision",
113+ domain: "app_example.com",
114+ wantErr: ErrDomainInvalidChars,
115+ },
111 }116 }
112 117
113 for _, tt := range tests {118 for _, tt := range tests {
@@ -122,6 +127,12 @@ func TestValidateDomain(t *testing.T) {
122 }127 }
123 }128 }
124 129
130+func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) {
131+ for _, domain := range []string{"app.example.com", "app:example:com", "app/example/com"} {
132+ assert.NoError(t, ValidateDomain(domain), "expected %q to remain valid", domain)
133+ }
134+}
135+
125 func TestService_ListKeys_ValidationErrors(t *testing.T) {136 func TestService_ListKeys_ValidationErrors(t *testing.T) {
126 store := outmocks.NewMockDomainSecretStore(t)137 store := outmocks.NewMockDomainSecretStore(t)
127 svc := NewService(store, testLogger())138 svc := NewService(store, testLogger())

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