fix(secrets): harden pass storage and auth
- commit
- ccfc41759bd0097b9b799e54f71a49551a7c3c0a
- author
- bnema · · 2026-01-30 21:07 UTC
- parent
- 80b88e8
- signature
- unsigned
5 files · +98 -22
M internal/adapters/in/cli/auth.go +11 -9
| @@ -326,22 +326,24 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||
| 326 | 326 | return err | |
| 327 | 327 | } | |
| 328 | 328 | ||
| 329 | - | if err := remote.UpdateRemoteToken(resolvedName, token); err != nil { | |
| 330 | - | return fmt.Errorf("failed to save token: %w", err) | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | fmt.Println() | |
| 334 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | |
| 335 | - | ||
| 336 | 329 | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | |
| 337 | 330 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 338 | 331 | defer cancel() | |
| 332 | + | verified := true | |
| 339 | 333 | if _, err := client.GetStatus(ctx); err != nil { | |
| 340 | 334 | fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | |
| 341 | - | return nil | |
| 335 | + | verified = false | |
| 342 | 336 | } | |
| 343 | 337 | ||
| 344 | - | fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint")) | |
| 338 | + | if err := remote.UpdateRemoteToken(resolvedName, token); err != nil { | |
| 339 | + | return fmt.Errorf("failed to save token: %w", err) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | fmt.Println() | |
| 343 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | |
| 344 | + | if verified { | |
| 345 | + | fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint")) | |
| 346 | + | } | |
| 345 | 347 | return nil | |
| 346 | 348 | } | |
| 347 | 349 | ||
| @@ -326,22 +326,24 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||||
| 326 | return err | 326 | return err | ||
| 327 | } | 327 | } | ||
| 328 | 328 | ||||
| 329 | - | if err := remote.UpdateRemoteToken(resolvedName, token); err != nil { | |||
| 330 | - | return fmt.Errorf("failed to save token: %w", err) | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | fmt.Println() | |||
| 334 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | |||
| 335 | - | ||||
| 336 | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | 329 | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | ||
| 337 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | 330 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| 338 | defer cancel() | 331 | defer cancel() | ||
| 332 | + | verified := true | |||
| 339 | if _, err := client.GetStatus(ctx); err != nil { | 333 | if _, err := client.GetStatus(ctx); err != nil { | ||
| 340 | fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | 334 | fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err))) | ||
| 341 | - | return nil | 335 | + | verified = false |
| 342 | } | 336 | } | ||
| 343 | 337 | ||||
| 344 | - | fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint")) | 338 | + | if err := remote.UpdateRemoteToken(resolvedName, token); err != nil { |
| 339 | + | return fmt.Errorf("failed to save token: %w", err) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | fmt.Println() | |||
| 343 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName))) | |||
| 344 | + | if verified { | |||
| 345 | + | fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint")) | |||
| 346 | + | } | |||
| 345 | return nil | 347 | return nil | ||
| 346 | } | 348 | } | ||
| 347 | 349 | ||||
M internal/adapters/out/domainsecrets/pass_store.go +49 -12
| @@ -86,9 +86,6 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |||
| 86 | 86 | return nil, err | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 90 | - | defer cancel() | |
| 91 | - | ||
| 92 | 89 | secretsMap := make(map[string]string) | |
| 93 | 90 | for _, key := range keys { | |
| 94 | 91 | keyPath, err := s.keyPath(domainName, key) | |
| @@ -96,7 +93,9 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |||
| 96 | 93 | return nil, err | |
| 97 | 94 | } | |
| 98 | 95 | ||
| 96 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 99 | 97 | value, exists, err := s.passShow(ctx, keyPath) | |
| 98 | + | cancel() | |
| 100 | 99 | if err != nil { | |
| 101 | 100 | return nil, err | |
| 102 | 101 | } | |
| @@ -121,22 +120,38 @@ func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | |||
| 121 | 120 | return err | |
| 122 | 121 | } | |
| 123 | 122 | ||
| 124 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 125 | - | defer cancel() | |
| 123 | + | existingKeys, err := s.ListKeys(domainName) | |
| 124 | + | if err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |
| 129 | + | for _, key := range existingKeys { | |
| 130 | + | existingSet[key] = struct{}{} | |
| 131 | + | } | |
| 126 | 132 | ||
| 133 | + | insertedNewPaths := make([]string, 0, len(secretsMap)) | |
| 127 | 134 | for key, value := range secretsMap { | |
| 135 | + | if err := domain.ValidateEnvKey(key); err != nil { | |
| 136 | + | return err | |
| 137 | + | } | |
| 138 | + | ||
| 128 | 139 | keyPath, err := s.keyPath(domainName, key) | |
| 129 | 140 | if err != nil { | |
| 130 | 141 | return err | |
| 131 | 142 | } | |
| 132 | - | if err := s.passInsert(ctx, keyPath, value); err != nil { | |
| 143 | + | ||
| 144 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 145 | + | err = s.passInsert(ctx, keyPath, value) | |
| 146 | + | cancel() | |
| 147 | + | if err != nil { | |
| 148 | + | s.cleanupInsertedPaths(insertedNewPaths) | |
| 133 | 149 | return fmt.Errorf("failed to store secret %s: %w", key, err) | |
| 134 | 150 | } | |
| 135 | - | } | |
| 136 | 151 | ||
| 137 | - | existingKeys, err := s.ListKeys(domainName) | |
| 138 | - | if err != nil { | |
| 139 | - | return err | |
| 152 | + | if _, exists := existingSet[key]; !exists { | |
| 153 | + | insertedNewPaths = append(insertedNewPaths, keyPath) | |
| 154 | + | } | |
| 140 | 155 | } | |
| 141 | 156 | ||
| 142 | 157 | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | |
| @@ -158,9 +173,13 @@ func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | |||
| 158 | 173 | return err | |
| 159 | 174 | } | |
| 160 | 175 | ||
| 176 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 161 | 177 | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | |
| 178 | + | cancel() | |
| 179 | + | s.cleanupInsertedPaths(insertedNewPaths) | |
| 162 | 180 | return fmt.Errorf("failed to update manifest: %w", err) | |
| 163 | 181 | } | |
| 182 | + | cancel() | |
| 164 | 183 | ||
| 165 | 184 | return nil | |
| 166 | 185 | } | |
| @@ -281,6 +300,9 @@ func (s *PassStore) keyPath(domainName, key string) (string, error) { | |||
| 281 | 300 | if err != nil { | |
| 282 | 301 | return "", err | |
| 283 | 302 | } | |
| 303 | + | if err := domain.ValidateEnvKey(key); err != nil { | |
| 304 | + | return "", err | |
| 305 | + | } | |
| 284 | 306 | path := fmt.Sprintf("%s/%s", domainPath, key) | |
| 285 | 307 | if err := secrets.ValidatePath(path); err != nil { | |
| 286 | 308 | return "", err | |
| @@ -363,7 +385,9 @@ func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, er | |||
| 363 | 385 | } | |
| 364 | 386 | return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err) | |
| 365 | 387 | } | |
| 366 | - | return strings.TrimSpace(string(output)), true, nil | |
| 388 | + | ||
| 389 | + | clean := ansiRegex.ReplaceAllString(string(output), "") | |
| 390 | + | return strings.TrimSpace(clean), true, nil | |
| 367 | 391 | } | |
| 368 | 392 | ||
| 369 | 393 | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | |
| @@ -453,7 +477,20 @@ func parsePassListOutput(basePath, output string) []passListEntry { | |||
| 453 | 477 | } | |
| 454 | 478 | ||
| 455 | 479 | func passEntryMissing(output string) bool { | |
| 456 | - | lower := strings.ToLower(output) | |
| 480 | + | clean := ansiRegex.ReplaceAllString(output, "") | |
| 481 | + | lower := strings.ToLower(clean) | |
| 457 | 482 | return strings.Contains(lower, "not in the password store") || | |
| 458 | 483 | strings.Contains(lower, "password store is empty") | |
| 459 | 484 | } | |
| 485 | + | ||
| 486 | + | func (s *PassStore) cleanupInsertedPaths(paths []string) { | |
| 487 | + | if len(paths) == 0 { | |
| 488 | + | return | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | for _, path := range paths { | |
| 492 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 493 | + | _ = s.passRemove(ctx, path) | |
| 494 | + | cancel() | |
| 495 | + | } | |
| 496 | + | } | |
| @@ -86,9 +86,6 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |||||
| 86 | return nil, err | 86 | return nil, err | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 90 | - | defer cancel() | |||
| 91 | - | ||||
| 92 | secretsMap := make(map[string]string) | 89 | secretsMap := make(map[string]string) | ||
| 93 | for _, key := range keys { | 90 | for _, key := range keys { | ||
| 94 | keyPath, err := s.keyPath(domainName, key) | 91 | keyPath, err := s.keyPath(domainName, key) | ||
| @@ -96,7 +93,9 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) { | |||||
| 96 | return nil, err | 93 | return nil, err | ||
| 97 | } | 94 | } | ||
| 98 | 95 | ||||
| 96 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 99 | value, exists, err := s.passShow(ctx, keyPath) | 97 | value, exists, err := s.passShow(ctx, keyPath) | ||
| 98 | + | cancel() | |||
| 100 | if err != nil { | 99 | if err != nil { | ||
| 101 | return nil, err | 100 | return nil, err | ||
| 102 | } | 101 | } | ||
| @@ -121,22 +120,38 @@ func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | |||||
| 121 | return err | 120 | return err | ||
| 122 | } | 121 | } | ||
| 123 | 122 | ||||
| 124 | - | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | 123 | + | existingKeys, err := s.ListKeys(domainName) |
| 125 | - | defer cancel() | 124 | + | if err != nil { |
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | existingSet := make(map[string]struct{}, len(existingKeys)) | |||
| 129 | + | for _, key := range existingKeys { | |||
| 130 | + | existingSet[key] = struct{}{} | |||
| 131 | + | } | |||
| 126 | 132 | ||||
| 133 | + | insertedNewPaths := make([]string, 0, len(secretsMap)) | |||
| 127 | for key, value := range secretsMap { | 134 | for key, value := range secretsMap { | ||
| 135 | + | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 136 | + | return err | |||
| 137 | + | } | |||
| 138 | + | ||||
| 128 | keyPath, err := s.keyPath(domainName, key) | 139 | keyPath, err := s.keyPath(domainName, key) | ||
| 129 | if err != nil { | 140 | if err != nil { | ||
| 130 | return err | 141 | return err | ||
| 131 | } | 142 | } | ||
| 132 | - | if err := s.passInsert(ctx, keyPath, value); err != nil { | 143 | + | |
| 144 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 145 | + | err = s.passInsert(ctx, keyPath, value) | |||
| 146 | + | cancel() | |||
| 147 | + | if err != nil { | |||
| 148 | + | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 133 | return fmt.Errorf("failed to store secret %s: %w", key, err) | 149 | return fmt.Errorf("failed to store secret %s: %w", key, err) | ||
| 134 | } | 150 | } | ||
| 135 | - | } | |||
| 136 | 151 | ||||
| 137 | - | existingKeys, err := s.ListKeys(domainName) | 152 | + | if _, exists := existingSet[key]; !exists { |
| 138 | - | if err != nil { | 153 | + | insertedNewPaths = append(insertedNewPaths, keyPath) |
| 139 | - | return err | 154 | + | } |
| 140 | } | 155 | } | ||
| 141 | 156 | ||||
| 142 | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | 157 | keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap)) | ||
| @@ -158,9 +173,13 @@ func (s *PassStore) Set(domainName string, secretsMap map[string]string) error { | |||||
| 158 | return err | 173 | return err | ||
| 159 | } | 174 | } | ||
| 160 | 175 | ||||
| 176 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 161 | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | 177 | if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil { | ||
| 178 | + | cancel() | |||
| 179 | + | s.cleanupInsertedPaths(insertedNewPaths) | |||
| 162 | return fmt.Errorf("failed to update manifest: %w", err) | 180 | return fmt.Errorf("failed to update manifest: %w", err) | ||
| 163 | } | 181 | } | ||
| 182 | + | cancel() | |||
| 164 | 183 | ||||
| 165 | return nil | 184 | return nil | ||
| 166 | } | 185 | } | ||
| @@ -281,6 +300,9 @@ func (s *PassStore) keyPath(domainName, key string) (string, error) { | |||||
| 281 | if err != nil { | 300 | if err != nil { | ||
| 282 | return "", err | 301 | return "", err | ||
| 283 | } | 302 | } | ||
| 303 | + | if err := domain.ValidateEnvKey(key); err != nil { | |||
| 304 | + | return "", err | |||
| 305 | + | } | |||
| 284 | path := fmt.Sprintf("%s/%s", domainPath, key) | 306 | path := fmt.Sprintf("%s/%s", domainPath, key) | ||
| 285 | if err := secrets.ValidatePath(path); err != nil { | 307 | if err := secrets.ValidatePath(path); err != nil { | ||
| 286 | return "", err | 308 | return "", err | ||
| @@ -363,7 +385,9 @@ func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, er | |||||
| 363 | } | 385 | } | ||
| 364 | return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err) | 386 | return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err) | ||
| 365 | } | 387 | } | ||
| 366 | - | return strings.TrimSpace(string(output)), true, nil | 388 | + | |
| 389 | + | clean := ansiRegex.ReplaceAllString(string(output), "") | |||
| 390 | + | return strings.TrimSpace(clean), true, nil | |||
| 367 | } | 391 | } | ||
| 368 | 392 | ||||
| 369 | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | 393 | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | ||
| @@ -453,7 +477,20 @@ func parsePassListOutput(basePath, output string) []passListEntry { | |||||
| 453 | } | 477 | } | ||
| 454 | 478 | ||||
| 455 | func passEntryMissing(output string) bool { | 479 | func passEntryMissing(output string) bool { | ||
| 456 | - | lower := strings.ToLower(output) | 480 | + | clean := ansiRegex.ReplaceAllString(output, "") |
| 481 | + | lower := strings.ToLower(clean) | |||
| 457 | return strings.Contains(lower, "not in the password store") || | 482 | return strings.Contains(lower, "not in the password store") || | ||
| 458 | strings.Contains(lower, "password store is empty") | 483 | strings.Contains(lower, "password store is empty") | ||
| 459 | } | 484 | } | ||
| 485 | + | ||||
| 486 | + | func (s *PassStore) cleanupInsertedPaths(paths []string) { | |||
| 487 | + | if len(paths) == 0 { | |||
| 488 | + | return | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | for _, path := range paths { | |||
| 492 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 493 | + | _ = s.passRemove(ctx, path) | |||
| 494 | + | cancel() | |||
| 495 | + | } | |||
| 496 | + | } | |||
M internal/app/migrate_env.go +6 -0
| @@ -61,6 +61,12 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log | |||
| 61 | 61 | if err != nil { | |
| 62 | 62 | return fmt.Errorf("failed to parse env file %s: %w", filePath, err) | |
| 63 | 63 | } | |
| 64 | + | if len(secrets) == 0 { | |
| 65 | + | log.Info(). | |
| 66 | + | Str("file", filePath). | |
| 67 | + | Msg("no secrets found in env file; skipping migration") | |
| 68 | + | return nil | |
| 69 | + | } | |
| 64 | 70 | ||
| 65 | 71 | existingKeys, err := passStore.ListKeys(domainName) | |
| 66 | 72 | if err != nil { | |
| @@ -61,6 +61,12 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log | |||||
| 61 | if err != nil { | 61 | if err != nil { | ||
| 62 | return fmt.Errorf("failed to parse env file %s: %w", filePath, err) | 62 | return fmt.Errorf("failed to parse env file %s: %w", filePath, err) | ||
| 63 | } | 63 | } | ||
| 64 | + | if len(secrets) == 0 { | |||
| 65 | + | log.Info(). | |||
| 66 | + | Str("file", filePath). | |||
| 67 | + | Msg("no secrets found in env file; skipping migration") | |||
| 68 | + | return nil | |||
| 69 | + | } | |||
| 64 | 70 | ||||
| 65 | existingKeys, err := passStore.ListKeys(domainName) | 71 | existingKeys, err := passStore.ListKeys(domainName) | ||
| 66 | if err != nil { | 72 | if err != nil { | ||
M internal/app/run.go +17 -1
| @@ -483,6 +483,7 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err | |||
| 483 | 483 | const ( | |
| 484 | 484 | internalRegistryUsername = "gordon-internal" | |
| 485 | 485 | serviceTokenSubject = "gordon-service" | |
| 486 | + | serviceTokenDefaultTTL = 30 * 24 * time.Hour | |
| 486 | 487 | ) | |
| 487 | 488 | ||
| 488 | 489 | func generateInternalRegistryAuth() (string, string, error) { | |
| @@ -789,6 +790,17 @@ func parseTokenExpiry(expiry string) (time.Duration, error) { | |||
| 789 | 790 | return parsed, nil | |
| 790 | 791 | } | |
| 791 | 792 | ||
| 793 | + | func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) { | |
| 794 | + | expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry) | |
| 795 | + | if err != nil { | |
| 796 | + | return 0, err | |
| 797 | + | } | |
| 798 | + | if expiry <= 0 { | |
| 799 | + | return serviceTokenDefaultTTL, nil | |
| 800 | + | } | |
| 801 | + | return expiry, nil | |
| 802 | + | } | |
| 803 | + | ||
| 792 | 804 | // loadSecret loads a secret from the configured backend. | |
| 793 | 805 | func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { | |
| 794 | 806 | switch backend { | |
| @@ -831,7 +843,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 831 | 843 | } | |
| 832 | 844 | ||
| 833 | 845 | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| 834 | - | serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, 0) | |
| 846 | + | expiry, err := resolveServiceTokenExpiry(cfg) | |
| 847 | + | if err != nil { | |
| 848 | + | return nil, log.WrapErr(err, "failed to resolve service token expiry") | |
| 849 | + | } | |
| 850 | + | serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry) | |
| 835 | 851 | if err != nil { | |
| 836 | 852 | return nil, log.WrapErr(err, "failed to generate registry service token") | |
| 837 | 853 | } | |
| @@ -483,6 +483,7 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err | |||||
| 483 | const ( | 483 | const ( | ||
| 484 | internalRegistryUsername = "gordon-internal" | 484 | internalRegistryUsername = "gordon-internal" | ||
| 485 | serviceTokenSubject = "gordon-service" | 485 | serviceTokenSubject = "gordon-service" | ||
| 486 | + | serviceTokenDefaultTTL = 30 * 24 * time.Hour | |||
| 486 | ) | 487 | ) | ||
| 487 | 488 | ||||
| 488 | func generateInternalRegistryAuth() (string, string, error) { | 489 | func generateInternalRegistryAuth() (string, string, error) { | ||
| @@ -789,6 +790,17 @@ func parseTokenExpiry(expiry string) (time.Duration, error) { | |||||
| 789 | return parsed, nil | 790 | return parsed, nil | ||
| 790 | } | 791 | } | ||
| 791 | 792 | ||||
| 793 | + | func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) { | |||
| 794 | + | expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry) | |||
| 795 | + | if err != nil { | |||
| 796 | + | return 0, err | |||
| 797 | + | } | |||
| 798 | + | if expiry <= 0 { | |||
| 799 | + | return serviceTokenDefaultTTL, nil | |||
| 800 | + | } | |||
| 801 | + | return expiry, nil | |||
| 802 | + | } | |||
| 803 | + | ||||
| 792 | // loadSecret loads a secret from the configured backend. | 804 | // loadSecret loads a secret from the configured backend. | ||
| 793 | func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { | 805 | func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { | ||
| 794 | switch backend { | 806 | switch backend { | ||
| @@ -831,7 +843,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 831 | } | 843 | } | ||
| 832 | 844 | ||||
| 833 | if cfg.Auth.Enabled && svc.authSvc != nil { | 845 | if cfg.Auth.Enabled && svc.authSvc != nil { | ||
| 834 | - | serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, 0) | 846 | + | expiry, err := resolveServiceTokenExpiry(cfg) |
| 847 | + | if err != nil { | |||
| 848 | + | return nil, log.WrapErr(err, "failed to resolve service token expiry") | |||
| 849 | + | } | |||
| 850 | + | serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry) | |||
| 835 | if err != nil { | 851 | if err != nil { | ||
| 836 | return nil, log.WrapErr(err, "failed to generate registry service token") | 852 | return nil, log.WrapErr(err, "failed to generate registry service token") | ||
| 837 | } | 853 | } | ||
M internal/domain/env.go +15 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | // envDomainRegex validates domain names used for env secrets. | |
| 11 | 11 | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | |
| 12 | 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_]*$`) | |
| 13 | 14 | ||
| 14 | 15 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | |
| 15 | 16 | // Returns a safe domain string suitable for filenames. | |
| @@ -68,3 +69,17 @@ func ParseEnvData(data []byte) (map[string]string, error) { | |||
| 68 | 69 | ||
| 69 | 70 | return secrets, nil | |
| 70 | 71 | } | |
| 72 | + | ||
| 73 | + | // ValidateEnvKey validates an env key for storage. | |
| 74 | + | func ValidateEnvKey(key string) error { | |
| 75 | + | if key == "" { | |
| 76 | + | return ErrPathTraversal | |
| 77 | + | } | |
| 78 | + | if strings.Contains(key, "..") || strings.ContainsAny(key, "/\\") { | |
| 79 | + | return ErrPathTraversal | |
| 80 | + | } | |
| 81 | + | if !envKeyRegex.MatchString(key) { | |
| 82 | + | return ErrPathTraversal | |
| 83 | + | } | |
| 84 | + | return nil | |
| 85 | + | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | // envDomainRegex validates domain names used for env secrets. | 10 | // envDomainRegex validates domain names used for env secrets. | ||
| 11 | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | 11 | // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths). | ||
| 12 | var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) | 12 | 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_]*$`) | |||
| 13 | 14 | ||||
| 14 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | 15 | // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage. | ||
| 15 | // Returns a safe domain string suitable for filenames. | 16 | // Returns a safe domain string suitable for filenames. | ||
| @@ -68,3 +69,17 @@ func ParseEnvData(data []byte) (map[string]string, error) { | |||||
| 68 | 69 | ||||
| 69 | return secrets, nil | 70 | return secrets, nil | ||
| 70 | } | 71 | } | ||
| 72 | + | ||||
| 73 | + | // ValidateEnvKey validates an env key for storage. | |||
| 74 | + | func ValidateEnvKey(key string) error { | |||
| 75 | + | if key == "" { | |||
| 76 | + | return ErrPathTraversal | |||
| 77 | + | } | |||
| 78 | + | if strings.Contains(key, "..") || strings.ContainsAny(key, "/\\") { | |||
| 79 | + | return ErrPathTraversal | |||
| 80 | + | } | |||
| 81 | + | if !envKeyRegex.MatchString(key) { | |||
| 82 | + | return ErrPathTraversal | |||
| 83 | + | } | |||
| 84 | + | return nil | |||
| 85 | + | } | |||