detached a4cddd9

Merge pull request #93 from bnema/security-review-fixes-v2

Harden env storage and admin cache handling
commit
a4cddd9f5171d4cf65ac9ebb52e8b4da6c9ea9f6
author
bnema · · 2026-03-07 15:11 UTC
committer
GitHub · · 2026-03-07 15:11 UTC
merge of
36dfce1 + b87733b · diff against the first parent
signature
unsigned
22 files · +367 -186
M internal/adapters/in/cli/controlplane_resolver_test.go +2 -0
@@ -30,6 +30,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
3030
3131 [auth]
3232 enabled = true
33+secrets_backend = "unsafe"
3334 `), 0o600)
3435 if err != nil {
3536 t.Fatalf("failed to write config: %v", err)
@@ -70,6 +71,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
7071
7172 [auth]
7273 enabled = false
74+secrets_backend = "unsafe"
7375 `), 0o600)
7476 if err != nil {
7577 t.Fatalf("failed to write config: %v", err)
@@ -30,6 +30,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
30 30
31 [auth]31 [auth]
32 enabled = true32 enabled = true
33+secrets_backend = "unsafe"
33 `), 0o600)34 `), 0o600)
34 if err != nil {35 if err != nil {
35 t.Fatalf("failed to write config: %v", err)36 t.Fatalf("failed to write config: %v", err)
@@ -70,6 +71,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
70 71
71 [auth]72 [auth]
72 enabled = false73 enabled = false
74+secrets_backend = "unsafe"
73 `), 0o600)75 `), 0o600)
74 if err != nil {76 if err != nil {
75 t.Fatalf("failed to write config: %v", err)77 t.Fatalf("failed to write config: %v", err)
M internal/adapters/in/http/admin/middleware.go +18 -4
@@ -32,7 +32,7 @@ func AuthMiddleware(
3232
3333 // Check global rate limit
3434 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
35- w.Header().Set("Content-Type", "application/json")
35+ setAdminJSONHeaders(w)
3636 w.Header().Set("Retry-After", "1")
3737 w.WriteHeader(http.StatusTooManyRequests)
3838 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -43,7 +43,7 @@ func AuthMiddleware(
4343 if ipLimiter != nil {
4444 ip := middleware.GetClientIP(r, trustedNets)
4545 if !ipLimiter.Allow(ctx, "ip:"+ip) {
46- w.Header().Set("Content-Type", "application/json")
46+ setAdminJSONHeaders(w)
4747 w.Header().Set("Retry-After", "1")
4848 w.WriteHeader(http.StatusTooManyRequests)
4949 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -106,6 +106,10 @@ 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+ setNoStoreHeaders(w)
112+
109113 // Attempt to slide token expiry. Non-fatal if it fails.
110114 // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
111115 if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
@@ -162,14 +166,24 @@ func HasAccess(ctx context.Context, resource, action string) bool {
162166 }
163167
164168 func sendUnauthorized(w http.ResponseWriter, message string) {
165- w.Header().Set("Content-Type", "application/json")
169+ setAdminJSONHeaders(w)
166170 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
167171 w.WriteHeader(http.StatusUnauthorized)
168172 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
169173 }
170174
171175 func sendForbidden(w http.ResponseWriter, message string) {
172- w.Header().Set("Content-Type", "application/json")
176+ setAdminJSONHeaders(w)
173177 w.WriteHeader(http.StatusForbidden)
174178 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
175179 }
180+
181+func setAdminJSONHeaders(w http.ResponseWriter) {
182+ w.Header().Set("Content-Type", "application/json")
183+ setNoStoreHeaders(w)
184+}
185+
186+func setNoStoreHeaders(w http.ResponseWriter) {
187+ w.Header().Set("Cache-Control", "no-store")
188+ w.Header().Set("Pragma", "no-cache")
189+}
@@ -32,7 +32,7 @@ func AuthMiddleware(
32 32
33 // Check global rate limit33 // Check global rate limit
34 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {34 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
35- w.Header().Set("Content-Type", "application/json")35+ setAdminJSONHeaders(w)
36 w.Header().Set("Retry-After", "1")36 w.Header().Set("Retry-After", "1")
37 w.WriteHeader(http.StatusTooManyRequests)37 w.WriteHeader(http.StatusTooManyRequests)
38 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})38 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -43,7 +43,7 @@ func AuthMiddleware(
43 if ipLimiter != nil {43 if ipLimiter != nil {
44 ip := middleware.GetClientIP(r, trustedNets)44 ip := middleware.GetClientIP(r, trustedNets)
45 if !ipLimiter.Allow(ctx, "ip:"+ip) {45 if !ipLimiter.Allow(ctx, "ip:"+ip) {
46- w.Header().Set("Content-Type", "application/json")46+ setAdminJSONHeaders(w)
47 w.Header().Set("Retry-After", "1")47 w.Header().Set("Retry-After", "1")
48 w.WriteHeader(http.StatusTooManyRequests)48 w.WriteHeader(http.StatusTooManyRequests)
49 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})49 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -106,6 +106,10 @@ 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+ setNoStoreHeaders(w)
112+
109 // Attempt to slide token expiry. Non-fatal if it fails.113 // 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.114 // 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 {115 if newToken, extErr := authSvc.ExtendToken(ctx, token); extErr != nil {
@@ -162,14 +166,24 @@ func HasAccess(ctx context.Context, resource, action string) bool {
162 }166 }
163 167
164 func sendUnauthorized(w http.ResponseWriter, message string) {168 func sendUnauthorized(w http.ResponseWriter, message string) {
165- w.Header().Set("Content-Type", "application/json")169+ setAdminJSONHeaders(w)
166 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)170 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
167 w.WriteHeader(http.StatusUnauthorized)171 w.WriteHeader(http.StatusUnauthorized)
168 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})172 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
169 }173 }
170 174
171 func sendForbidden(w http.ResponseWriter, message string) {175 func sendForbidden(w http.ResponseWriter, message string) {
172- w.Header().Set("Content-Type", "application/json")176+ setAdminJSONHeaders(w)
173 w.WriteHeader(http.StatusForbidden)177 w.WriteHeader(http.StatusForbidden)
174 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})178 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
175 }179 }
180+
181+func setAdminJSONHeaders(w http.ResponseWriter) {
182+ w.Header().Set("Content-Type", "application/json")
183+ setNoStoreHeaders(w)
184+}
185+
186+func setNoStoreHeaders(w http.ResponseWriter) {
187+ w.Header().Set("Cache-Control", "no-store")
188+ w.Header().Set("Pragma", "no-cache")
189+}
M internal/adapters/in/http/admin/middleware_test.go +10 -0
@@ -42,6 +42,8 @@ func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) {
4242
4343 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
4444 assert.Contains(t, rec.Body.String(), "rate limit exceeded")
45+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
46+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
4547 }
4648
4749 func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
@@ -67,6 +69,8 @@ func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
6769
6870 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
6971 assert.Contains(t, rec.Body.String(), "rate limit exceeded")
72+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
73+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
7074 }
7175
7276 func TestAuthMiddleware_TrustedProxy(t *testing.T) {
@@ -181,6 +185,8 @@ func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
181185
182186 assert.Equal(t, http.StatusUnauthorized, rec.Code)
183187 assert.Contains(t, rec.Body.String(), "missing authorization header")
188+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
189+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
184190 }
185191
186192 func TestAuthMiddleware_InvalidToken(t *testing.T) {
@@ -390,6 +396,8 @@ func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
390396
391397 assert.Equal(t, http.StatusOK, rec.Code)
392398 assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
399+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
400+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
393401 }
394402
395403 func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
@@ -417,6 +425,8 @@ func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
417425
418426 assert.Equal(t, http.StatusOK, rec.Code)
419427 assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
428+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
429+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
420430 }
421431
422432 func TestHasAccess(t *testing.T) {
@@ -42,6 +42,8 @@ func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) {
42 42
43 assert.Equal(t, http.StatusTooManyRequests, rec.Code)43 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
44 assert.Contains(t, rec.Body.String(), "rate limit exceeded")44 assert.Contains(t, rec.Body.String(), "rate limit exceeded")
45+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
46+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
45 }47 }
46 48
47 func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {49 func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
@@ -67,6 +69,8 @@ func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
67 69
68 assert.Equal(t, http.StatusTooManyRequests, rec.Code)70 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
69 assert.Contains(t, rec.Body.String(), "rate limit exceeded")71 assert.Contains(t, rec.Body.String(), "rate limit exceeded")
72+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
73+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
70 }74 }
71 75
72 func TestAuthMiddleware_TrustedProxy(t *testing.T) {76 func TestAuthMiddleware_TrustedProxy(t *testing.T) {
@@ -181,6 +185,8 @@ func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
181 185
182 assert.Equal(t, http.StatusUnauthorized, rec.Code)186 assert.Equal(t, http.StatusUnauthorized, rec.Code)
183 assert.Contains(t, rec.Body.String(), "missing authorization header")187 assert.Contains(t, rec.Body.String(), "missing authorization header")
188+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
189+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
184 }190 }
185 191
186 func TestAuthMiddleware_InvalidToken(t *testing.T) {192 func TestAuthMiddleware_InvalidToken(t *testing.T) {
@@ -390,6 +396,8 @@ func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
390 396
391 assert.Equal(t, http.StatusOK, rec.Code)397 assert.Equal(t, http.StatusOK, rec.Code)
392 assert.Empty(t, rec.Header().Get("X-Gordon-Token"))398 assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
399+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
400+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
393 }401 }
394 402
395 func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {403 func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
@@ -417,6 +425,8 @@ func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
417 425
418 assert.Equal(t, http.StatusOK, rec.Code)426 assert.Equal(t, http.StatusOK, rec.Code)
419 assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))427 assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
428+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
429+ assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
420 }430 }
421 431
422 func TestHasAccess(t *testing.T) {432 func TestHasAccess(t *testing.T) {
M internal/adapters/out/domainsecrets/store.go +7 -11
@@ -285,8 +285,8 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str
285285 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
286286 //
287287 // SECURITY: Validates domain and ensures the resulting path stays within envDir.
288-func (s *FileStore) getEnvFilePath(domainName string) string {
289- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
288+func (s *FileStore) getEnvFilePath(domainName string) (string, error) {
289+ storageKey, err := domain.NewEnvStorageKey(domainName)
290290 if err != nil {
291291 s.log.Warn().
292292 Str(zerowrap.FieldLayer, "adapter").
@@ -294,10 +294,10 @@ func (s *FileStore) getEnvFilePath(domainName string) string {
294294 Str("domain", domainName).
295295 Err(err).
296296 Msg("rejected invalid domain")
297- return ""
297+ return "", err
298298 }
299299
300- fullPath := filepath.Join(s.envDir, safeDomain+".env")
300+ fullPath := filepath.Join(s.envDir, storageKey.FileName())
301301
302302 // SECURITY: Final validation - ensure path stays within envDir
303303 cleanPath := filepath.Clean(fullPath)
@@ -309,20 +309,16 @@ func (s *FileStore) getEnvFilePath(domainName string) string {
309309 Str("domain", domainName).
310310 Str("attempted_path", fullPath).
311311 Msg("path traversal attempt blocked - path escapes env directory")
312- return ""
312+ return "", domain.ErrPathTraversal
313313 }
314314
315- return fullPath
315+ return fullPath, nil
316316 }
317317
318318 // validateEnvFilePath validates that a domain produces a valid env file path.
319319 // Returns an error if the domain is invalid or would result in path traversal.
320320 func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
321- path := s.getEnvFilePath(domainName)
322- if path == "" {
323- return "", domain.ErrPathTraversal
324- }
325- return path, nil
321+ return s.getEnvFilePath(domainName)
326322 }
327323
328324 // getAttachmentEnvFilePath converts a container name to its attachment env file path.
@@ -285,8 +285,8 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str
285 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.285 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
286 //286 //
287 // SECURITY: Validates domain and ensures the resulting path stays within envDir.287 // SECURITY: Validates domain and ensures the resulting path stays within envDir.
288-func (s *FileStore) getEnvFilePath(domainName string) string {288+func (s *FileStore) getEnvFilePath(domainName string) (string, error) {
289- safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)289+ storageKey, err := domain.NewEnvStorageKey(domainName)
290 if err != nil {290 if err != nil {
291 s.log.Warn().291 s.log.Warn().
292 Str(zerowrap.FieldLayer, "adapter").292 Str(zerowrap.FieldLayer, "adapter").
@@ -294,10 +294,10 @@ func (s *FileStore) getEnvFilePath(domainName string) string {
294 Str("domain", domainName).294 Str("domain", domainName).
295 Err(err).295 Err(err).
296 Msg("rejected invalid domain")296 Msg("rejected invalid domain")
297- return ""297+ return "", err
298 }298 }
299 299
300- fullPath := filepath.Join(s.envDir, safeDomain+".env")300+ fullPath := filepath.Join(s.envDir, storageKey.FileName())
301 301
302 // SECURITY: Final validation - ensure path stays within envDir302 // SECURITY: Final validation - ensure path stays within envDir
303 cleanPath := filepath.Clean(fullPath)303 cleanPath := filepath.Clean(fullPath)
@@ -309,20 +309,16 @@ func (s *FileStore) getEnvFilePath(domainName string) string {
309 Str("domain", domainName).309 Str("domain", domainName).
310 Str("attempted_path", fullPath).310 Str("attempted_path", fullPath).
311 Msg("path traversal attempt blocked - path escapes env directory")311 Msg("path traversal attempt blocked - path escapes env directory")
312- return ""312+ return "", domain.ErrPathTraversal
313 }313 }
314 314
315- return fullPath315+ return fullPath, nil
316 }316 }
317 317
318 // validateEnvFilePath validates that a domain produces a valid env file path.318 // validateEnvFilePath validates that a domain produces a valid env file path.
319 // Returns an error if the domain is invalid or would result in path traversal.319 // Returns an error if the domain is invalid or would result in path traversal.
320 func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {320 func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
321- path := s.getEnvFilePath(domainName)321+ return s.getEnvFilePath(domainName)
322- if path == "" {
323- return "", domain.ErrPathTraversal
324- }
325- return path, nil
326 }322 }
327 323
328 // getAttachmentEnvFilePath converts a container name to its attachment env file path.324 // getAttachmentEnvFilePath converts a container name to its attachment env file path.
M internal/adapters/out/domainsecrets/store_test.go +26 -6
@@ -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, "ZXhhbXBsZS5jb20.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,21 +407,23 @@ 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, "ZXhhbXBsZS5jb20.env"},
411+ {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"},
412+ {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"},
413+ {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"},
396414 {"../evil", true, ""},
397415 {"", true, ""},
398416 }
399417
400418 for _, tt := range tests {
401419 t.Run(tt.domain, func(t *testing.T) {
402- path := store.getEnvFilePath(tt.domain)
420+ path, err := store.getEnvFilePath(tt.domain)
403421
404422 if tt.wantEmpty {
423+ assert.Error(t, err)
405424 assert.Empty(t, path)
406425 } else {
426+ require.NoError(t, err)
407427 assert.NotEmpty(t, path)
408428 assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
409429 }
@@ -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, "ZXhhbXBsZS5jb20.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,21 +407,23 @@ 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, "ZXhhbXBsZS5jb20.env"},
393- {"sub.example.com", false, "sub_example_com.env"},411+ {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"},
394- {"example.com:8080", false, "example_com_8080.env"},412+ {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"},
395- {"example.com/path", false, "example_com_path.env"},413+ {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"},
396 {"../evil", true, ""},414 {"../evil", true, ""},
397 {"", true, ""},415 {"", true, ""},
398 }416 }
399 417
400 for _, tt := range tests {418 for _, tt := range tests {
401 t.Run(tt.domain, func(t *testing.T) {419 t.Run(tt.domain, func(t *testing.T) {
402- path := store.getEnvFilePath(tt.domain)420+ path, err := store.getEnvFilePath(tt.domain)
403 421
404 if tt.wantEmpty {422 if tt.wantEmpty {
423+ assert.Error(t, err)
405 assert.Empty(t, path)424 assert.Empty(t, path)
406 } else {425 } else {
426+ require.NoError(t, err)
407 assert.NotEmpty(t, path)427 assert.NotEmpty(t, path)
408 assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)428 assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
409 }429 }
M internal/adapters/out/envloader/file.go +31 -12
@@ -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.
@@ -62,7 +63,10 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
6263 log := zerowrap.FromCtx(ctx)
6364
6465 envVars := []string{}
65- envFile := l.getEnvFilePath(domain)
66+ envFile, err := l.getEnvFilePath(domain)
67+ if err != nil {
68+ return nil, log.WrapErr(err, "invalid env file domain")
69+ }
6670
6771 // Check if env file exists
6872 if _, err := os.Stat(envFile); os.IsNotExist(err) {
@@ -142,7 +146,10 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
142146 })
143147 log := zerowrap.FromCtx(ctx)
144148
145- envFile := l.getEnvFilePath(domain)
149+ envFile, err := l.getEnvFilePath(domain)
150+ if err != nil {
151+ return log.WrapErr(err, "invalid env file domain")
152+ }
146153
147154 // Check if file already exists
148155 if _, err := os.Stat(envFile); err == nil {
@@ -169,10 +176,21 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
169176 }
170177
171178 // EnvFileExists checks if an environment file exists for a domain.
172-func (l *FileLoader) EnvFileExists(domain string) bool {
173- envFile := l.getEnvFilePath(domain)
174- _, err := os.Stat(envFile)
175- return err == nil
179+func (l *FileLoader) EnvFileExists(domain string) (bool, error) {
180+ envFile, err := l.getEnvFilePath(domain)
181+ if err != nil {
182+ return false, err
183+ }
184+
185+ _, err = os.Stat(envFile)
186+ if os.IsNotExist(err) {
187+ return false, nil
188+ }
189+ if err != nil {
190+ return false, err
191+ }
192+
193+ return true, nil
176194 }
177195
178196 // resolveSecrets resolves any secret references in the value.
@@ -222,10 +240,11 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
222240 return result, nil
223241 }
224242
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, "/", "_")
230- return filepath.Join(l.envDir, safeDomain+".env")
243+func (l *FileLoader) getEnvFilePath(domainName string) (string, error) {
244+ storageKey, err := domain.NewEnvStorageKey(domainName)
245+ if err != nil {
246+ return "", err
247+ }
248+
249+ return filepath.Join(l.envDir, storageKey.FileName()), nil
231250 }
@@ -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.
@@ -62,7 +63,10 @@ func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
62 log := zerowrap.FromCtx(ctx)63 log := zerowrap.FromCtx(ctx)
63 64
64 envVars := []string{}65 envVars := []string{}
65- envFile := l.getEnvFilePath(domain)66+ envFile, err := l.getEnvFilePath(domain)
67+ if err != nil {
68+ return nil, log.WrapErr(err, "invalid env file domain")
69+ }
66 70
67 // Check if env file exists71 // Check if env file exists
68 if _, err := os.Stat(envFile); os.IsNotExist(err) {72 if _, err := os.Stat(envFile); os.IsNotExist(err) {
@@ -142,7 +146,10 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
142 })146 })
143 log := zerowrap.FromCtx(ctx)147 log := zerowrap.FromCtx(ctx)
144 148
145- envFile := l.getEnvFilePath(domain)149+ envFile, err := l.getEnvFilePath(domain)
150+ if err != nil {
151+ return log.WrapErr(err, "invalid env file domain")
152+ }
146 153
147 // Check if file already exists154 // Check if file already exists
148 if _, err := os.Stat(envFile); err == nil {155 if _, err := os.Stat(envFile); err == nil {
@@ -169,10 +176,21 @@ func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
169 }176 }
170 177
171 // EnvFileExists checks if an environment file exists for a domain.178 // EnvFileExists checks if an environment file exists for a domain.
172-func (l *FileLoader) EnvFileExists(domain string) bool {179+func (l *FileLoader) EnvFileExists(domain string) (bool, error) {
173- envFile := l.getEnvFilePath(domain)180+ envFile, err := l.getEnvFilePath(domain)
174- _, err := os.Stat(envFile)181+ if err != nil {
175- return err == nil182+ return false, err
183+ }
184+
185+ _, err = os.Stat(envFile)
186+ if os.IsNotExist(err) {
187+ return false, nil
188+ }
189+ if err != nil {
190+ return false, err
191+ }
192+
193+ return true, nil
176 }194 }
177 195
178 // resolveSecrets resolves any secret references in the value.196 // resolveSecrets resolves any secret references in the value.
@@ -222,10 +240,11 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string,
222 return result, nil240 return result, nil
223 }241 }
224 242
225-func (l *FileLoader) getEnvFilePath(domain string) string {243+func (l *FileLoader) getEnvFilePath(domainName string) (string, error) {
226- // Create domain-safe filename (replace dots and other chars with underscores)244+ storageKey, err := domain.NewEnvStorageKey(domainName)
227- safeDomain := strings.ReplaceAll(domain, ".", "_")245+ if err != nil {
228- safeDomain = strings.ReplaceAll(safeDomain, ":", "_")246+ return "", err
229- safeDomain = strings.ReplaceAll(safeDomain, "/", "_")247+ }
230- return filepath.Join(l.envDir, safeDomain+".env")248+
249+ return filepath.Join(l.envDir, storageKey.FileName()), nil
231 }250 }
M internal/adapters/out/envloader/file_test.go +77 -8
@@ -11,6 +11,7 @@ import (
1111 "github.com/stretchr/testify/require"
1212
1313 "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
1415 )
1516
1617 func TestFileLoader_ResolveSecrets(t *testing.T) {
@@ -121,7 +122,9 @@ func TestFileLoader_LoadEnv(t *testing.T) {
121122 NODE_ENV=production
122123 API_URL=https://api.example.com
123124 `
124- envFile := filepath.Join(tmpDir, "app_example_com.env")
125+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
126+ require.NoError(t, err)
127+ envFile := filepath.Join(tmpDir, storageKey.FileName())
125128 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126129
127130 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -146,7 +149,9 @@ API_URL=https://api.example.com
146149 envContent := `NODE_ENV=production
147150 API_KEY=${pass:app/api-key}
148151 `
149- envFile := filepath.Join(tmpDir, "app_example_com.env")
152+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
153+ require.NoError(t, err)
154+ envFile := filepath.Join(tmpDir, storageKey.FileName())
150155 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151156
152157 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -164,7 +169,9 @@ API_KEY=${pass:app/api-key}
164169 envContent := `DOUBLE_QUOTED="hello world"
165170 SINGLE_QUOTED='hello world'
166171 `
167- envFile := filepath.Join(tmpDir, "app_example_com.env")
172+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
173+ require.NoError(t, err)
174+ envFile := filepath.Join(tmpDir, storageKey.FileName())
168175 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169176
170177 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -183,7 +190,9 @@ SINGLE_QUOTED='hello world'
183190 invalid line without equals
184191 ALSO_VALID=another
185192 `
186- envFile := filepath.Join(tmpDir, "app_example_com.env")
193+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
194+ require.NoError(t, err)
195+ envFile := filepath.Join(tmpDir, storageKey.FileName())
187196 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188197
189198 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -205,15 +214,75 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) {
205214 domain string
206215 expected string
207216 }{
208- {"app.example.com", "app_example_com.env"},
209- {"api.sub.example.com", "api_sub_example_com.env"},
210- {"localhost:8080", "localhost_8080.env"},
217+ {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"},
218+ {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"},
219+ {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"},
211220 }
212221
213222 for _, tt := range tests {
214223 t.Run(tt.domain, func(t *testing.T) {
215- result := loader.getEnvFilePath(tt.domain)
224+ result, err := loader.getEnvFilePath(tt.domain)
225+ require.NoError(t, err)
216226 assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
217227 })
218228 }
219229 }
230+
231+func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) {
232+ tmpDir := t.TempDir()
233+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
234+
235+ loader, err := NewFileLoader(tmpDir, log)
236+ require.NoError(t, err)
237+
238+ dotPath, err := loader.getEnvFilePath("app.example.com")
239+ require.NoError(t, err)
240+ portPath, err := loader.getEnvFilePath("app:example:com")
241+ require.NoError(t, err)
242+ slashPath, err := loader.getEnvFilePath("app/example/com")
243+ require.NoError(t, err)
244+
245+ assert.NotEqual(t, dotPath, portPath)
246+ assert.NotEqual(t, dotPath, slashPath)
247+ assert.NotEqual(t, portPath, slashPath)
248+}
249+
250+func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) {
251+ tmpDir := t.TempDir()
252+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
253+
254+ loader, err := NewFileLoader(tmpDir, log)
255+ require.NoError(t, err)
256+
257+ _, err = loader.getEnvFilePath("../etc/passwd")
258+ require.ErrorIs(t, err, domain.ErrPathTraversal)
259+}
260+
261+func TestFileLoader_EnvFileExists(t *testing.T) {
262+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
263+
264+ t.Run("returns false nil when file is missing", func(t *testing.T) {
265+ tmpDir := t.TempDir()
266+ loader, err := NewFileLoader(tmpDir, log)
267+ require.NoError(t, err)
268+
269+ exists, err := loader.EnvFileExists("app.example.com")
270+ require.NoError(t, err)
271+ assert.False(t, exists)
272+ })
273+
274+ t.Run("returns true nil when file exists", func(t *testing.T) {
275+ tmpDir := t.TempDir()
276+ loader, err := NewFileLoader(tmpDir, log)
277+ require.NoError(t, err)
278+
279+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
280+ require.NoError(t, err)
281+ envFile := filepath.Join(tmpDir, storageKey.FileName())
282+ require.NoError(t, os.WriteFile(envFile, []byte("KEY=value\n"), 0600))
283+
284+ exists, err := loader.EnvFileExists("app.example.com")
285+ require.NoError(t, err)
286+ assert.True(t, exists)
287+ })
288+}
@@ -11,6 +11,7 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13 "github.com/bnema/gordon/internal/boundaries/out/mocks"13 "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
14 )15 )
15 16
16 func TestFileLoader_ResolveSecrets(t *testing.T) {17 func TestFileLoader_ResolveSecrets(t *testing.T) {
@@ -121,7 +122,9 @@ func TestFileLoader_LoadEnv(t *testing.T) {
121 NODE_ENV=production122 NODE_ENV=production
122 API_URL=https://api.example.com123 API_URL=https://api.example.com
123 `124 `
124- envFile := filepath.Join(tmpDir, "app_example_com.env")125+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
126+ require.NoError(t, err)
127+ envFile := filepath.Join(tmpDir, storageKey.FileName())
125 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))128 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
126 129
127 envVars, err := loader.LoadEnv(ctx, "app.example.com")130 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -146,7 +149,9 @@ API_URL=https://api.example.com
146 envContent := `NODE_ENV=production149 envContent := `NODE_ENV=production
147 API_KEY=${pass:app/api-key}150 API_KEY=${pass:app/api-key}
148 `151 `
149- envFile := filepath.Join(tmpDir, "app_example_com.env")152+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
153+ require.NoError(t, err)
154+ envFile := filepath.Join(tmpDir, storageKey.FileName())
150 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))155 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
151 156
152 envVars, err := loader.LoadEnv(ctx, "app.example.com")157 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -164,7 +169,9 @@ API_KEY=${pass:app/api-key}
164 envContent := `DOUBLE_QUOTED="hello world"169 envContent := `DOUBLE_QUOTED="hello world"
165 SINGLE_QUOTED='hello world'170 SINGLE_QUOTED='hello world'
166 `171 `
167- envFile := filepath.Join(tmpDir, "app_example_com.env")172+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
173+ require.NoError(t, err)
174+ envFile := filepath.Join(tmpDir, storageKey.FileName())
168 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))175 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
169 176
170 envVars, err := loader.LoadEnv(ctx, "app.example.com")177 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -183,7 +190,9 @@ SINGLE_QUOTED='hello world'
183 invalid line without equals190 invalid line without equals
184 ALSO_VALID=another191 ALSO_VALID=another
185 `192 `
186- envFile := filepath.Join(tmpDir, "app_example_com.env")193+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
194+ require.NoError(t, err)
195+ envFile := filepath.Join(tmpDir, storageKey.FileName())
187 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))196 require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
188 197
189 envVars, err := loader.LoadEnv(ctx, "app.example.com")198 envVars, err := loader.LoadEnv(ctx, "app.example.com")
@@ -205,15 +214,75 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) {
205 domain string214 domain string
206 expected string215 expected string
207 }{216 }{
208- {"app.example.com", "app_example_com.env"},217+ {"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"},
209- {"api.sub.example.com", "api_sub_example_com.env"},218+ {"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"},
210- {"localhost:8080", "localhost_8080.env"},219+ {"localhost:8080", "bG9jYWxob3N0OjgwODA.env"},
211 }220 }
212 221
213 for _, tt := range tests {222 for _, tt := range tests {
214 t.Run(tt.domain, func(t *testing.T) {223 t.Run(tt.domain, func(t *testing.T) {
215- result := loader.getEnvFilePath(tt.domain)224+ result, err := loader.getEnvFilePath(tt.domain)
225+ require.NoError(t, err)
216 assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)226 assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
217 })227 })
218 }228 }
219 }229 }
230+
231+func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) {
232+ tmpDir := t.TempDir()
233+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
234+
235+ loader, err := NewFileLoader(tmpDir, log)
236+ require.NoError(t, err)
237+
238+ dotPath, err := loader.getEnvFilePath("app.example.com")
239+ require.NoError(t, err)
240+ portPath, err := loader.getEnvFilePath("app:example:com")
241+ require.NoError(t, err)
242+ slashPath, err := loader.getEnvFilePath("app/example/com")
243+ require.NoError(t, err)
244+
245+ assert.NotEqual(t, dotPath, portPath)
246+ assert.NotEqual(t, dotPath, slashPath)
247+ assert.NotEqual(t, portPath, slashPath)
248+}
249+
250+func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) {
251+ tmpDir := t.TempDir()
252+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
253+
254+ loader, err := NewFileLoader(tmpDir, log)
255+ require.NoError(t, err)
256+
257+ _, err = loader.getEnvFilePath("../etc/passwd")
258+ require.ErrorIs(t, err, domain.ErrPathTraversal)
259+}
260+
261+func TestFileLoader_EnvFileExists(t *testing.T) {
262+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
263+
264+ t.Run("returns false nil when file is missing", func(t *testing.T) {
265+ tmpDir := t.TempDir()
266+ loader, err := NewFileLoader(tmpDir, log)
267+ require.NoError(t, err)
268+
269+ exists, err := loader.EnvFileExists("app.example.com")
270+ require.NoError(t, err)
271+ assert.False(t, exists)
272+ })
273+
274+ t.Run("returns true nil when file exists", func(t *testing.T) {
275+ tmpDir := t.TempDir()
276+ loader, err := NewFileLoader(tmpDir, log)
277+ require.NoError(t, err)
278+
279+ storageKey, err := domain.NewEnvStorageKey("app.example.com")
280+ require.NoError(t, err)
281+ envFile := filepath.Join(tmpDir, storageKey.FileName())
282+ require.NoError(t, os.WriteFile(envFile, []byte("KEY=value\n"), 0600))
283+
284+ exists, err := loader.EnvFileExists("app.example.com")
285+ require.NoError(t, err)
286+ assert.True(t, exists)
287+ })
288+}
M internal/adapters/out/envloader/pass_loader.go +3 -3
@@ -104,11 +104,11 @@ func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
104104 }
105105
106106 // EnvFileExists checks if a manifest exists for a domain in pass.
107-func (l *PassLoader) EnvFileExists(domain string) bool {
107+func (l *PassLoader) EnvFileExists(domain string) (bool, error) {
108108 exists, err := l.store.ManifestExists(domain)
109109 if err != nil {
110110 l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")
111- return false
111+ return false, err
112112 }
113- return exists
113+ return exists, nil
114114 }
@@ -104,11 +104,11 @@ func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
104 }104 }
105 105
106 // EnvFileExists checks if a manifest exists for a domain in pass.106 // EnvFileExists checks if a manifest exists for a domain in pass.
107-func (l *PassLoader) EnvFileExists(domain string) bool {107+func (l *PassLoader) EnvFileExists(domain string) (bool, error) {
108 exists, err := l.store.ManifestExists(domain)108 exists, err := l.store.ManifestExists(domain)
109 if err != nil {109 if err != nil {
110 l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")110 l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest")
111- return false111+ return false, err
112 }112 }
113- return exists113+ return exists, nil
114 }114 }
M internal/app/kernel_test.go +1 -0
@@ -22,6 +22,7 @@ data_dir = %q
2222
2323 [auth]
2424 enabled = false
25+secrets_backend = "unsafe"
2526 `, dataDir)
2627
2728 err := os.WriteFile(cfgPath, []byte(cfg), 0o600)
@@ -22,6 +22,7 @@ data_dir = %q
22 22
23 [auth]23 [auth]
24 enabled = false24 enabled = false
25+secrets_backend = "unsafe"
25 `, dataDir)26 `, dataDir)
26 27
27 err := os.WriteFile(cfgPath, []byte(cfg), 0o600)28 err := os.WriteFile(cfgPath, []byte(cfg), 0o600)
M internal/app/run.go +17 -9
@@ -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
@@ -2339,7 +2347,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
23392347 v.SetDefault("auth.enabled", true)
23402348 // Note: auth.type is intentionally not set - it's inferred from config
23412349 // If password_hash is set -> password mode, otherwise -> token mode
2342- v.SetDefault("auth.secrets_backend", "unsafe")
2350+ v.SetDefault("auth.secrets_backend", "")
23432351 v.SetDefault("auth.token_expiry", "720h")
23442352 v.SetDefault("api.rate_limit.enabled", true)
23452353 v.SetDefault("api.rate_limit.global_rps", 500)
@@ -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
@@ -2339,7 +2347,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
2339 v.SetDefault("auth.enabled", true)2347 v.SetDefault("auth.enabled", true)
2340 // Note: auth.type is intentionally not set - it's inferred from config2348 // Note: auth.type is intentionally not set - it's inferred from config
2341 // If password_hash is set -> password mode, otherwise -> token mode2349 // If password_hash is set -> password mode, otherwise -> token mode
2342- v.SetDefault("auth.secrets_backend", "unsafe")2350+ v.SetDefault("auth.secrets_backend", "")
2343 v.SetDefault("auth.token_expiry", "720h")2351 v.SetDefault("auth.token_expiry", "720h")
2344 v.SetDefault("api.rate_limit.enabled", true)2352 v.SetDefault("api.rate_limit.enabled", true)
2345 v.SetDefault("api.rate_limit.global_rps", 500)2353 v.SetDefault("api.rate_limit.global_rps", 500)
M internal/app/run_auth_config_test.go +35 -0
@@ -2,10 +2,12 @@ package app
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/gordon/internal/domain"
89 "github.com/bnema/zerowrap"
10+ "github.com/spf13/viper"
911 )
1012
1113 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
@@ -23,3 +25,36 @@ func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
2325 t.Fatalf("expected auth config enabled=false, got true")
2426 }
2527 }
28+
29+func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) {
30+ _, err := resolveSecretsBackend("")
31+ if err == nil {
32+ t.Fatal("expected error for missing secrets backend")
33+ }
34+ if !strings.Contains(err.Error(), "auth.secrets_backend is required") {
35+ t.Fatalf("unexpected error: %v", err)
36+ }
37+}
38+
39+func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {
40+ _, err := resolveSecretsBackend("vault")
41+ if err == nil {
42+ t.Fatal("expected error for unsupported secrets backend")
43+ }
44+ if !strings.Contains(err.Error(), `unsupported auth.secrets_backend "vault"`) {
45+ t.Fatalf("unexpected error: %v", err)
46+ }
47+}
48+
49+func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
50+ v := viper.New()
51+
52+ err := loadConfig(v, "")
53+ if err != nil {
54+ t.Fatalf("expected no error, got %v", err)
55+ }
56+
57+ if got := v.GetString("auth.secrets_backend"); got != "" {
58+ t.Fatalf("expected empty default for auth.secrets_backend, got %q", got)
59+ }
60+}
@@ -2,10 +2,12 @@ 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"
8 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
10+ "github.com/spf13/viper"
9 )11 )
10 12
11 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {13 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
@@ -23,3 +25,36 @@ func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
23 t.Fatalf("expected auth config enabled=false, got true")25 t.Fatalf("expected auth config enabled=false, got true")
24 }26 }
25 }27 }
28+
29+func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) {
30+ _, err := resolveSecretsBackend("")
31+ if err == nil {
32+ t.Fatal("expected error for missing secrets backend")
33+ }
34+ if !strings.Contains(err.Error(), "auth.secrets_backend is required") {
35+ t.Fatalf("unexpected error: %v", err)
36+ }
37+}
38+
39+func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {
40+ _, err := resolveSecretsBackend("vault")
41+ if err == nil {
42+ t.Fatal("expected error for unsupported secrets backend")
43+ }
44+ if !strings.Contains(err.Error(), `unsupported auth.secrets_backend "vault"`) {
45+ t.Fatalf("unexpected error: %v", err)
46+ }
47+}
48+
49+func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
50+ v := viper.New()
51+
52+ err := loadConfig(v, "")
53+ if err != nil {
54+ t.Fatalf("expected no error, got %v", err)
55+ }
56+
57+ if got := v.GetString("auth.secrets_backend"); got != "" {
58+ t.Fatalf("expected empty default for auth.secrets_backend, got %q", got)
59+ }
60+}
M internal/boundaries/in/mocks/mock_registry_service.go +0 -80
@@ -231,46 +231,6 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte
231231 return _c
232232 }
233233
234-// ClearDeployEventSuppression provides a mock function for the type MockRegistryService
235-func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {
236- _mock.Called(imageName)
237- return
238-}
239-
240-// MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'
241-type MockRegistryService_ClearDeployEventSuppression_Call struct {
242- *mock.Call
243-}
244-
245-// ClearDeployEventSuppression is a helper method to define mock.On call
246-// - imageName string
247-func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call {
248- return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)}
249-}
250-
251-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
252- _c.Call.Run(func(args mock.Arguments) {
253- var arg0 string
254- if args[0] != nil {
255- arg0 = args[0].(string)
256- }
257- run(
258- arg0,
259- )
260- })
261- return _c
262-}
263-
264-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call {
265- _c.Call.Return()
266- return _c
267-}
268-
269-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
270- _c.Run(run)
271- return _c
272-}
273-
274234 // DeleteManifest provides a mock function for the type MockRegistryService
275235 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {
276236 ret := _mock.Called(ctx, name, reference)
@@ -935,43 +895,3 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex
935895 _c.Call.Return(run)
936896 return _c
937897 }
938-
939-// SuppressDeployEvent provides a mock function for the type MockRegistryService
940-func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {
941- _mock.Called(imageName)
942- return
943-}
944-
945-// MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'
946-type MockRegistryService_SuppressDeployEvent_Call struct {
947- *mock.Call
948-}
949-
950-// SuppressDeployEvent is a helper method to define mock.On call
951-// - imageName string
952-func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call {
953- return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)}
954-}
955-
956-func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
957- _c.Call.Run(func(args mock.Arguments) {
958- var arg0 string
959- if args[0] != nil {
960- arg0 = args[0].(string)
961- }
962- run(
963- arg0,
964- )
965- })
966- return _c
967-}
968-
969-func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call {
970- _c.Call.Return()
971- return _c
972-}
973-
974-func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
975- _c.Run(run)
976- return _c
977-}
@@ -231,46 +231,6 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte
231 return _c231 return _c
232 }232 }
233 233
234-// ClearDeployEventSuppression provides a mock function for the type MockRegistryService
235-func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {
236- _mock.Called(imageName)
237- return
238-}
239-
240-// MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'
241-type MockRegistryService_ClearDeployEventSuppression_Call struct {
242- *mock.Call
243-}
244-
245-// ClearDeployEventSuppression is a helper method to define mock.On call
246-// - imageName string
247-func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call {
248- return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)}
249-}
250-
251-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
252- _c.Call.Run(func(args mock.Arguments) {
253- var arg0 string
254- if args[0] != nil {
255- arg0 = args[0].(string)
256- }
257- run(
258- arg0,
259- )
260- })
261- return _c
262-}
263-
264-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call {
265- _c.Call.Return()
266- return _c
267-}
268-
269-func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
270- _c.Run(run)
271- return _c
272-}
273-
274 // DeleteManifest provides a mock function for the type MockRegistryService234 // DeleteManifest provides a mock function for the type MockRegistryService
275 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {235 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {
276 ret := _mock.Called(ctx, name, reference)236 ret := _mock.Called(ctx, name, reference)
@@ -935,43 +895,3 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex
935 _c.Call.Return(run)895 _c.Call.Return(run)
936 return _c896 return _c
937 }897 }
938-
939-// SuppressDeployEvent provides a mock function for the type MockRegistryService
940-func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {
941- _mock.Called(imageName)
942- return
943-}
944-
945-// MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'
946-type MockRegistryService_SuppressDeployEvent_Call struct {
947- *mock.Call
948-}
949-
950-// SuppressDeployEvent is a helper method to define mock.On call
951-// - imageName string
952-func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call {
953- return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)}
954-}
955-
956-func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
957- _c.Call.Run(func(args mock.Arguments) {
958- var arg0 string
959- if args[0] != nil {
960- arg0 = args[0].(string)
961- }
962- run(
963- arg0,
964- )
965- })
966- return _c
967-}
968-
969-func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call {
970- _c.Call.Return()
971- return _c
972-}
973-
974-func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
975- _c.Run(run)
976- return _c
977-}
A internal/boundaries/in/mocks/mock_registry_service_deploy.go +9 -0
@@ -0,0 +1,9 @@
1+package mocks
2+
3+// SuppressDeployEvent satisfies the admin handler's composite registry/deploy test dependency.
4+// Admin handler tests that only need registry behavior do not assert on deploy suppression.
5+func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {}
6+
7+// ClearDeployEventSuppression satisfies the admin handler's composite registry/deploy test dependency.
8+// Admin handler tests that only need registry behavior do not assert on deploy suppression.
9+func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {}
@@ -0,0 +1,9 @@
1+package mocks
2+
3+// SuppressDeployEvent satisfies the admin handler's composite registry/deploy test dependency.
4+// Admin handler tests that only need registry behavior do not assert on deploy suppression.
5+func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {}
6+
7+// ClearDeployEventSuppression satisfies the admin handler's composite registry/deploy test dependency.
8+// Admin handler tests that only need registry behavior do not assert on deploy suppression.
9+func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {}
M internal/boundaries/out/envloader.go +1 -1
@@ -12,5 +12,5 @@ type EnvLoader interface {
1212 CreateEnvFile(ctx context.Context, domain string) error
1313
1414 // EnvFileExists checks if an environment file exists for a domain.
15- EnvFileExists(domain string) bool
15+ EnvFileExists(domain string) (bool, error)
1616 }
@@ -12,5 +12,5 @@ type EnvLoader interface {
12 CreateEnvFile(ctx context.Context, domain string) error12 CreateEnvFile(ctx context.Context, domain string) error
13 13
14 // EnvFileExists checks if an environment file exists for a domain.14 // EnvFileExists checks if an environment file exists for a domain.
15- EnvFileExists(domain string) bool15+ EnvFileExists(domain string) (bool, error)
16 }16 }
M internal/boundaries/out/mocks/mock_env_loader.go +14 -5
@@ -95,7 +95,7 @@ func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Co
9595 }
9696
9797 // EnvFileExists provides a mock function for the type MockEnvLoader
98-func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {
98+func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) {
9999 ret := _mock.Called(domain)
100100
101101 if len(ret) == 0 {
@@ -103,12 +103,21 @@ func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {
103103 }
104104
105105 var r0 bool
106+ var r1 error
107+ if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok {
108+ return returnFunc(domain)
109+ }
106110 if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
107111 r0 = returnFunc(domain)
108112 } else {
109113 r0 = ret.Get(0).(bool)
110114 }
111- return r0
115+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
116+ r1 = returnFunc(domain)
117+ } else {
118+ r1 = ret.Error(1)
119+ }
120+ return r0, r1
112121 }
113122
114123 // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists'
@@ -135,12 +144,12 @@ func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEn
135144 return _c
136145 }
137146
138-func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool) *MockEnvLoader_EnvFileExists_Call {
139- _c.Call.Return(b)
147+func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call {
148+ _c.Call.Return(b, err)
140149 return _c
141150 }
142151
143-func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) bool) *MockEnvLoader_EnvFileExists_Call {
152+func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call {
144153 _c.Call.Return(run)
145154 return _c
146155 }
@@ -95,7 +95,7 @@ func (_c *MockEnvLoader_CreateEnvFile_Call) RunAndReturn(run func(ctx context.Co
95 }95 }
96 96
97 // EnvFileExists provides a mock function for the type MockEnvLoader97 // EnvFileExists provides a mock function for the type MockEnvLoader
98-func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {98+func (_mock *MockEnvLoader) EnvFileExists(domain string) (bool, error) {
99 ret := _mock.Called(domain)99 ret := _mock.Called(domain)
100 100
101 if len(ret) == 0 {101 if len(ret) == 0 {
@@ -103,12 +103,21 @@ func (_mock *MockEnvLoader) EnvFileExists(domain string) bool {
103 }103 }
104 104
105 var r0 bool105 var r0 bool
106+ var r1 error
107+ if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok {
108+ return returnFunc(domain)
109+ }
106 if returnFunc, ok := ret.Get(0).(func(string) bool); ok {110 if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
107 r0 = returnFunc(domain)111 r0 = returnFunc(domain)
108 } else {112 } else {
109 r0 = ret.Get(0).(bool)113 r0 = ret.Get(0).(bool)
110 }114 }
111- return r0115+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
116+ r1 = returnFunc(domain)
117+ } else {
118+ r1 = ret.Error(1)
119+ }
120+ return r0, r1
112 }121 }
113 122
114 // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists'123 // MockEnvLoader_EnvFileExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvFileExists'
@@ -135,12 +144,12 @@ func (_c *MockEnvLoader_EnvFileExists_Call) Run(run func(domain string)) *MockEn
135 return _c144 return _c
136 }145 }
137 146
138-func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool) *MockEnvLoader_EnvFileExists_Call {147+func (_c *MockEnvLoader_EnvFileExists_Call) Return(b bool, err error) *MockEnvLoader_EnvFileExists_Call {
139- _c.Call.Return(b)148+ _c.Call.Return(b, err)
140 return _c149 return _c
141 }150 }
142 151
143-func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) bool) *MockEnvLoader_EnvFileExists_Call {152+func (_c *MockEnvLoader_EnvFileExists_Call) RunAndReturn(run func(domain string) (bool, error)) *MockEnvLoader_EnvFileExists_Call {
144 _c.Call.Return(run)153 _c.Call.Return(run)
145 return _c154 return _c
146 }155 }
M internal/domain/env.go +5 -19
@@ -7,32 +7,18 @@ import (
77 "strings"
88 )
99
10-// envDomainRegex validates domain names used for env secrets.
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]$`)
1310 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
1411 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
1512
1613 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
17-// Returns a safe domain string suitable for filenames.
18-// This function is idempotent: calling it on already-sanitized input returns the same value.
14+// Returns the collision-resistant storage key used for filenames.
1915 func SanitizeDomainForEnvFile(domainName string) (string, error) {
20- if domainName == "" {
21- return "", ErrPathTraversal
16+ safeDomain, err := NewEnvStorageKey(domainName)
17+ if err != nil {
18+ return "", err
2219 }
2320
24- if strings.Contains(domainName, "..") {
25- return "", ErrPathTraversal
26- }
27-
28- if !envDomainRegex.MatchString(domainName) {
29- return "", ErrPathTraversal
30- }
31-
32- safeDomain := strings.ReplaceAll(domainName, ".", "_")
33- safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
34- safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
35- return safeDomain, nil
21+ return string(safeDomain), nil
3622 }
3723
3824 // ParseEnvData parses env data into a key-value map.
@@ -7,32 +7,18 @@ import (
7 "strings"7 "strings"
8 )8 )
9 9
10-// envDomainRegex validates domain names used for env secrets.
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]$`)
13 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)10 var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
14 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)11 var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
15 12
16 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.13 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
17-// Returns a safe domain string suitable for filenames.14+// Returns the collision-resistant storage key used for filenames.
18-// This function is idempotent: calling it on already-sanitized input returns the same value.
19 func SanitizeDomainForEnvFile(domainName string) (string, error) {15 func SanitizeDomainForEnvFile(domainName string) (string, error) {
20- if domainName == "" {16+ safeDomain, err := NewEnvStorageKey(domainName)
21- return "", ErrPathTraversal17+ if err != nil {
18+ return "", err
22 }19 }
23 20
24- if strings.Contains(domainName, "..") {21+ return string(safeDomain), nil
25- return "", ErrPathTraversal
26- }
27-
28- if !envDomainRegex.MatchString(domainName) {
29- return "", ErrPathTraversal
30- }
31-
32- safeDomain := strings.ReplaceAll(domainName, ".", "_")
33- safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
34- safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
35- return safeDomain, nil
36 }22 }
37 23
38 // ParseEnvData parses env data into a key-value map.24 // ParseEnvData parses env data into a key-value map.
M internal/domain/env_test.go +23 -8
@@ -1,6 +1,7 @@
11 package domain
22
33 import (
4+ "encoding/base64"
45 "strings"
56 "testing"
67
@@ -15,12 +16,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
1516 want string
1617 wantErr bool
1718 }{
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"},
22- {name: "single char", domain: "a", want: "a"},
23- {name: "hyphenated", domain: "my-app.example.com", want: "my-app_example_com"},
19+ {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))},
20+ {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))},
21+ {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))},
22+ {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))},
23+ {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))},
24+ {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))},
2425 {name: "empty", domain: "", wantErr: true},
2526 {name: "path traversal", domain: "../etc/passwd", wantErr: true},
2627 {name: "double dots", domain: "foo..bar", wantErr: true},
@@ -28,8 +29,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
2829 {name: "ends with dot", domain: "trailing.", wantErr: true},
2930 {name: "space", domain: "has space", wantErr: true},
3031 {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"},
32+ {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true},
3333 }
3434
3535 for _, tt := range tests {
@@ -45,6 +45,21 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
4545 }
4646 }
4747
48+func TestSanitizeDomainForEnvFile_DistinguishesSeparators(t *testing.T) {
49+ gotDot, err := SanitizeDomainForEnvFile("app.example.com")
50+ require.NoError(t, err)
51+
52+ gotPort, err := SanitizeDomainForEnvFile("app:example:com")
53+ require.NoError(t, err)
54+
55+ gotPath, err := SanitizeDomainForEnvFile("app/example/com")
56+ require.NoError(t, err)
57+
58+ assert.NotEqual(t, gotDot, gotPort)
59+ assert.NotEqual(t, gotDot, gotPath)
60+ assert.NotEqual(t, gotPort, gotPath)
61+}
62+
4863 func TestParseEnvData(t *testing.T) {
4964 tests := []struct {
5065 name string
@@ -1,6 +1,7 @@
1 package domain1 package domain
2 2
3 import (3 import (
4+ "encoding/base64"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
@@ -15,12 +16,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
15 want string16 want string
16 wantErr bool17 wantErr bool
17 }{18 }{
18- {name: "simple domain", domain: "example.com", want: "example_com"},19+ {name: "simple domain", domain: "example.com", want: base64.RawURLEncoding.EncodeToString([]byte("example.com"))},
19- {name: "subdomain", domain: "app.example.com", want: "app_example_com"},20+ {name: "subdomain", domain: "app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("app.example.com"))},
20- {name: "domain with port", domain: "example.com:8080", want: "example_com_8080"},21+ {name: "domain with port", domain: "example.com:8080", want: base64.RawURLEncoding.EncodeToString([]byte("example.com:8080"))},
21- {name: "domain with path", domain: "example.com/path", want: "example_com_path"},22+ {name: "domain with path", domain: "example.com/path", want: base64.RawURLEncoding.EncodeToString([]byte("example.com/path"))},
22- {name: "single char", domain: "a", want: "a"},23+ {name: "single char", domain: "a", want: base64.RawURLEncoding.EncodeToString([]byte("a"))},
23- {name: "hyphenated", domain: "my-app.example.com", want: "my-app_example_com"},24+ {name: "hyphenated", domain: "my-app.example.com", want: base64.RawURLEncoding.EncodeToString([]byte("my-app.example.com"))},
24 {name: "empty", domain: "", wantErr: true},25 {name: "empty", domain: "", wantErr: true},
25 {name: "path traversal", domain: "../etc/passwd", wantErr: true},26 {name: "path traversal", domain: "../etc/passwd", wantErr: true},
26 {name: "double dots", domain: "foo..bar", wantErr: true},27 {name: "double dots", domain: "foo..bar", wantErr: true},
@@ -28,8 +29,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
28 {name: "ends with dot", domain: "trailing.", wantErr: true},29 {name: "ends with dot", domain: "trailing.", wantErr: true},
29 {name: "space", domain: "has space", wantErr: true},30 {name: "space", domain: "has space", wantErr: true},
30 {name: "special chars", domain: "bad$domain", wantErr: true},31 {name: "special chars", domain: "bad$domain", wantErr: true},
31- {name: "idempotent simple", domain: "example_com", want: "example_com"},32+ {name: "underscore rejected as invalid domain character", domain: "example_com", wantErr: true},
32- {name: "idempotent complex", domain: "my-app_example_com_8080", want: "my-app_example_com_8080"},
33 }33 }
34 34
35 for _, tt := range tests {35 for _, tt := range tests {
@@ -45,6 +45,21 @@ func TestSanitizeDomainForEnvFile(t *testing.T) {
45 }45 }
46 }46 }
47 47
48+func TestSanitizeDomainForEnvFile_DistinguishesSeparators(t *testing.T) {
49+ gotDot, err := SanitizeDomainForEnvFile("app.example.com")
50+ require.NoError(t, err)
51+
52+ gotPort, err := SanitizeDomainForEnvFile("app:example:com")
53+ require.NoError(t, err)
54+
55+ gotPath, err := SanitizeDomainForEnvFile("app/example/com")
56+ require.NoError(t, err)
57+
58+ assert.NotEqual(t, gotDot, gotPort)
59+ assert.NotEqual(t, gotDot, gotPath)
60+ assert.NotEqual(t, gotPort, gotPath)
61+}
62+
48 func TestParseEnvData(t *testing.T) {63 func TestParseEnvData(t *testing.T) {
49 tests := []struct {64 tests := []struct {
50 name string65 name string
A internal/domain/envfile.go +47 -0
@@ -0,0 +1,47 @@
1+package domain
2+
3+import (
4+ "encoding/base64"
5+ "regexp"
6+ "strings"
7+)
8+
9+// envDomainRegex validates domain names used for env-backed secret storage.
10+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
11+var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
12+
13+// EnvStorageKey is the domain-level storage identifier used for env-backed secrets.
14+// It is deterministic, collision-resistant, and safe to use in file names.
15+type EnvStorageKey string
16+
17+// NewEnvStorageKey validates a route domain and returns the storage-safe identifier
18+// used by env/secrets adapters. The identifier is reversible and unique for the input.
19+func NewEnvStorageKey(domainName string) (EnvStorageKey, error) {
20+ if err := ValidateEnvStorageDomain(domainName); err != nil {
21+ return "", err
22+ }
23+
24+ return EnvStorageKey(base64.RawURLEncoding.EncodeToString([]byte(domainName))), nil
25+}
26+
27+// ValidateEnvStorageDomain validates domains used for env-backed secret storage.
28+func ValidateEnvStorageDomain(domainName string) error {
29+ if domainName == "" {
30+ return ErrPathTraversal
31+ }
32+
33+ if strings.Contains(domainName, "..") {
34+ return ErrPathTraversal
35+ }
36+
37+ if !envDomainRegex.MatchString(domainName) {
38+ return ErrPathTraversal
39+ }
40+
41+ return nil
42+}
43+
44+// FileName returns the on-disk env filename for this storage key.
45+func (k EnvStorageKey) FileName() string {
46+ return string(k) + ".env"
47+}
@@ -0,0 +1,47 @@
1+package domain
2+
3+import (
4+ "encoding/base64"
5+ "regexp"
6+ "strings"
7+)
8+
9+// envDomainRegex validates domain names used for env-backed secret storage.
10+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
11+var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9.:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
12+
13+// EnvStorageKey is the domain-level storage identifier used for env-backed secrets.
14+// It is deterministic, collision-resistant, and safe to use in file names.
15+type EnvStorageKey string
16+
17+// NewEnvStorageKey validates a route domain and returns the storage-safe identifier
18+// used by env/secrets adapters. The identifier is reversible and unique for the input.
19+func NewEnvStorageKey(domainName string) (EnvStorageKey, error) {
20+ if err := ValidateEnvStorageDomain(domainName); err != nil {
21+ return "", err
22+ }
23+
24+ return EnvStorageKey(base64.RawURLEncoding.EncodeToString([]byte(domainName))), nil
25+}
26+
27+// ValidateEnvStorageDomain validates domains used for env-backed secret storage.
28+func ValidateEnvStorageDomain(domainName string) error {
29+ if domainName == "" {
30+ return ErrPathTraversal
31+ }
32+
33+ if strings.Contains(domainName, "..") {
34+ return ErrPathTraversal
35+ }
36+
37+ if !envDomainRegex.MatchString(domainName) {
38+ return ErrPathTraversal
39+ }
40+
41+ return nil
42+}
43+
44+// FileName returns the on-disk env filename for this storage key.
45+func (k EnvStorageKey) FileName() string {
46+ return string(k) + ".env"
47+}
M internal/usecase/container/autoroute.go +12 -9
@@ -273,7 +273,10 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
273273 }
274274
275275 // Load existing env file for this domain
276- envFileName := domainToEnvFileName(routeDomain)
276+ envFileName, err := domainToEnvFileName(routeDomain)
277+ if err != nil {
278+ return fmt.Errorf("invalid env storage domain: %w", err)
279+ }
277280 envFileDst := filepath.Join(h.envDir, envFileName)
278281
279282 existingEnv := make(map[string]string)
@@ -465,14 +468,14 @@ func parseEnvFile(data []byte) (map[string]string, error) {
465468 }
466469
467470 // domainToEnvFileName converts a domain to an env file name.
468-// e.g., "app.mydomain.com" -> "app_mydomain_com.env"
469-// Must match the naming convention in envloader.FileLoader.getEnvFilePath
470-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, "/", "_")
475- return name + ".env"
471+// Must match the naming convention in envloader.FileLoader.getEnvFilePath.
472+func domainToEnvFileName(domainName string) (string, error) {
473+ storageKey, err := domain.NewEnvStorageKey(domainName)
474+ if err != nil {
475+ return "", err
476+ }
477+
478+ return storageKey.FileName(), nil
476479 }
477480
478481 // writeEnvFile writes environment variables to a file.
@@ -273,7 +273,10 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
273 }273 }
274 274
275 // Load existing env file for this domain275 // Load existing env file for this domain
276- envFileName := domainToEnvFileName(routeDomain)276+ envFileName, err := domainToEnvFileName(routeDomain)
277+ if err != nil {
278+ return fmt.Errorf("invalid env storage domain: %w", err)
279+ }
277 envFileDst := filepath.Join(h.envDir, envFileName)280 envFileDst := filepath.Join(h.envDir, envFileName)
278 281
279 existingEnv := make(map[string]string)282 existingEnv := make(map[string]string)
@@ -465,14 +468,14 @@ func parseEnvFile(data []byte) (map[string]string, error) {
465 }468 }
466 469
467 // domainToEnvFileName converts a domain to an env file name.470 // domainToEnvFileName converts a domain to an env file name.
468-// e.g., "app.mydomain.com" -> "app_mydomain_com.env"471+// Must match the naming convention in envloader.FileLoader.getEnvFilePath.
469-// Must match the naming convention in envloader.FileLoader.getEnvFilePath472+func domainToEnvFileName(domainName string) (string, error) {
470-func domainToEnvFileName(domainName string) string {473+ storageKey, err := domain.NewEnvStorageKey(domainName)
471- // Replace special characters with underscores (matches envloader)474+ if err != nil {
472- name := strings.ReplaceAll(domainName, ".", "_")475+ return "", err
473- name = strings.ReplaceAll(name, ":", "_")476+ }
474- name = strings.ReplaceAll(name, "/", "_")477+
475- return name + ".env"478+ return storageKey.FileName(), nil
476 }479 }
477 480
478 // writeEnvFile writes environment variables to a file.481 // writeEnvFile writes environment variables to a file.
M internal/usecase/container/autoroute_test.go +7 -6
@@ -28,33 +28,34 @@ func TestDomainToEnvFileName(t *testing.T) {
2828 {
2929 name: "simple domain",
3030 domain: "app.example.com",
31- expected: "app_example_com.env",
31+ expected: "YXBwLmV4YW1wbGUuY29t.env",
3232 },
3333 {
3434 name: "domain with port",
3535 domain: "app.example.com:8080",
36- expected: "app_example_com_8080.env",
36+ expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env",
3737 },
3838 {
3939 name: "domain with path",
4040 domain: "app.example.com/api",
41- expected: "app_example_com_api.env",
41+ expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.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: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env",
4747 },
4848 {
4949 name: "single word",
5050 domain: "localhost",
51- expected: "localhost.env",
51+ expected: "bG9jYWxob3N0.env",
5252 },
5353 }
5454
5555 for _, tt := range tests {
5656 t.Run(tt.name, func(t *testing.T) {
57- result := domainToEnvFileName(tt.domain)
57+ result, err := domainToEnvFileName(tt.domain)
58+ require.NoError(t, err)
5859 assert.Equal(t, tt.expected, result)
5960 })
6061 }
@@ -28,33 +28,34 @@ 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: "YXBwLmV4YW1wbGUuY29t.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: "YXBwLmV4YW1wbGUuY29tOjgwODA.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: "YXBwLmV4YW1wbGUuY29tL2FwaQ.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: "c3ViLmFwcC5leGFtcGxlLmNvbTo0NDMvdjE.env",
47 },47 },
48 {48 {
49 name: "single word",49 name: "single word",
50 domain: "localhost",50 domain: "localhost",
51- expected: "localhost.env",51+ expected: "bG9jYWxob3N0.env",
52 },52 },
53 }53 }
54 54
55 for _, tt := range tests {55 for _, tt := range tests {
56 t.Run(tt.name, func(t *testing.T) {56 t.Run(tt.name, func(t *testing.T) {
57- result := domainToEnvFileName(tt.domain)57+ result, err := domainToEnvFileName(tt.domain)
58+ require.NoError(t, err)
58 assert.Equal(t, tt.expected, result)59 assert.Equal(t, tt.expected, result)
59 })60 })
60 }61 }
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