detached 0726e4c

fix(security): prevent path traversal in domain secrets store

commit
0726e4cfafc590143625133bd51c27d8fe7ac3f9
author
bnema · · 2026-01-17 20:58 UTC
parent
55bbfad
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +290 -16
M internal/adapters/out/domainsecrets/store.go +90 -16
@@ -6,9 +6,12 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "regexp"
910 "strings"
1011
1112 "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
1215 )
1316
1417 // FileStore implements the DomainSecretStore interface using filesystem-based env files.
@@ -39,8 +42,11 @@ func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
3942 }
4043
4144 // ListKeys returns the list of secret keys for a domain (not values).
42-func (s *FileStore) ListKeys(domain string) ([]string, error) {
43- envFile := s.getEnvFilePath(domain)
45+func (s *FileStore) ListKeys(domainName string) ([]string, error) {
46+ envFile, err := s.validateEnvFilePath(domainName)
47+ if err != nil {
48+ return nil, err
49+ }
4450
4551 file, err := os.Open(envFile)
4652 if err != nil {
@@ -73,8 +79,11 @@ func (s *FileStore) ListKeys(domain string) ([]string, error) {
7379 }
7480
7581 // GetAll returns all secrets for a domain as a key-value map.
76-func (s *FileStore) GetAll(domain string) (map[string]string, error) {
77- envFile := s.getEnvFilePath(domain)
82+func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
83+ envFile, err := s.validateEnvFilePath(domainName)
84+ if err != nil {
85+ return nil, err
86+ }
7887
7988 file, err := os.Open(envFile)
8089 if err != nil {
@@ -109,14 +118,19 @@ func (s *FileStore) GetAll(domain string) (map[string]string, error) {
109118 }
110119
111120 // Set sets or updates multiple secrets for a domain, merging with existing.
112-func (s *FileStore) Set(domain string, secrets map[string]string) error {
121+func (s *FileStore) Set(domainName string, secrets map[string]string) error {
122+ // Validate domain first
123+ if _, err := s.validateEnvFilePath(domainName); err != nil {
124+ return err
125+ }
126+
113127 // Ensure env directory exists
114128 if err := os.MkdirAll(s.envDir, 0700); err != nil {
115129 return fmt.Errorf("failed to create env directory: %w", err)
116130 }
117131
118132 // Read existing secrets
119- existing, err := s.GetAll(domain)
133+ existing, err := s.GetAll(domainName)
120134 if err != nil {
121135 return err
122136 }
@@ -127,13 +141,18 @@ func (s *FileStore) Set(domain string, secrets map[string]string) error {
127141 }
128142
129143 // Write back atomically
130- return s.writeSecretsAtomic(domain, existing)
144+ return s.writeSecretsAtomic(domainName, existing)
131145 }
132146
133147 // Delete removes a specific secret key from a domain.
134-func (s *FileStore) Delete(domain, key string) error {
148+func (s *FileStore) Delete(domainName, key string) error {
149+ // Validate domain first
150+ if _, err := s.validateEnvFilePath(domainName); err != nil {
151+ return err
152+ }
153+
135154 // Read existing secrets
136- existing, err := s.GetAll(domain)
155+ existing, err := s.GetAll(domainName)
137156 if err != nil {
138157 return err
139158 }
@@ -142,13 +161,16 @@ func (s *FileStore) Delete(domain, key string) error {
142161 delete(existing, key)
143162
144163 // Write back atomically
145- return s.writeSecretsAtomic(domain, existing)
164+ return s.writeSecretsAtomic(domainName, existing)
146165 }
147166
148167 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
149168 // It writes to a temporary file first, syncs it, then renames to the final path.
150-func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error {
151- envFile := s.getEnvFilePath(domain)
169+func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
170+ envFile, err := s.validateEnvFilePath(domainName)
171+ if err != nil {
172+ return err
173+ }
152174 tmpFile := envFile + ".tmp"
153175
154176 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
@@ -157,7 +179,7 @@ func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string)
157179 }
158180
159181 // Write header comment
160- if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
182+ if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil {
161183 file.Close()
162184 os.Remove(tmpFile)
163185 return fmt.Errorf("failed to write header: %w", err)
@@ -198,12 +220,64 @@ func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string)
198220 return nil
199221 }
200222
223+// domainRegex validates domain names to prevent path injection.
224+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
225+var domainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
226+
201227 // getEnvFilePath converts a domain to its env file path.
202228 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
203-func (s *FileStore) getEnvFilePath(domain string) string {
229+//
230+// SECURITY: Validates domain and ensures the resulting path stays within envDir.
231+func (s *FileStore) getEnvFilePath(domainName string) string {
232+ // SECURITY: Reject domains that look like path traversal attempts
233+ if strings.Contains(domainName, "..") {
234+ s.log.Warn().
235+ Str(zerowrap.FieldLayer, "adapter").
236+ Str(zerowrap.FieldAdapter, "domainsecrets").
237+ Str("domain", domainName).
238+ Msg("rejected domain with path traversal sequence")
239+ return ""
240+ }
241+
242+ // SECURITY: Validate domain format
243+ if !domainRegex.MatchString(domainName) {
244+ s.log.Warn().
245+ Str(zerowrap.FieldLayer, "adapter").
246+ Str(zerowrap.FieldAdapter, "domainsecrets").
247+ Str("domain", domainName).
248+ Msg("rejected invalid domain format")
249+ return ""
250+ }
251+
204252 // Create domain-safe filename (replace dots and other chars with underscores)
205- safeDomain := strings.ReplaceAll(domain, ".", "_")
253+ safeDomain := strings.ReplaceAll(domainName, ".", "_")
206254 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
207255 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
208- return filepath.Join(s.envDir, safeDomain+".env")
256+
257+ fullPath := filepath.Join(s.envDir, safeDomain+".env")
258+
259+ // SECURITY: Final validation - ensure path stays within envDir
260+ cleanPath := filepath.Clean(fullPath)
261+ cleanEnvDir := filepath.Clean(s.envDir)
262+ if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
263+ s.log.Error().
264+ Str(zerowrap.FieldLayer, "adapter").
265+ Str(zerowrap.FieldAdapter, "domainsecrets").
266+ Str("domain", domainName).
267+ Str("attempted_path", fullPath).
268+ Msg("path traversal attempt blocked - path escapes env directory")
269+ return ""
270+ }
271+
272+ return fullPath
273+}
274+
275+// validateEnvFilePath validates that a domain produces a valid env file path.
276+// Returns an error if the domain is invalid or would result in path traversal.
277+func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
278+ path := s.getEnvFilePath(domainName)
279+ if path == "" {
280+ return "", domain.ErrPathTraversal
281+ }
282+ return path, nil
209283 }
@@ -6,9 +6,12 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "regexp"
9 "strings"10 "strings"
10 11
11 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
12 )15 )
13 16
14 // FileStore implements the DomainSecretStore interface using filesystem-based env files.17 // FileStore implements the DomainSecretStore interface using filesystem-based env files.
@@ -39,8 +42,11 @@ func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
39 }42 }
40 43
41 // ListKeys returns the list of secret keys for a domain (not values).44 // ListKeys returns the list of secret keys for a domain (not values).
42-func (s *FileStore) ListKeys(domain string) ([]string, error) {45+func (s *FileStore) ListKeys(domainName string) ([]string, error) {
43- envFile := s.getEnvFilePath(domain)46+ envFile, err := s.validateEnvFilePath(domainName)
47+ if err != nil {
48+ return nil, err
49+ }
44 50
45 file, err := os.Open(envFile)51 file, err := os.Open(envFile)
46 if err != nil {52 if err != nil {
@@ -73,8 +79,11 @@ func (s *FileStore) ListKeys(domain string) ([]string, error) {
73 }79 }
74 80
75 // GetAll returns all secrets for a domain as a key-value map.81 // GetAll returns all secrets for a domain as a key-value map.
76-func (s *FileStore) GetAll(domain string) (map[string]string, error) {82+func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
77- envFile := s.getEnvFilePath(domain)83+ envFile, err := s.validateEnvFilePath(domainName)
84+ if err != nil {
85+ return nil, err
86+ }
78 87
79 file, err := os.Open(envFile)88 file, err := os.Open(envFile)
80 if err != nil {89 if err != nil {
@@ -109,14 +118,19 @@ func (s *FileStore) GetAll(domain string) (map[string]string, error) {
109 }118 }
110 119
111 // Set sets or updates multiple secrets for a domain, merging with existing.120 // Set sets or updates multiple secrets for a domain, merging with existing.
112-func (s *FileStore) Set(domain string, secrets map[string]string) error {121+func (s *FileStore) Set(domainName string, secrets map[string]string) error {
122+ // Validate domain first
123+ if _, err := s.validateEnvFilePath(domainName); err != nil {
124+ return err
125+ }
126+
113 // Ensure env directory exists127 // Ensure env directory exists
114 if err := os.MkdirAll(s.envDir, 0700); err != nil {128 if err := os.MkdirAll(s.envDir, 0700); err != nil {
115 return fmt.Errorf("failed to create env directory: %w", err)129 return fmt.Errorf("failed to create env directory: %w", err)
116 }130 }
117 131
118 // Read existing secrets132 // Read existing secrets
119- existing, err := s.GetAll(domain)133+ existing, err := s.GetAll(domainName)
120 if err != nil {134 if err != nil {
121 return err135 return err
122 }136 }
@@ -127,13 +141,18 @@ func (s *FileStore) Set(domain string, secrets map[string]string) error {
127 }141 }
128 142
129 // Write back atomically143 // Write back atomically
130- return s.writeSecretsAtomic(domain, existing)144+ return s.writeSecretsAtomic(domainName, existing)
131 }145 }
132 146
133 // Delete removes a specific secret key from a domain.147 // Delete removes a specific secret key from a domain.
134-func (s *FileStore) Delete(domain, key string) error {148+func (s *FileStore) Delete(domainName, key string) error {
149+ // Validate domain first
150+ if _, err := s.validateEnvFilePath(domainName); err != nil {
151+ return err
152+ }
153+
135 // Read existing secrets154 // Read existing secrets
136- existing, err := s.GetAll(domain)155+ existing, err := s.GetAll(domainName)
137 if err != nil {156 if err != nil {
138 return err157 return err
139 }158 }
@@ -142,13 +161,16 @@ func (s *FileStore) Delete(domain, key string) error {
142 delete(existing, key)161 delete(existing, key)
143 162
144 // Write back atomically163 // Write back atomically
145- return s.writeSecretsAtomic(domain, existing)164+ return s.writeSecretsAtomic(domainName, existing)
146 }165 }
147 166
148 // writeSecretsAtomic writes all secrets to the domain's env file atomically.167 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
149 // It writes to a temporary file first, syncs it, then renames to the final path.168 // It writes to a temporary file first, syncs it, then renames to the final path.
150-func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error {169+func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
151- envFile := s.getEnvFilePath(domain)170+ envFile, err := s.validateEnvFilePath(domainName)
171+ if err != nil {
172+ return err
173+ }
152 tmpFile := envFile + ".tmp"174 tmpFile := envFile + ".tmp"
153 175
154 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)176 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
@@ -157,7 +179,7 @@ func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string)
157 }179 }
158 180
159 // Write header comment181 // Write header comment
160- if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {182+ if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil {
161 file.Close()183 file.Close()
162 os.Remove(tmpFile)184 os.Remove(tmpFile)
163 return fmt.Errorf("failed to write header: %w", err)185 return fmt.Errorf("failed to write header: %w", err)
@@ -198,12 +220,64 @@ func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string)
198 return nil220 return nil
199 }221 }
200 222
223+// domainRegex validates domain names to prevent path injection.
224+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
225+var domainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
226+
201 // getEnvFilePath converts a domain to its env file path.227 // getEnvFilePath converts a domain to its env file path.
202 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.228 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
203-func (s *FileStore) getEnvFilePath(domain string) string {229+//
230+// SECURITY: Validates domain and ensures the resulting path stays within envDir.
231+func (s *FileStore) getEnvFilePath(domainName string) string {
232+ // SECURITY: Reject domains that look like path traversal attempts
233+ if strings.Contains(domainName, "..") {
234+ s.log.Warn().
235+ Str(zerowrap.FieldLayer, "adapter").
236+ Str(zerowrap.FieldAdapter, "domainsecrets").
237+ Str("domain", domainName).
238+ Msg("rejected domain with path traversal sequence")
239+ return ""
240+ }
241+
242+ // SECURITY: Validate domain format
243+ if !domainRegex.MatchString(domainName) {
244+ s.log.Warn().
245+ Str(zerowrap.FieldLayer, "adapter").
246+ Str(zerowrap.FieldAdapter, "domainsecrets").
247+ Str("domain", domainName).
248+ Msg("rejected invalid domain format")
249+ return ""
250+ }
251+
204 // Create domain-safe filename (replace dots and other chars with underscores)252 // Create domain-safe filename (replace dots and other chars with underscores)
205- safeDomain := strings.ReplaceAll(domain, ".", "_")253+ safeDomain := strings.ReplaceAll(domainName, ".", "_")
206 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")254 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
207 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")255 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
208- return filepath.Join(s.envDir, safeDomain+".env")256+
257+ fullPath := filepath.Join(s.envDir, safeDomain+".env")
258+
259+ // SECURITY: Final validation - ensure path stays within envDir
260+ cleanPath := filepath.Clean(fullPath)
261+ cleanEnvDir := filepath.Clean(s.envDir)
262+ if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
263+ s.log.Error().
264+ Str(zerowrap.FieldLayer, "adapter").
265+ Str(zerowrap.FieldAdapter, "domainsecrets").
266+ Str("domain", domainName).
267+ Str("attempted_path", fullPath).
268+ Msg("path traversal attempt blocked - path escapes env directory")
269+ return ""
270+ }
271+
272+ return fullPath
273+}
274+
275+// validateEnvFilePath validates that a domain produces a valid env file path.
276+// Returns an error if the domain is invalid or would result in path traversal.
277+func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
278+ path := s.getEnvFilePath(domainName)
279+ if path == "" {
280+ return "", domain.ErrPathTraversal
281+ }
282+ return path, nil
209 }283 }
A internal/adapters/out/domainsecrets/store_test.go +200 -0
@@ -0,0 +1,200 @@
1+package domainsecrets
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "gordon/internal/domain"
14+)
15+
16+func testLogger() zerowrap.Logger {
17+ return zerowrap.Default()
18+}
19+
20+func TestFileStore_PathTraversal(t *testing.T) {
21+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
22+ require.NoError(t, err)
23+ defer os.RemoveAll(tmpDir)
24+
25+ store, err := NewFileStore(tmpDir, testLogger())
26+ require.NoError(t, err)
27+
28+ tests := []struct {
29+ name string
30+ domain string
31+ wantErr bool
32+ }{
33+ // Valid domains
34+ {"simple domain", "example.com", false},
35+ {"subdomain", "sub.example.com", false},
36+ {"domain with port", "example.com:8080", false},
37+ {"domain with path", "example.com/path", false},
38+ {"hyphenated domain", "my-app.example.com", false},
39+ {"single char", "a", false},
40+
41+ // Path traversal attempts - should fail
42+ {"path traversal with dots", "../../../etc/passwd", true},
43+ {"path traversal in middle", "example.com/../../../etc/passwd", true},
44+ {"double dots only", "..", true},
45+ {"path traversal at end", "example.com/..", true},
46+
47+ // Invalid format - should fail
48+ {"empty domain", "", true},
49+ {"starts with dot", ".example.com", true},
50+ {"ends with dot", "example.com.", true},
51+ {"special chars semicolon", "example;rm -rf /", true},
52+ {"null byte", "example\x00.com", true},
53+ {"newline", "example\n.com", true},
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ // Test ListKeys
59+ _, err := store.ListKeys(tt.domain)
60+ if tt.wantErr {
61+ require.Error(t, err)
62+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
63+ } else {
64+ // No error, or file not found (which is fine)
65+ if err != nil {
66+ assert.False(t, errors.Is(err, domain.ErrPathTraversal), "unexpected ErrPathTraversal")
67+ }
68+ }
69+
70+ // Test Set
71+ err = store.Set(tt.domain, map[string]string{"KEY": "value"})
72+ if tt.wantErr {
73+ require.Error(t, err)
74+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
75+ } else {
76+ assert.NoError(t, err)
77+ }
78+
79+ // Test GetAll
80+ _, err = store.GetAll(tt.domain)
81+ if tt.wantErr {
82+ require.Error(t, err)
83+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
84+ }
85+
86+ // Test Delete
87+ err = store.Delete(tt.domain, "KEY")
88+ if tt.wantErr {
89+ require.Error(t, err)
90+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
91+ }
92+ })
93+ }
94+}
95+
96+func TestFileStore_PathContainment(t *testing.T) {
97+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
98+ require.NoError(t, err)
99+ defer os.RemoveAll(tmpDir)
100+
101+ store, err := NewFileStore(tmpDir, testLogger())
102+ require.NoError(t, err)
103+
104+ // Valid domain - file should be created inside tmpDir
105+ err = store.Set("example.com", map[string]string{"KEY": "value"})
106+ require.NoError(t, err)
107+
108+ // Verify file exists inside tmpDir
109+ expectedFile := filepath.Join(tmpDir, "example_com.env")
110+ _, err = os.Stat(expectedFile)
111+ assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
112+
113+ // Verify no file was created outside tmpDir
114+ entries, err := filepath.Glob(tmpDir + "/../*")
115+ require.NoError(t, err)
116+ for _, entry := range entries {
117+ if filepath.Base(entry) != filepath.Base(tmpDir) {
118+ // Check it's not our test file escaped
119+ assert.NotContains(t, entry, ".env", "unexpected .env file outside tmpDir: %s", entry)
120+ }
121+ }
122+}
123+
124+func TestFileStore_ValidDomainOperations(t *testing.T) {
125+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
126+ require.NoError(t, err)
127+ defer os.RemoveAll(tmpDir)
128+
129+ store, err := NewFileStore(tmpDir, testLogger())
130+ require.NoError(t, err)
131+
132+ domain := "test.example.com"
133+
134+ // Set secrets
135+ err = store.Set(domain, map[string]string{
136+ "DB_HOST": "localhost",
137+ "DB_PASSWORD": "secret123",
138+ })
139+ require.NoError(t, err)
140+
141+ // Get all secrets
142+ secrets, err := store.GetAll(domain)
143+ require.NoError(t, err)
144+ assert.Equal(t, "localhost", secrets["DB_HOST"])
145+ assert.Equal(t, "secret123", secrets["DB_PASSWORD"])
146+
147+ // List keys
148+ keys, err := store.ListKeys(domain)
149+ require.NoError(t, err)
150+ assert.Len(t, keys, 2)
151+ assert.Contains(t, keys, "DB_HOST")
152+ assert.Contains(t, keys, "DB_PASSWORD")
153+
154+ // Delete a key
155+ err = store.Delete(domain, "DB_PASSWORD")
156+ require.NoError(t, err)
157+
158+ // Verify deletion
159+ secrets, err = store.GetAll(domain)
160+ require.NoError(t, err)
161+ assert.Len(t, secrets, 1)
162+ assert.Equal(t, "localhost", secrets["DB_HOST"])
163+ _, exists := secrets["DB_PASSWORD"]
164+ assert.False(t, exists)
165+}
166+
167+func TestGetEnvFilePath(t *testing.T) {
168+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
169+ require.NoError(t, err)
170+ defer os.RemoveAll(tmpDir)
171+
172+ store, err := NewFileStore(tmpDir, testLogger())
173+ require.NoError(t, err)
174+
175+ tests := []struct {
176+ domain string
177+ wantEmpty bool
178+ wantFilename string
179+ }{
180+ {"example.com", false, "example_com.env"},
181+ {"sub.example.com", false, "sub_example_com.env"},
182+ {"example.com:8080", false, "example_com_8080.env"},
183+ {"example.com/path", false, "example_com_path.env"},
184+ {"../evil", true, ""},
185+ {"", true, ""},
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(tt.domain, func(t *testing.T) {
190+ path := store.getEnvFilePath(tt.domain)
191+
192+ if tt.wantEmpty {
193+ assert.Empty(t, path)
194+ } else {
195+ assert.NotEmpty(t, path)
196+ assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
197+ }
198+ })
199+ }
200+}
@@ -0,0 +1,200 @@
1+package domainsecrets
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "gordon/internal/domain"
14+)
15+
16+func testLogger() zerowrap.Logger {
17+ return zerowrap.Default()
18+}
19+
20+func TestFileStore_PathTraversal(t *testing.T) {
21+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
22+ require.NoError(t, err)
23+ defer os.RemoveAll(tmpDir)
24+
25+ store, err := NewFileStore(tmpDir, testLogger())
26+ require.NoError(t, err)
27+
28+ tests := []struct {
29+ name string
30+ domain string
31+ wantErr bool
32+ }{
33+ // Valid domains
34+ {"simple domain", "example.com", false},
35+ {"subdomain", "sub.example.com", false},
36+ {"domain with port", "example.com:8080", false},
37+ {"domain with path", "example.com/path", false},
38+ {"hyphenated domain", "my-app.example.com", false},
39+ {"single char", "a", false},
40+
41+ // Path traversal attempts - should fail
42+ {"path traversal with dots", "../../../etc/passwd", true},
43+ {"path traversal in middle", "example.com/../../../etc/passwd", true},
44+ {"double dots only", "..", true},
45+ {"path traversal at end", "example.com/..", true},
46+
47+ // Invalid format - should fail
48+ {"empty domain", "", true},
49+ {"starts with dot", ".example.com", true},
50+ {"ends with dot", "example.com.", true},
51+ {"special chars semicolon", "example;rm -rf /", true},
52+ {"null byte", "example\x00.com", true},
53+ {"newline", "example\n.com", true},
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ // Test ListKeys
59+ _, err := store.ListKeys(tt.domain)
60+ if tt.wantErr {
61+ require.Error(t, err)
62+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
63+ } else {
64+ // No error, or file not found (which is fine)
65+ if err != nil {
66+ assert.False(t, errors.Is(err, domain.ErrPathTraversal), "unexpected ErrPathTraversal")
67+ }
68+ }
69+
70+ // Test Set
71+ err = store.Set(tt.domain, map[string]string{"KEY": "value"})
72+ if tt.wantErr {
73+ require.Error(t, err)
74+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
75+ } else {
76+ assert.NoError(t, err)
77+ }
78+
79+ // Test GetAll
80+ _, err = store.GetAll(tt.domain)
81+ if tt.wantErr {
82+ require.Error(t, err)
83+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
84+ }
85+
86+ // Test Delete
87+ err = store.Delete(tt.domain, "KEY")
88+ if tt.wantErr {
89+ require.Error(t, err)
90+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
91+ }
92+ })
93+ }
94+}
95+
96+func TestFileStore_PathContainment(t *testing.T) {
97+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
98+ require.NoError(t, err)
99+ defer os.RemoveAll(tmpDir)
100+
101+ store, err := NewFileStore(tmpDir, testLogger())
102+ require.NoError(t, err)
103+
104+ // Valid domain - file should be created inside tmpDir
105+ err = store.Set("example.com", map[string]string{"KEY": "value"})
106+ require.NoError(t, err)
107+
108+ // Verify file exists inside tmpDir
109+ expectedFile := filepath.Join(tmpDir, "example_com.env")
110+ _, err = os.Stat(expectedFile)
111+ assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
112+
113+ // Verify no file was created outside tmpDir
114+ entries, err := filepath.Glob(tmpDir + "/../*")
115+ require.NoError(t, err)
116+ for _, entry := range entries {
117+ if filepath.Base(entry) != filepath.Base(tmpDir) {
118+ // Check it's not our test file escaped
119+ assert.NotContains(t, entry, ".env", "unexpected .env file outside tmpDir: %s", entry)
120+ }
121+ }
122+}
123+
124+func TestFileStore_ValidDomainOperations(t *testing.T) {
125+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
126+ require.NoError(t, err)
127+ defer os.RemoveAll(tmpDir)
128+
129+ store, err := NewFileStore(tmpDir, testLogger())
130+ require.NoError(t, err)
131+
132+ domain := "test.example.com"
133+
134+ // Set secrets
135+ err = store.Set(domain, map[string]string{
136+ "DB_HOST": "localhost",
137+ "DB_PASSWORD": "secret123",
138+ })
139+ require.NoError(t, err)
140+
141+ // Get all secrets
142+ secrets, err := store.GetAll(domain)
143+ require.NoError(t, err)
144+ assert.Equal(t, "localhost", secrets["DB_HOST"])
145+ assert.Equal(t, "secret123", secrets["DB_PASSWORD"])
146+
147+ // List keys
148+ keys, err := store.ListKeys(domain)
149+ require.NoError(t, err)
150+ assert.Len(t, keys, 2)
151+ assert.Contains(t, keys, "DB_HOST")
152+ assert.Contains(t, keys, "DB_PASSWORD")
153+
154+ // Delete a key
155+ err = store.Delete(domain, "DB_PASSWORD")
156+ require.NoError(t, err)
157+
158+ // Verify deletion
159+ secrets, err = store.GetAll(domain)
160+ require.NoError(t, err)
161+ assert.Len(t, secrets, 1)
162+ assert.Equal(t, "localhost", secrets["DB_HOST"])
163+ _, exists := secrets["DB_PASSWORD"]
164+ assert.False(t, exists)
165+}
166+
167+func TestGetEnvFilePath(t *testing.T) {
168+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
169+ require.NoError(t, err)
170+ defer os.RemoveAll(tmpDir)
171+
172+ store, err := NewFileStore(tmpDir, testLogger())
173+ require.NoError(t, err)
174+
175+ tests := []struct {
176+ domain string
177+ wantEmpty bool
178+ wantFilename string
179+ }{
180+ {"example.com", false, "example_com.env"},
181+ {"sub.example.com", false, "sub_example_com.env"},
182+ {"example.com:8080", false, "example_com_8080.env"},
183+ {"example.com/path", false, "example_com_path.env"},
184+ {"../evil", true, ""},
185+ {"", true, ""},
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(tt.domain, func(t *testing.T) {
190+ path := store.getEnvFilePath(tt.domain)
191+
192+ if tt.wantEmpty {
193+ assert.Empty(t, path)
194+ } else {
195+ assert.NotEmpty(t, path)
196+ assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
197+ }
198+ })
199+ }
200+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close