Merge pull request #93 from bnema/security-review-fixes-v2
Harden env storage and admin cache handling
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")+`" | |||
| 30 | 30 | ||
| 31 | 31 | [auth] | |
| 32 | 32 | enabled = true | |
| 33 | + | secrets_backend = "unsafe" | |
| 33 | 34 | `), 0o600) | |
| 34 | 35 | if err != nil { | |
| 35 | 36 | t.Fatalf("failed to write config: %v", err) | |
| @@ -70,6 +71,7 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||
| 70 | 71 | ||
| 71 | 72 | [auth] | |
| 72 | 73 | enabled = false | |
| 74 | + | secrets_backend = "unsafe" | |
| 73 | 75 | `), 0o600) | |
| 74 | 76 | if err != nil { | |
| 75 | 77 | 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 = true | 32 | 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 = false | 73 | 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( | |||
| 32 | 32 | ||
| 33 | 33 | // Check global rate limit | |
| 34 | 34 | if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") { | |
| 35 | - | w.Header().Set("Content-Type", "application/json") | |
| 35 | + | setAdminJSONHeaders(w) | |
| 36 | 36 | w.Header().Set("Retry-After", "1") | |
| 37 | 37 | w.WriteHeader(http.StatusTooManyRequests) | |
| 38 | 38 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | |
| @@ -43,7 +43,7 @@ func AuthMiddleware( | |||
| 43 | 43 | if ipLimiter != nil { | |
| 44 | 44 | ip := middleware.GetClientIP(r, trustedNets) | |
| 45 | 45 | if !ipLimiter.Allow(ctx, "ip:"+ip) { | |
| 46 | - | w.Header().Set("Content-Type", "application/json") | |
| 46 | + | setAdminJSONHeaders(w) | |
| 47 | 47 | w.Header().Set("Retry-After", "1") | |
| 48 | 48 | w.WriteHeader(http.StatusTooManyRequests) | |
| 49 | 49 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | |
| @@ -106,6 +106,10 @@ func AuthMiddleware( | |||
| 106 | 106 | ctx = context.WithValue(ctx, domain.ContextKeySubject, claims.Subject) | |
| 107 | 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 | 113 | // Attempt to slide token expiry. Non-fatal if it fails. | |
| 110 | 114 | // The new token is returned in X-Gordon-Token so the CLI can persist it atomically. | |
| 111 | 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 | 168 | func sendUnauthorized(w http.ResponseWriter, message string) { | |
| 165 | - | w.Header().Set("Content-Type", "application/json") | |
| 169 | + | setAdminJSONHeaders(w) | |
| 166 | 170 | w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`) | |
| 167 | 171 | w.WriteHeader(http.StatusUnauthorized) | |
| 168 | 172 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message}) | |
| 169 | 173 | } | |
| 170 | 174 | ||
| 171 | 175 | func sendForbidden(w http.ResponseWriter, message string) { | |
| 172 | - | w.Header().Set("Content-Type", "application/json") | |
| 176 | + | setAdminJSONHeaders(w) | |
| 173 | 177 | w.WriteHeader(http.StatusForbidden) | |
| 174 | 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 | + | } | |
| @@ -32,7 +32,7 @@ func AuthMiddleware( | |||||
| 32 | 32 | ||||
| 33 | // Check global rate limit | 33 | // 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) { | |||
| 42 | 42 | ||
| 43 | 43 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 44 | 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 | 49 | func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | |
| @@ -67,6 +69,8 @@ func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) { | |||
| 67 | 69 | ||
| 68 | 70 | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 69 | 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 | 76 | func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |
| @@ -181,6 +185,8 @@ func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | |||
| 181 | 185 | ||
| 182 | 186 | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 183 | 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 | 192 | func TestAuthMiddleware_InvalidToken(t *testing.T) { | |
| @@ -390,6 +396,8 @@ func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) { | |||
| 390 | 396 | ||
| 391 | 397 | assert.Equal(t, http.StatusOK, rec.Code) | |
| 392 | 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 | 403 | func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) { | |
| @@ -417,6 +425,8 @@ func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) { | |||
| 417 | 425 | ||
| 418 | 426 | assert.Equal(t, http.StatusOK, rec.Code) | |
| 419 | 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 | 432 | 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 | |||
| 285 | 285 | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | |
| 286 | 286 | // | |
| 287 | 287 | // 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) | |
| 290 | 290 | if err != nil { | |
| 291 | 291 | s.log.Warn(). | |
| 292 | 292 | Str(zerowrap.FieldLayer, "adapter"). | |
| @@ -294,10 +294,10 @@ func (s *FileStore) getEnvFilePath(domainName string) string { | |||
| 294 | 294 | Str("domain", domainName). | |
| 295 | 295 | Err(err). | |
| 296 | 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 | 302 | // SECURITY: Final validation - ensure path stays within envDir | |
| 303 | 303 | cleanPath := filepath.Clean(fullPath) | |
| @@ -309,20 +309,16 @@ func (s *FileStore) getEnvFilePath(domainName string) string { | |||
| 309 | 309 | Str("domain", domainName). | |
| 310 | 310 | Str("attempted_path", fullPath). | |
| 311 | 311 | Msg("path traversal attempt blocked - path escapes env directory") | |
| 312 | - | return "" | |
| 312 | + | return "", domain.ErrPathTraversal | |
| 313 | 313 | } | |
| 314 | 314 | ||
| 315 | - | return fullPath | |
| 315 | + | return fullPath, nil | |
| 316 | 316 | } | |
| 317 | 317 | ||
| 318 | 318 | // validateEnvFilePath validates that a domain produces a valid env file path. | |
| 319 | 319 | // Returns an error if the domain is invalid or would result in path traversal. | |
| 320 | 320 | 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) | |
| 326 | 322 | } | |
| 327 | 323 | ||
| 328 | 324 | // 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 envDir | 302 | // 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 fullPath | 315 | + | 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) { | |||
| 101 | 101 | require.NoError(t, err) | |
| 102 | 102 | ||
| 103 | 103 | // Verify file exists inside tmpDir | |
| 104 | - | expectedFile := filepath.Join(tmpDir, "example_com.env") | |
| 104 | + | expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env") | |
| 105 | 105 | _, err = os.Stat(expectedFile) | |
| 106 | 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 | 137 | func TestSanitizeDomainForContainer(t *testing.T) { | |
| 120 | 138 | tests := []struct { | |
| 121 | 139 | domain string | |
| @@ -389,21 +407,23 @@ func TestGetEnvFilePath(t *testing.T) { | |||
| 389 | 407 | wantEmpty bool | |
| 390 | 408 | wantFilename string | |
| 391 | 409 | }{ | |
| 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"}, | |
| 396 | 414 | {"../evil", true, ""}, | |
| 397 | 415 | {"", true, ""}, | |
| 398 | 416 | } | |
| 399 | 417 | ||
| 400 | 418 | for _, tt := range tests { | |
| 401 | 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 | 422 | if tt.wantEmpty { | |
| 423 | + | assert.Error(t, err) | |
| 405 | 424 | assert.Empty(t, path) | |
| 406 | 425 | } else { | |
| 426 | + | require.NoError(t, err) | |
| 407 | 427 | assert.NotEmpty(t, path) | |
| 408 | 428 | assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path) | |
| 409 | 429 | } | |
| @@ -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 tmpDir | 103 | // 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 string | 139 | domain string | ||
| @@ -389,21 +407,23 @@ func TestGetEnvFilePath(t *testing.T) { | |||||
| 389 | wantEmpty bool | 407 | wantEmpty bool | ||
| 390 | wantFilename string | 408 | 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 ( | |||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 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 | 63 | log := zerowrap.FromCtx(ctx) | |
| 63 | 64 | ||
| 64 | 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 | 71 | // Check if env file exists | |
| 68 | 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 | 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 | 154 | // Check if file already exists | |
| 148 | 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 | 178 | // 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 | |
| 176 | 194 | } | |
| 177 | 195 | ||
| 178 | 196 | // resolveSecrets resolves any secret references in the value. | |
| @@ -222,10 +240,11 @@ func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, | |||
| 222 | 240 | return result, nil | |
| 223 | 241 | } | |
| 224 | 242 | ||
| 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 | |
| 231 | 250 | } | |
| @@ -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 exists | 71 | // 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 exists | 154 | // 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 == 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 | |||
| 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, nil | 240 | 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 ( | |||
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | func TestFileLoader_ResolveSecrets(t *testing.T) { | |
| @@ -121,7 +122,9 @@ func TestFileLoader_LoadEnv(t *testing.T) { | |||
| 121 | 122 | NODE_ENV=production | |
| 122 | 123 | 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 | 128 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 126 | 129 | ||
| 127 | 130 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| @@ -146,7 +149,9 @@ API_URL=https://api.example.com | |||
| 146 | 149 | envContent := `NODE_ENV=production | |
| 147 | 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 | 155 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 151 | 156 | ||
| 152 | 157 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| @@ -164,7 +169,9 @@ API_KEY=${pass:app/api-key} | |||
| 164 | 169 | envContent := `DOUBLE_QUOTED="hello world" | |
| 165 | 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 | 175 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 169 | 176 | ||
| 170 | 177 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| @@ -183,7 +190,9 @@ SINGLE_QUOTED='hello world' | |||
| 183 | 190 | invalid line without equals | |
| 184 | 191 | 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 | 196 | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 188 | 197 | ||
| 189 | 198 | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| @@ -205,15 +214,75 @@ func TestFileLoader_GetEnvFilePath(t *testing.T) { | |||
| 205 | 214 | domain string | |
| 206 | 215 | expected string | |
| 207 | 216 | }{ | |
| 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"}, | |
| 211 | 220 | } | |
| 212 | 221 | ||
| 213 | 222 | for _, tt := range tests { | |
| 214 | 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 | 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 | + | } | |
| @@ -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=production | 122 | NODE_ENV=production | ||
| 122 | API_URL=https://api.example.com | 123 | 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=production | 149 | 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 equals | 190 | invalid line without equals | ||
| 184 | ALSO_VALID=another | 191 | 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 string | 214 | domain string | ||
| 206 | expected string | 215 | 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 { | |||
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 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 | 108 | exists, err := l.store.ManifestExists(domain) | |
| 109 | 109 | if err != nil { | |
| 110 | 110 | l.log.Warn().Err(err).Str("domain", domain).Msg("failed to check pass manifest") | |
| 111 | - | return false | |
| 111 | + | return false, err | |
| 112 | 112 | } | |
| 113 | - | return exists | |
| 113 | + | return exists, nil | |
| 114 | 114 | } | |
| @@ -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 false | 111 | + | return false, err |
| 112 | } | 112 | } | ||
| 113 | - | return exists | 113 | + | return exists, nil |
| 114 | } | 114 | } | ||
M internal/app/kernel_test.go +1 -0
| @@ -22,6 +22,7 @@ data_dir = %q | |||
| 22 | 22 | ||
| 23 | 23 | [auth] | |
| 24 | 24 | enabled = false | |
| 25 | + | secrets_backend = "unsafe" | |
| 25 | 26 | `, dataDir) | |
| 26 | 27 | ||
| 27 | 28 | err := os.WriteFile(cfgPath, []byte(cfg), 0o600) | |
| @@ -22,6 +22,7 @@ data_dir = %q | |||||
| 22 | 22 | ||||
| 23 | [auth] | 23 | [auth] | ||
| 24 | enabled = false | 24 | 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 { | |||
| 585 | 585 | ||
| 586 | 586 | func createDomainSecretStore(cfg Config, log zerowrap.Logger) (string, domain.SecretsBackend, *domainsecrets.PassStore, out.DomainSecretStore, error) { | |
| 587 | 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 | 593 | switch backend { | |
| 591 | 594 | case domain.SecretsBackendPass: | |
| @@ -896,7 +899,10 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||
| 896 | 899 | } | |
| 897 | 900 | ||
| 898 | 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 | 906 | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 901 | 907 | ||
| 902 | 908 | store, err := createTokenStore(backend, dataDir, log) | |
| @@ -941,16 +947,18 @@ func resolveAuthType(cfg Config) domain.AuthType { | |||
| 941 | 947 | return domain.AuthTypeToken | |
| 942 | 948 | } | |
| 943 | 949 | ||
| 944 | - | func resolveSecretsBackend(backend string) domain.SecretsBackend { | |
| 950 | + | func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) { | |
| 945 | 951 | switch backend { | |
| 946 | 952 | case "pass": | |
| 947 | - | return domain.SecretsBackendPass | |
| 953 | + | return domain.SecretsBackendPass, nil | |
| 948 | 954 | 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") | |
| 952 | 960 | default: | |
| 953 | - | return domain.SecretsBackendUnsafe | |
| 961 | + | 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 | 2347 | v.SetDefault("auth.enabled", true) | |
| 2340 | 2348 | // Note: auth.type is intentionally not set - it's inferred from config | |
| 2341 | 2349 | // If password_hash is set -> password mode, otherwise -> token mode | |
| 2342 | - | v.SetDefault("auth.secrets_backend", "unsafe") | |
| 2350 | + | v.SetDefault("auth.secrets_backend", "") | |
| 2343 | 2351 | v.SetDefault("auth.token_expiry", "720h") | |
| 2344 | 2352 | v.SetDefault("api.rate_limit.enabled", true) | |
| 2345 | 2353 | 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.AuthTypeToken | 947 | 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.SecretsBackendPass | 953 | + | return domain.SecretsBackendPass, nil |
| 948 | case "sops": | 954 | case "sops": | ||
| 949 | - | return domain.SecretsBackendSops | 955 | + | return domain.SecretsBackendSops, nil |
| 950 | - | case "unsafe", "": | 956 | + | case "unsafe": |
| 951 | - | return domain.SecretsBackendUnsafe | 957 | + | return domain.SecretsBackendUnsafe, nil |
| 958 | + | case "": | |||
| 959 | + | return "", fmt.Errorf("auth.secrets_backend is required") | |||
| 952 | default: | 960 | default: | ||
| 953 | - | return domain.SecretsBackendUnsafe | 961 | + | 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 config | 2348 | // Note: auth.type is intentionally not set - it's inferred from config | ||
| 2341 | // If password_hash is set -> password mode, otherwise -> token mode | 2349 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/gordon/internal/domain" | |
| 8 | 9 | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/spf13/viper" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | |
| @@ -23,3 +25,36 @@ func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) { | |||
| 23 | 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 | + | } | |
| @@ -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 | |||
| 231 | 231 | 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 | 234 | // DeleteManifest provides a mock function for the type MockRegistryService | |
| 275 | 235 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | |
| 276 | 236 | ret := _mock.Called(ctx, name, reference) | |
| @@ -935,43 +895,3 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex | |||
| 935 | 895 | _c.Call.Return(run) | |
| 936 | 896 | 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 | - | } | |
| @@ -231,46 +231,6 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte | |||||
| 231 | return _c | 231 | 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 MockRegistryService | 234 | // 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 _c | 896 | 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 { | |||
| 12 | 12 | CreateEnvFile(ctx context.Context, domain string) error | |
| 13 | 13 | ||
| 14 | 14 | // EnvFileExists checks if an environment file exists for a domain. | |
| 15 | - | EnvFileExists(domain string) bool | |
| 15 | + | EnvFileExists(domain string) (bool, error) | |
| 16 | 16 | } | |
| @@ -12,5 +12,5 @@ type EnvLoader interface { | |||||
| 12 | CreateEnvFile(ctx context.Context, domain string) error | 12 | 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) bool | 15 | + | 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 | |||
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | 97 | // 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 | 99 | ret := _mock.Called(domain) | |
| 100 | 100 | ||
| 101 | 101 | if len(ret) == 0 { | |
| @@ -103,12 +103,21 @@ func (_mock *MockEnvLoader) EnvFileExists(domain string) bool { | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | 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 | 110 | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { | |
| 107 | 111 | r0 = returnFunc(domain) | |
| 108 | 112 | } else { | |
| 109 | 113 | r0 = ret.Get(0).(bool) | |
| 110 | 114 | } | |
| 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 | |
| 112 | 121 | } | |
| 113 | 122 | ||
| 114 | 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 | 144 | return _c | |
| 136 | 145 | } | |
| 137 | 146 | ||
| 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) | |
| 140 | 149 | 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 | 153 | _c.Call.Return(run) | |
| 145 | 154 | return _c | |
| 146 | 155 | } | |
| @@ -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 MockEnvLoader | 97 | // 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 bool | 105 | 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 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 | |||
| 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 _c | 144 | 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 _c | 149 | 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 _c | 154 | return _c | ||
| 146 | } | 155 | } | ||
M internal/domain/env.go +5 -19
| @@ -7,32 +7,18 @@ import ( | |||
| 7 | 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 | 10 | var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | |
| 14 | 11 | var containerNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) | |
| 15 | 12 | ||
| 16 | 13 | // 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. | |
| 19 | 15 | func SanitizeDomainForEnvFile(domainName string) (string, error) { | |
| 20 | - | if domainName == "" { | |
| 21 | - | return "", ErrPathTraversal | |
| 16 | + | safeDomain, err := NewEnvStorageKey(domainName) | |
| 17 | + | if err != nil { | |
| 18 | + | return "", err | |
| 22 | 19 | } | |
| 23 | 20 | ||
| 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 | |
| 36 | 22 | } | |
| 37 | 23 | ||
| 38 | 24 | // 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 "", ErrPathTraversal | 17 | + | 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 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/base64" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -15,12 +16,12 @@ func TestSanitizeDomainForEnvFile(t *testing.T) { | |||
| 15 | 16 | want string | |
| 16 | 17 | wantErr bool | |
| 17 | 18 | }{ | |
| 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"))}, | |
| 24 | 25 | {name: "empty", domain: "", wantErr: true}, | |
| 25 | 26 | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | |
| 26 | 27 | {name: "double dots", domain: "foo..bar", wantErr: true}, | |
| @@ -28,8 +29,7 @@ func TestSanitizeDomainForEnvFile(t *testing.T) { | |||
| 28 | 29 | {name: "ends with dot", domain: "trailing.", wantErr: true}, | |
| 29 | 30 | {name: "space", domain: "has space", wantErr: true}, | |
| 30 | 31 | {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}, | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 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 | 63 | func TestParseEnvData(t *testing.T) { | |
| 49 | 64 | tests := []struct { | |
| 50 | 65 | name string | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package domain | 1 | 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 string | 16 | want string | ||
| 16 | wantErr bool | 17 | 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 string | 65 | 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, | |||
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | 275 | // 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 | 280 | envFileDst := filepath.Join(h.envDir, envFileName) | |
| 278 | 281 | ||
| 279 | 282 | existingEnv := make(map[string]string) | |
| @@ -465,14 +468,14 @@ func parseEnvFile(data []byte) (map[string]string, error) { | |||
| 465 | 468 | } | |
| 466 | 469 | ||
| 467 | 470 | // 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 | |
| 476 | 479 | } | |
| 477 | 480 | ||
| 478 | 481 | // 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 domain | 275 | // 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.getEnvFilePath | 472 | + | 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) { | |||
| 28 | 28 | { | |
| 29 | 29 | name: "simple domain", | |
| 30 | 30 | domain: "app.example.com", | |
| 31 | - | expected: "app_example_com.env", | |
| 31 | + | expected: "YXBwLmV4YW1wbGUuY29t.env", | |
| 32 | 32 | }, | |
| 33 | 33 | { | |
| 34 | 34 | name: "domain with port", | |
| 35 | 35 | domain: "app.example.com:8080", | |
| 36 | - | expected: "app_example_com_8080.env", | |
| 36 | + | expected: "YXBwLmV4YW1wbGUuY29tOjgwODA.env", | |
| 37 | 37 | }, | |
| 38 | 38 | { | |
| 39 | 39 | name: "domain with path", | |
| 40 | 40 | domain: "app.example.com/api", | |
| 41 | - | expected: "app_example_com_api.env", | |
| 41 | + | expected: "YXBwLmV4YW1wbGUuY29tL2FwaQ.env", | |
| 42 | 42 | }, | |
| 43 | 43 | { | |
| 44 | 44 | name: "complex domain", | |
| 45 | 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 | 49 | name: "single word", | |
| 50 | 50 | domain: "localhost", | |
| 51 | - | expected: "localhost.env", | |
| 51 | + | expected: "bG9jYWxob3N0.env", | |
| 52 | 52 | }, | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | for _, tt := range tests { | |
| 56 | 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 | 59 | assert.Equal(t, tt.expected, result) | |
| 59 | 60 | }) | |
| 60 | 61 | } | |
| @@ -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 { | |||
| 278 | 278 | ||
| 279 | 279 | // ValidateDomain validates that a domain is safe to use for secret storage. | |
| 280 | 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 | 282 | // Check for empty domain | |
| 283 | - | if domain == "" { | |
| 283 | + | if domainName == "" { | |
| 284 | 284 | return ErrDomainEmpty | |
| 285 | 285 | } | |
| 286 | 286 | ||
| 287 | 287 | // Check domain length (max DNS name length is 253) | |
| 288 | - | if len(domain) > 253 { | |
| 288 | + | if len(domainName) > 253 { | |
| 289 | 289 | return ErrDomainTooLong | |
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | 292 | // Check for path traversal attempts | |
| 293 | - | if strings.Contains(domain, "..") { | |
| 293 | + | if strings.Contains(domainName, "..") { | |
| 294 | 294 | return ErrDomainPathTraversal | |
| 295 | 295 | } | |
| 296 | 296 | ||
| 297 | 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 | 305 | return ErrDomainInvalidChars | |
| 300 | 306 | } | |
| 301 | 307 | ||
| @@ -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 domain | 282 | // Check for empty domain | ||
| 283 | - | if domain == "" { | 283 | + | if domainName == "" { |
| 284 | return ErrDomainEmpty | 284 | 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 ErrDomainTooLong | 289 | return ErrDomainTooLong | ||
| 290 | } | 290 | } | ||
| 291 | 291 | ||||
| 292 | // Check for path traversal attempts | 292 | // Check for path traversal attempts | ||
| 293 | - | if strings.Contains(domain, "..") { | 293 | + | if strings.Contains(domainName, "..") { |
| 294 | return ErrDomainPathTraversal | 294 | 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 ErrDomainInvalidChars | 305 | return ErrDomainInvalidChars | ||
| 300 | } | 306 | } | ||
| 301 | 307 | ||||
M internal/usecase/secrets/service_test.go +11 -0
| @@ -108,6 +108,11 @@ func TestValidateDomain(t *testing.T) { | |||
| 108 | 108 | domain: "\x00example.com", | |
| 109 | 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 | 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 | 136 | func TestService_ListKeys_ValidationErrors(t *testing.T) { | |
| 126 | 137 | store := outmocks.NewMockDomainSecretStore(t) | |
| 127 | 138 | 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()) | ||