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( | |||
| 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") | |
| 36 | - | w.Header().Set("Cache-Control", "no-store") | |
| 37 | - | w.Header().Set("Pragma", "no-cache") | |
| 35 | + | setAdminJSONHeaders(w) | |
| 38 | 36 | w.Header().Set("Retry-After", "1") | |
| 39 | 37 | w.WriteHeader(http.StatusTooManyRequests) | |
| 40 | 38 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | |
| @@ -45,9 +43,7 @@ func AuthMiddleware( | |||
| 45 | 43 | if ipLimiter != nil { | |
| 46 | 44 | ip := middleware.GetClientIP(r, trustedNets) | |
| 47 | 45 | 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) | |
| 51 | 47 | w.Header().Set("Retry-After", "1") | |
| 52 | 48 | w.WriteHeader(http.StatusTooManyRequests) | |
| 53 | 49 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "rate limit exceeded"}) | |
| @@ -112,8 +108,7 @@ func AuthMiddleware( | |||
| 112 | 108 | ||
| 113 | 109 | // Prevent intermediaries from storing admin API responses, especially | |
| 114 | 110 | // 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) | |
| 117 | 112 | ||
| 118 | 113 | // Attempt to slide token expiry. Non-fatal if it fails. | |
| 119 | 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 | 168 | 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) | |
| 177 | 170 | w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`) | |
| 178 | 171 | w.WriteHeader(http.StatusUnauthorized) | |
| 179 | 172 | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message}) | |
| 180 | 173 | } | |
| 181 | 174 | ||
| 182 | 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 | 182 | w.Header().Set("Content-Type", "application/json") | |
| 183 | + | setNoStoreHeaders(w) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func setNoStoreHeaders(w http.ResponseWriter) { | |
| 184 | 187 | w.Header().Set("Cache-Control", "no-store") | |
| 185 | 188 | w.Header().Set("Pragma", "no-cache") | |
| 186 | - | w.WriteHeader(http.StatusForbidden) | |
| 187 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: message}) | |
| 188 | 189 | } | |
| @@ -32,9 +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("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, especially | 109 | // 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) { | |||
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | 185 | _, 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 | |
| 187 | 194 | } | |
| 188 | 195 | ||
| 189 | 196 | // 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, 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 | |||
| 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) { | |||
| 257 | 257 | _, err = loader.getEnvFilePath("../etc/passwd") | |
| 258 | 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 | + | } | |
| @@ -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 | + | } | |||