detached b87733b

Tighten env file checks and dedupe admin headers

commit
b87733b0c32cd3df2177a45669d7cacfd25d1acd
author
brice · · 2026-03-05 21:54 UTC
parent
b467aa8
signature
unsigned
3 files · +51 -14
M internal/adapters/in/http/admin/middleware.go +14 -13
@@ -32,9 +32,7 @@ func AuthMiddleware(
3232
3333 // Check global rate limit
3434 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
35- w.Header().Set("Content-Type", "application/json")
36- w.Header().Set("Cache-Control", "no-store")
37- w.Header().Set("Pragma", "no-cache")
35+ setAdminJSONHeaders(w)
3836 w.Header().Set("Retry-After", "1")
3937 w.WriteHeader(http.StatusTooManyRequests)
4038 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -45,9 +43,7 @@ func AuthMiddleware(
4543 if ipLimiter != nil {
4644 ip := middleware.GetClientIP(r, trustedNets)
4745 if !ipLimiter.Allow(ctx, "ip:"+ip) {
48- w.Header().Set("Content-Type", "application/json")
49- w.Header().Set("Cache-Control", "no-store")
50- w.Header().Set("Pragma", "no-cache")
46+ setAdminJSONHeaders(w)
5147 w.Header().Set("Retry-After", "1")
5248 w.WriteHeader(http.StatusTooManyRequests)
5349 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -112,8 +108,7 @@ func AuthMiddleware(
112108
113109 // Prevent intermediaries from storing admin API responses, especially
114110 // when a rotated bearer token is returned in X-Gordon-Token.
115- w.Header().Set("Cache-Control", "no-store")
116- w.Header().Set("Pragma", "no-cache")
111+ setNoStoreHeaders(w)
117112
118113 // Attempt to slide token expiry. Non-fatal if it fails.
119114 // The new token is returned in X-Gordon-Token so the CLI can persist it atomically.
@@ -171,18 +166,24 @@ func HasAccess(ctx context.Context, resource, action string) bool {
171166 }
172167
173168 func sendUnauthorized(w http.ResponseWriter, message string) {
174- w.Header().Set("Content-Type", "application/json")
175- w.Header().Set("Cache-Control", "no-store")
176- w.Header().Set("Pragma", "no-cache")
169+ setAdminJSONHeaders(w)
177170 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
178171 w.WriteHeader(http.StatusUnauthorized)
179172 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
180173 }
181174
182175 func sendForbidden(w http.ResponseWriter, message string) {
176+ setAdminJSONHeaders(w)
177+ w.WriteHeader(http.StatusForbidden)
178+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
179+}
180+
181+func setAdminJSONHeaders(w http.ResponseWriter) {
183182 w.Header().Set("Content-Type", "application/json")
183+ setNoStoreHeaders(w)
184+}
185+
186+func setNoStoreHeaders(w http.ResponseWriter) {
184187 w.Header().Set("Cache-Control", "no-store")
185188 w.Header().Set("Pragma", "no-cache")
186- w.WriteHeader(http.StatusForbidden)
187- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
188189 }
@@ -32,9 +32,7 @@ func AuthMiddleware(
32 32
33 // Check global rate limit33 // Check global rate limit
34 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {34 if globalLimiter != nil && !globalLimiter.Allow(ctx, "global") {
35- w.Header().Set("Content-Type", "application/json")35+ setAdminJSONHeaders(w)
36- w.Header().Set("Cache-Control", "no-store")
37- w.Header().Set("Pragma", "no-cache")
38 w.Header().Set("Retry-After", "1")36 w.Header().Set("Retry-After", "1")
39 w.WriteHeader(http.StatusTooManyRequests)37 w.WriteHeader(http.StatusTooManyRequests)
40 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})38 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -45,9 +43,7 @@ func AuthMiddleware(
45 if ipLimiter != nil {43 if ipLimiter != nil {
46 ip := middleware.GetClientIP(r, trustedNets)44 ip := middleware.GetClientIP(r, trustedNets)
47 if !ipLimiter.Allow(ctx, "ip:"+ip) {45 if !ipLimiter.Allow(ctx, "ip:"+ip) {
48- w.Header().Set("Content-Type", "application/json")46+ setAdminJSONHeaders(w)
49- w.Header().Set("Cache-Control", "no-store")
50- w.Header().Set("Pragma", "no-cache")
51 w.Header().Set("Retry-After", "1")47 w.Header().Set("Retry-After", "1")
52 w.WriteHeader(http.StatusTooManyRequests)48 w.WriteHeader(http.StatusTooManyRequests)
53 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})49 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"})
@@ -112,8 +108,7 @@ func AuthMiddleware(
112 108
113 // Prevent intermediaries from storing admin API responses, especially109 // Prevent intermediaries from storing admin API responses, especially
114 // when a rotated bearer token is returned in X-Gordon-Token.110 // when a rotated bearer token is returned in X-Gordon-Token.
115- w.Header().Set("Cache-Control", "no-store")111+ setNoStoreHeaders(w)
116- w.Header().Set("Pragma", "no-cache")
117 112
118 // Attempt to slide token expiry. Non-fatal if it fails.113 // Attempt to slide token expiry. Non-fatal if it fails.
119 // 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.
@@ -171,18 +166,24 @@ func HasAccess(ctx context.Context, resource, action string) bool {
171 }166 }
172 167
173 func sendUnauthorized(w http.ResponseWriter, message string) {168 func sendUnauthorized(w http.ResponseWriter, message string) {
174- w.Header().Set("Content-Type", "application/json")169+ setAdminJSONHeaders(w)
175- w.Header().Set("Cache-Control", "no-store")
176- w.Header().Set("Pragma", "no-cache")
177 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)170 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
178 w.WriteHeader(http.StatusUnauthorized)171 w.WriteHeader(http.StatusUnauthorized)
179 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})172 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
180 }173 }
181 174
182 func sendForbidden(w http.ResponseWriter, message string) {175 func sendForbidden(w http.ResponseWriter, message string) {
176+ setAdminJSONHeaders(w)
177+ w.WriteHeader(http.StatusForbidden)
178+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
179+}
180+
181+func setAdminJSONHeaders(w http.ResponseWriter) {
183 w.Header().Set("Content-Type", "application/json")182 w.Header().Set("Content-Type", "application/json")
183+ setNoStoreHeaders(w)
184+}
185+
186+func setNoStoreHeaders(w http.ResponseWriter) {
184 w.Header().Set("Cache-Control", "no-store")187 w.Header().Set("Cache-Control", "no-store")
185 w.Header().Set("Pragma", "no-cache")188 w.Header().Set("Pragma", "no-cache")
186- w.WriteHeader(http.StatusForbidden)
187- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message})
188 }189 }
M internal/adapters/out/envloader/file.go +8 -1
@@ -183,7 +183,14 @@ func (l *FileLoader) EnvFileExists(domain string) (bool, error) {
183183 }
184184
185185 _, err = os.Stat(envFile)
186- return err == nil, nil
186+ if os.IsNotExist(err) {
187+ return false, nil
188+ }
189+ if err != nil {
190+ return false, err
191+ }
192+
193+ return true, nil
187194 }
188195
189196 // resolveSecrets resolves any secret references in the value.
@@ -183,7 +183,14 @@ func (l *FileLoader) EnvFileExists(domain string) (bool, error) {
183 }183 }
184 184
185 _, err = os.Stat(envFile)185 _, err = os.Stat(envFile)
186- return err == nil, nil186+ if os.IsNotExist(err) {
187+ return false, nil
188+ }
189+ if err != nil {
190+ return false, err
191+ }
192+
193+ return true, nil
187 }194 }
188 195
189 // resolveSecrets resolves any secret references in the value.196 // resolveSecrets resolves any secret references in the value.
M internal/adapters/out/envloader/file_test.go +29 -0
@@ -257,3 +257,32 @@ func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) {
257257 _, err = loader.getEnvFilePath("../etc/passwd")
258258 require.ErrorIs(t, err, domain.ErrPathTraversal)
259259 }
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+}
@@ -257,3 +257,32 @@ func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) {
257 _, err = loader.getEnvFilePath("../etc/passwd")257 _, err = loader.getEnvFilePath("../etc/passwd")
258 require.ErrorIs(t, err, domain.ErrPathTraversal)258 require.ErrorIs(t, err, domain.ErrPathTraversal)
259 }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+}

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