detached 20ba381

fix(admin): address security vulnerabilities in admin API

- Fix JSON injection in error responses using json.NewEncoder
- Add rate limiting (10 req/s, burst 20) to admin API middleware
- Add warning log when auth is disabled for admin API access
- Add request body size limit (1MB) for POST/PUT endpoints
- Replace internal error messages with generic ones to prevent leakage
- Add path traversal validation in secrets domain handling
- Implement atomic writes for secrets using temp file + rename
- Add semaphore (max 10) for concurrent health check probes
commit
20ba381439c7ae1f5851aab33be5844ca2dbf6c6
author
bnema · · 2026-01-16 22:39 UTC
parent
2e916c6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +125 -32
M go.mod +2 -2
@@ -45,7 +45,7 @@ require (
4545 github.com/pmezard/go-difflib v1.0.0 // indirect
4646 github.com/stretchr/objx v0.5.2 // indirect
4747 go.uber.org/mock v0.5.2 // direct
48- golang.org/x/time v0.12.0 // indirect
48+ golang.org/x/time v0.14.0
4949 )
5050
5151 require (
@@ -69,7 +69,7 @@ require (
6969 github.com/morikuni/aec v1.0.0 // indirect
7070 github.com/opencontainers/go-digest v1.0.0 // indirect
7171 github.com/opencontainers/image-spec v1.1.1 // indirect
72- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
72+ github.com/pelletier/go-toml/v2 v2.2.4
7373 github.com/pkg/errors v0.9.1 // indirect
7474 github.com/sagikazarmark/locafero v0.12.0 // indirect
7575 github.com/spf13/afero v1.15.0
@@ -45,7 +45,7 @@ require (
45 github.com/pmezard/go-difflib v1.0.0 // indirect45 github.com/pmezard/go-difflib v1.0.0 // indirect
46 github.com/stretchr/objx v0.5.2 // indirect46 github.com/stretchr/objx v0.5.2 // indirect
47 go.uber.org/mock v0.5.2 // direct47 go.uber.org/mock v0.5.2 // direct
48- golang.org/x/time v0.12.0 // indirect48+ golang.org/x/time v0.14.0
49 )49 )
50 50
51 require (51 require (
@@ -69,7 +69,7 @@ require (
69 github.com/morikuni/aec v1.0.0 // indirect69 github.com/morikuni/aec v1.0.0 // indirect
70 github.com/opencontainers/go-digest v1.0.0 // indirect70 github.com/opencontainers/go-digest v1.0.0 // indirect
71 github.com/opencontainers/image-spec v1.1.1 // indirect71 github.com/opencontainers/image-spec v1.1.1 // indirect
72- github.com/pelletier/go-toml/v2 v2.2.4 // indirect72+ github.com/pelletier/go-toml/v2 v2.2.4
73 github.com/pkg/errors v0.9.1 // indirect73 github.com/pkg/errors v0.9.1 // indirect
74 github.com/sagikazarmark/locafero v0.12.0 // indirect74 github.com/sagikazarmark/locafero v0.12.0 // indirect
75 github.com/spf13/afero v1.15.075 github.com/spf13/afero v1.15.0
M go.sum +2 -2
@@ -194,8 +194,8 @@ golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
194194 golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
195195 golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
196196 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
197-golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
198-golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
197+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
198+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
199199 google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
200200 google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
201201 google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
@@ -194,8 +194,8 @@ golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
194 golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=194 golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
195 golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=195 golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
196 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=196 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
197-golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=197+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
198-golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=198+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
199 google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=199 google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
200 google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=200 google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
201 google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=201 google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
M internal/adapters/in/http/admin/handler.go +20 -7
@@ -13,6 +13,9 @@ import (
1313 "gordon/internal/domain"
1414 )
1515
16+// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
17+const maxAdminRequestSize = 1 << 20 // 1MB
18+
1619 // Handler implements the HTTP handler for the admin API.
1720 type Handler struct {
1821 configSvc in.ConfigService
@@ -157,6 +160,9 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
157160 return
158161 }
159162
163+ // Limit request body size
164+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
165+
160166 var route domain.Route
161167 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
162168 log.Warn().Err(err).Msg("invalid route JSON")
@@ -171,7 +177,7 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
171177
172178 if err := h.configSvc.AddRoute(ctx, route); err != nil {
173179 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
174- h.sendError(w, http.StatusInternalServerError, err.Error())
180+ h.sendError(w, http.StatusInternalServerError, "failed to add route")
175181 return
176182 }
177183
@@ -194,6 +200,9 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
194200 return
195201 }
196202
203+ // Limit request body size
204+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
205+
197206 var route domain.Route
198207 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
199208 log.Warn().Err(err).Msg("invalid route JSON")
@@ -205,7 +214,7 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
205214
206215 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
207216 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
208- h.sendError(w, http.StatusInternalServerError, err.Error())
217+ h.sendError(w, http.StatusInternalServerError, "failed to update route")
209218 return
210219 }
211220
@@ -230,7 +239,7 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
230239
231240 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
232241 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
233- h.sendError(w, http.StatusInternalServerError, err.Error())
242+ h.sendError(w, http.StatusInternalServerError, "failed to remove route")
234243 return
235244 }
236245
@@ -267,7 +276,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
267276 secrets, err := h.listSecrets(secretDomain)
268277 if err != nil {
269278 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
270- h.sendError(w, http.StatusInternalServerError, err.Error())
279+ h.sendError(w, http.StatusInternalServerError, "failed to list secrets")
271280 return
272281 }
273282 h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": secrets})
@@ -278,6 +287,10 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
278287 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
279288 return
280289 }
290+
291+ // Limit request body size
292+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
293+
281294 // Set secret(s)
282295 var data map[string]string
283296 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
@@ -288,7 +301,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
288301
289302 if err := h.setSecrets(secretDomain, data); err != nil {
290303 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
291- h.sendError(w, http.StatusInternalServerError, err.Error())
304+ h.sendError(w, http.StatusInternalServerError, "failed to set secrets")
292305 return
293306 }
294307
@@ -308,7 +321,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
308321
309322 if err := h.deleteSecret(secretDomain, secretKey); err != nil {
310323 log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
311- h.sendError(w, http.StatusInternalServerError, err.Error())
324+ h.sendError(w, http.StatusInternalServerError, "failed to delete secret")
312325 return
313326 }
314327
@@ -414,7 +427,7 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
414427 // Reload configuration from file into memory
415428 if err := h.configSvc.Load(ctx); err != nil {
416429 log.Error().Err(err).Msg("failed to reload config")
417- h.sendError(w, http.StatusInternalServerError, err.Error())
430+ h.sendError(w, http.StatusInternalServerError, "failed to reload config")
418431 return
419432 }
420433
@@ -13,6 +13,9 @@ import (
13 "gordon/internal/domain"13 "gordon/internal/domain"
14 )14 )
15 15
16+// maxAdminRequestSize is the maximum allowed size for admin API request bodies.
17+const maxAdminRequestSize = 1 << 20 // 1MB
18+
16 // Handler implements the HTTP handler for the admin API.19 // Handler implements the HTTP handler for the admin API.
17 type Handler struct {20 type Handler struct {
18 configSvc in.ConfigService21 configSvc in.ConfigService
@@ -157,6 +160,9 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
157 return160 return
158 }161 }
159 162
163+ // Limit request body size
164+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
165+
160 var route domain.Route166 var route domain.Route
161 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {167 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
162 log.Warn().Err(err).Msg("invalid route JSON")168 log.Warn().Err(err).Msg("invalid route JSON")
@@ -171,7 +177,7 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
171 177
172 if err := h.configSvc.AddRoute(ctx, route); err != nil {178 if err := h.configSvc.AddRoute(ctx, route); err != nil {
173 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")179 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
174- h.sendError(w, http.StatusInternalServerError, err.Error())180+ h.sendError(w, http.StatusInternalServerError, "failed to add route")
175 return181 return
176 }182 }
177 183
@@ -194,6 +200,9 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
194 return200 return
195 }201 }
196 202
203+ // Limit request body size
204+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
205+
197 var route domain.Route206 var route domain.Route
198 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {207 if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
199 log.Warn().Err(err).Msg("invalid route JSON")208 log.Warn().Err(err).Msg("invalid route JSON")
@@ -205,7 +214,7 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
205 214
206 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {215 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
207 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")216 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
208- h.sendError(w, http.StatusInternalServerError, err.Error())217+ h.sendError(w, http.StatusInternalServerError, "failed to update route")
209 return218 return
210 }219 }
211 220
@@ -230,7 +239,7 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
230 239
231 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {240 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
232 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")241 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
233- h.sendError(w, http.StatusInternalServerError, err.Error())242+ h.sendError(w, http.StatusInternalServerError, "failed to remove route")
234 return243 return
235 }244 }
236 245
@@ -267,7 +276,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
267 secrets, err := h.listSecrets(secretDomain)276 secrets, err := h.listSecrets(secretDomain)
268 if err != nil {277 if err != nil {
269 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")278 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
270- h.sendError(w, http.StatusInternalServerError, err.Error())279+ h.sendError(w, http.StatusInternalServerError, "failed to list secrets")
271 return280 return
272 }281 }
273 h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": secrets})282 h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": secrets})
@@ -278,6 +287,10 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
278 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")287 h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
279 return288 return
280 }289 }
290+
291+ // Limit request body size
292+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
293+
281 // Set secret(s)294 // Set secret(s)
282 var data map[string]string295 var data map[string]string
283 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {296 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
@@ -288,7 +301,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
288 301
289 if err := h.setSecrets(secretDomain, data); err != nil {302 if err := h.setSecrets(secretDomain, data); err != nil {
290 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")303 log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
291- h.sendError(w, http.StatusInternalServerError, err.Error())304+ h.sendError(w, http.StatusInternalServerError, "failed to set secrets")
292 return305 return
293 }306 }
294 307
@@ -308,7 +321,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
308 321
309 if err := h.deleteSecret(secretDomain, secretKey); err != nil {322 if err := h.deleteSecret(secretDomain, secretKey); err != nil {
310 log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")323 log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
311- h.sendError(w, http.StatusInternalServerError, err.Error())324+ h.sendError(w, http.StatusInternalServerError, "failed to delete secret")
312 return325 return
313 }326 }
314 327
@@ -414,7 +427,7 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
414 // Reload configuration from file into memory427 // Reload configuration from file into memory
415 if err := h.configSvc.Load(ctx); err != nil {428 if err := h.configSvc.Load(ctx); err != nil {
416 log.Error().Err(err).Msg("failed to reload config")429 log.Error().Err(err).Msg("failed to reload config")
417- h.sendError(w, http.StatusInternalServerError, err.Error())430+ h.sendError(w, http.StatusInternalServerError, "failed to reload config")
418 return431 return
419 }432 }
420 433
M internal/adapters/in/http/admin/middleware.go +21 -4
@@ -2,10 +2,12 @@ package admin
22
33 import (
44 "context"
5+ "encoding/json"
56 "net/http"
67 "strings"
78
89 "github.com/bnema/zerowrap"
10+ "golang.org/x/time/rate"
911
1012 "gordon/internal/boundaries/in"
1113 "gordon/internal/domain"
@@ -22,14 +24,29 @@ const (
2224 )
2325
2426 // AuthMiddleware creates middleware that validates admin API authentication.
25-func AuthMiddleware(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {
27+// The limiter parameter is optional and can be nil to disable rate limiting.
28+func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.Logger) func(http.Handler) http.Handler {
2629 return func(next http.Handler) http.Handler {
2730 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2831 ctx := r.Context()
2932
33+ // Rate limit check (before any auth processing)
34+ if limiter != nil && !limiter.Allow() {
35+ w.Header().Set("Content-Type", "application/json")
36+ w.Header().Set("Retry-After", "1")
37+ w.WriteHeader(http.StatusTooManyRequests)
38+ _ = json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})
39+ return
40+ }
41+
3042 // Check if auth is enabled
3143 if !authSvc.IsEnabled() {
32- // Auth disabled, allow all requests
44+ // Auth disabled, log warning and allow all requests
45+ log.Warn().
46+ Str("path", r.URL.Path).
47+ Str("method", r.Method).
48+ Str("remote_addr", r.RemoteAddr).
49+ Msg("auth disabled - allowing unauthenticated admin API access")
3350 next.ServeHTTP(w, r)
3451 return
3552 }
@@ -125,11 +142,11 @@ func sendUnauthorized(w http.ResponseWriter, message string) {
125142 w.Header().Set("Content-Type", "application/json")
126143 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
127144 w.WriteHeader(http.StatusUnauthorized)
128- w.Write([]byte(`{"error":"` + message + `"}`))
145+ _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
129146 }
130147
131148 func sendForbidden(w http.ResponseWriter, message string) {
132149 w.Header().Set("Content-Type", "application/json")
133150 w.WriteHeader(http.StatusForbidden)
134- w.Write([]byte(`{"error":"` + message + `"}`))
151+ _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
135152 }
@@ -2,10 +2,12 @@ package admin
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "net/http"6 "net/http"
6 "strings"7 "strings"
7 8
8 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
10+ "golang.org/x/time/rate"
9 11
10 "gordon/internal/boundaries/in"12 "gordon/internal/boundaries/in"
11 "gordon/internal/domain"13 "gordon/internal/domain"
@@ -22,14 +24,29 @@ const (
22 )24 )
23 25
24 // AuthMiddleware creates middleware that validates admin API authentication.26 // AuthMiddleware creates middleware that validates admin API authentication.
25-func AuthMiddleware(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {27+// The limiter parameter is optional and can be nil to disable rate limiting.
28+func AuthMiddleware(authSvc in.AuthService, limiter *rate.Limiter, log zerowrap.Logger) func(http.Handler) http.Handler {
26 return func(next http.Handler) http.Handler {29 return func(next http.Handler) http.Handler {
27 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {30 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 ctx := r.Context()31 ctx := r.Context()
29 32
33+ // Rate limit check (before any auth processing)
34+ if limiter != nil && !limiter.Allow() {
35+ w.Header().Set("Content-Type", "application/json")
36+ w.Header().Set("Retry-After", "1")
37+ w.WriteHeader(http.StatusTooManyRequests)
38+ _ = json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"})
39+ return
40+ }
41+
30 // Check if auth is enabled42 // Check if auth is enabled
31 if !authSvc.IsEnabled() {43 if !authSvc.IsEnabled() {
32- // Auth disabled, allow all requests44+ // Auth disabled, log warning and allow all requests
45+ log.Warn().
46+ Str("path", r.URL.Path).
47+ Str("method", r.Method).
48+ Str("remote_addr", r.RemoteAddr).
49+ Msg("auth disabled - allowing unauthenticated admin API access")
33 next.ServeHTTP(w, r)50 next.ServeHTTP(w, r)
34 return51 return
35 }52 }
@@ -125,11 +142,11 @@ func sendUnauthorized(w http.ResponseWriter, message string) {
125 w.Header().Set("Content-Type", "application/json")142 w.Header().Set("Content-Type", "application/json")
126 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)143 w.Header().Set("WWW-Authenticate", `Bearer realm="gordon-admin"`)
127 w.WriteHeader(http.StatusUnauthorized)144 w.WriteHeader(http.StatusUnauthorized)
128- w.Write([]byte(`{"error":"` + message + `"}`))145+ _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
129 }146 }
130 147
131 func sendForbidden(w http.ResponseWriter, message string) {148 func sendForbidden(w http.ResponseWriter, message string) {
132 w.Header().Set("Content-Type", "application/json")149 w.Header().Set("Content-Type", "application/json")
133 w.WriteHeader(http.StatusForbidden)150 w.WriteHeader(http.StatusForbidden)
134- w.Write([]byte(`{"error":"` + message + `"}`))151+ _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
135 }152 }
M internal/adapters/in/http/admin/secrets.go +67 -15
@@ -11,17 +11,41 @@ import (
1111 // domainToEnvFile converts a domain to its env file path.
1212 // Example: app.mydomain.com -> app_mydomain_com.env
1313 // Must match the naming convention in envloader.FileLoader.getEnvFilePath
14-func (h *Handler) domainToEnvFile(domain string) string {
14+// Returns an error if the domain contains path traversal attempts or is invalid.
15+func (h *Handler) domainToEnvFile(domain string) (string, error) {
16+ // Reject path traversal attempts
17+ if strings.Contains(domain, "..") {
18+ return "", fmt.Errorf("invalid domain: path traversal not allowed")
19+ }
20+
21+ // Validate domain length (max DNS name length is 253)
22+ if len(domain) == 0 || len(domain) > 253 {
23+ return "", fmt.Errorf("invalid domain length")
24+ }
25+
1526 // Replace special characters with underscores (matches envloader)
1627 filename := strings.ReplaceAll(domain, ".", "_")
1728 filename = strings.ReplaceAll(filename, ":", "_")
1829 filename = strings.ReplaceAll(filename, "/", "_")
19- return filepath.Join(h.envDir, filename+".env")
30+
31+ fullPath := filepath.Join(h.envDir, filename+".env")
32+
33+ // Verify path stays within envDir after cleaning
34+ cleanPath := filepath.Clean(fullPath)
35+ cleanEnvDir := filepath.Clean(h.envDir)
36+ if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
37+ return "", fmt.Errorf("invalid domain: path escapes env directory")
38+ }
39+
40+ return cleanPath, nil
2041 }
2142
2243 // listSecrets returns the list of secret keys for a domain (not values).
2344 func (h *Handler) listSecrets(domain string) ([]string, error) {
24- envFile := h.domainToEnvFile(domain)
45+ envFile, err := h.domainToEnvFile(domain)
46+ if err != nil {
47+ return nil, err
48+ }
2549
2650 file, err := os.Open(envFile)
2751 if err != nil {
@@ -55,7 +79,10 @@ func (h *Handler) listSecrets(domain string) ([]string, error) {
5579
5680 // getSecrets returns all secrets for a domain as a map.
5781 func (h *Handler) getSecrets(domain string) (map[string]string, error) {
58- envFile := h.domainToEnvFile(domain)
82+ envFile, err := h.domainToEnvFile(domain)
83+ if err != nil {
84+ return nil, err
85+ }
5986
6087 file, err := os.Open(envFile)
6188 if err != nil {
@@ -126,35 +153,60 @@ func (h *Handler) deleteSecret(domain, key string) error {
126153 return h.writeSecrets(domain, existing)
127154 }
128155
129-// writeSecrets writes all secrets to the domain's env file.
130-func (h *Handler) writeSecrets(domain string, secrets map[string]string) (err error) {
131- envFile := h.domainToEnvFile(domain)
156+// writeSecrets writes all secrets to the domain's env file atomically.
157+// It writes to a temporary file first, syncs it, then renames to the final path.
158+func (h *Handler) writeSecrets(domain string, secrets map[string]string) error {
159+ envFile, err := h.domainToEnvFile(domain)
160+ if err != nil {
161+ return err
162+ }
132163
133- // Create/truncate file with secure permissions
134- file, err := os.OpenFile(envFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
164+ // Write to temp file for atomic operation
165+ tmpFile := envFile + ".tmp"
166+
167+ file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
135168 if err != nil {
136- return fmt.Errorf("failed to create env file: %w", err)
169+ return fmt.Errorf("failed to create temp env file: %w", err)
137170 }
138- defer func() {
139- if cerr := file.Close(); cerr != nil && err == nil {
140- err = fmt.Errorf("failed to close env file: %w", cerr)
141- }
142- }()
143171
144172 // Write header comment
145173 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
174+ file.Close()
175+ os.Remove(tmpFile)
146176 return fmt.Errorf("failed to write header: %w", err)
147177 }
148178 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
179+ file.Close()
180+ os.Remove(tmpFile)
149181 return fmt.Errorf("failed to write header: %w", err)
150182 }
151183
152184 // Write each secret
153185 for key, value := range secrets {
154186 if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
187+ file.Close()
188+ os.Remove(tmpFile)
155189 return fmt.Errorf("failed to write secret %s: %w", key, err)
156190 }
157191 }
158192
193+ // Sync to ensure data is on disk before rename
194+ if err := file.Sync(); err != nil {
195+ file.Close()
196+ os.Remove(tmpFile)
197+ return fmt.Errorf("failed to sync env file: %w", err)
198+ }
199+
200+ if err := file.Close(); err != nil {
201+ os.Remove(tmpFile)
202+ return fmt.Errorf("failed to close env file: %w", err)
203+ }
204+
205+ // Atomic rename
206+ if err := os.Rename(tmpFile, envFile); err != nil {
207+ os.Remove(tmpFile)
208+ return fmt.Errorf("failed to rename env file: %w", err)
209+ }
210+
159211 return nil
160212 }
@@ -11,17 +11,41 @@ import (
11 // domainToEnvFile converts a domain to its env file path.11 // domainToEnvFile converts a domain to its env file path.
12 // Example: app.mydomain.com -> app_mydomain_com.env12 // Example: app.mydomain.com -> app_mydomain_com.env
13 // Must match the naming convention in envloader.FileLoader.getEnvFilePath13 // Must match the naming convention in envloader.FileLoader.getEnvFilePath
14-func (h *Handler) domainToEnvFile(domain string) string {14+// Returns an error if the domain contains path traversal attempts or is invalid.
15+func (h *Handler) domainToEnvFile(domain string) (string, error) {
16+ // Reject path traversal attempts
17+ if strings.Contains(domain, "..") {
18+ return "", fmt.Errorf("invalid domain: path traversal not allowed")
19+ }
20+
21+ // Validate domain length (max DNS name length is 253)
22+ if len(domain) == 0 || len(domain) > 253 {
23+ return "", fmt.Errorf("invalid domain length")
24+ }
25+
15 // Replace special characters with underscores (matches envloader)26 // Replace special characters with underscores (matches envloader)
16 filename := strings.ReplaceAll(domain, ".", "_")27 filename := strings.ReplaceAll(domain, ".", "_")
17 filename = strings.ReplaceAll(filename, ":", "_")28 filename = strings.ReplaceAll(filename, ":", "_")
18 filename = strings.ReplaceAll(filename, "/", "_")29 filename = strings.ReplaceAll(filename, "/", "_")
19- return filepath.Join(h.envDir, filename+".env")30+
31+ fullPath := filepath.Join(h.envDir, filename+".env")
32+
33+ // Verify path stays within envDir after cleaning
34+ cleanPath := filepath.Clean(fullPath)
35+ cleanEnvDir := filepath.Clean(h.envDir)
36+ if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
37+ return "", fmt.Errorf("invalid domain: path escapes env directory")
38+ }
39+
40+ return cleanPath, nil
20 }41 }
21 42
22 // listSecrets returns the list of secret keys for a domain (not values).43 // listSecrets returns the list of secret keys for a domain (not values).
23 func (h *Handler) listSecrets(domain string) ([]string, error) {44 func (h *Handler) listSecrets(domain string) ([]string, error) {
24- envFile := h.domainToEnvFile(domain)45+ envFile, err := h.domainToEnvFile(domain)
46+ if err != nil {
47+ return nil, err
48+ }
25 49
26 file, err := os.Open(envFile)50 file, err := os.Open(envFile)
27 if err != nil {51 if err != nil {
@@ -55,7 +79,10 @@ func (h *Handler) listSecrets(domain string) ([]string, error) {
55 79
56 // getSecrets returns all secrets for a domain as a map.80 // getSecrets returns all secrets for a domain as a map.
57 func (h *Handler) getSecrets(domain string) (map[string]string, error) {81 func (h *Handler) getSecrets(domain string) (map[string]string, error) {
58- envFile := h.domainToEnvFile(domain)82+ envFile, err := h.domainToEnvFile(domain)
83+ if err != nil {
84+ return nil, err
85+ }
59 86
60 file, err := os.Open(envFile)87 file, err := os.Open(envFile)
61 if err != nil {88 if err != nil {
@@ -126,35 +153,60 @@ func (h *Handler) deleteSecret(domain, key string) error {
126 return h.writeSecrets(domain, existing)153 return h.writeSecrets(domain, existing)
127 }154 }
128 155
129-// writeSecrets writes all secrets to the domain's env file.156+// writeSecrets writes all secrets to the domain's env file atomically.
130-func (h *Handler) writeSecrets(domain string, secrets map[string]string) (err error) {157+// It writes to a temporary file first, syncs it, then renames to the final path.
131- envFile := h.domainToEnvFile(domain)158+func (h *Handler) writeSecrets(domain string, secrets map[string]string) error {
159+ envFile, err := h.domainToEnvFile(domain)
160+ if err != nil {
161+ return err
162+ }
132 163
133- // Create/truncate file with secure permissions164+ // Write to temp file for atomic operation
134- file, err := os.OpenFile(envFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)165+ tmpFile := envFile + ".tmp"
166+
167+ file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
135 if err != nil {168 if err != nil {
136- return fmt.Errorf("failed to create env file: %w", err)169+ return fmt.Errorf("failed to create temp env file: %w", err)
137 }170 }
138- defer func() {
139- if cerr := file.Close(); cerr != nil && err == nil {
140- err = fmt.Errorf("failed to close env file: %w", cerr)
141- }
142- }()
143 171
144 // Write header comment172 // Write header comment
145 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {173 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
174+ file.Close()
175+ os.Remove(tmpFile)
146 return fmt.Errorf("failed to write header: %w", err)176 return fmt.Errorf("failed to write header: %w", err)
147 }177 }
148 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {178 if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
179+ file.Close()
180+ os.Remove(tmpFile)
149 return fmt.Errorf("failed to write header: %w", err)181 return fmt.Errorf("failed to write header: %w", err)
150 }182 }
151 183
152 // Write each secret184 // Write each secret
153 for key, value := range secrets {185 for key, value := range secrets {
154 if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {186 if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
187+ file.Close()
188+ os.Remove(tmpFile)
155 return fmt.Errorf("failed to write secret %s: %w", key, err)189 return fmt.Errorf("failed to write secret %s: %w", key, err)
156 }190 }
157 }191 }
158 192
193+ // Sync to ensure data is on disk before rename
194+ if err := file.Sync(); err != nil {
195+ file.Close()
196+ os.Remove(tmpFile)
197+ return fmt.Errorf("failed to sync env file: %w", err)
198+ }
199+
200+ if err := file.Close(); err != nil {
201+ os.Remove(tmpFile)
202+ return fmt.Errorf("failed to close env file: %w", err)
203+ }
204+
205+ // Atomic rename
206+ if err := os.Rename(tmpFile, envFile); err != nil {
207+ os.Remove(tmpFile)
208+ return fmt.Errorf("failed to rename env file: %w", err)
209+ }
210+
159 return nil211 return nil
160 }212 }
M internal/app/run.go +5 -1
@@ -29,6 +29,8 @@ import (
2929 "gordon/internal/adapters/out/secrets"
3030 "gordon/internal/adapters/out/tokenstore"
3131
32+ "golang.org/x/time/rate"
33+
3234 // Adapters - Input
3335 "gordon/internal/adapters/in/http/admin"
3436 "gordon/internal/adapters/in/http/middleware"
@@ -800,7 +802,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
800802 }
801803 // Add admin auth middleware if auth is enabled
802804 if svc.authSvc != nil {
803- adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, log))
805+ // Rate limiter for admin API: 10 req/s with burst of 20
806+ adminLimiter := rate.NewLimiter(rate.Limit(10), 20)
807+ adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
804808 }
805809 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
806810 registryMux.Handle("/admin/", adminWithMiddleware)
@@ -29,6 +29,8 @@ import (
29 "gordon/internal/adapters/out/secrets"29 "gordon/internal/adapters/out/secrets"
30 "gordon/internal/adapters/out/tokenstore"30 "gordon/internal/adapters/out/tokenstore"
31 31
32+ "golang.org/x/time/rate"
33+
32 // Adapters - Input34 // Adapters - Input
33 "gordon/internal/adapters/in/http/admin"35 "gordon/internal/adapters/in/http/admin"
34 "gordon/internal/adapters/in/http/middleware"36 "gordon/internal/adapters/in/http/middleware"
@@ -800,7 +802,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
800 }802 }
801 // Add admin auth middleware if auth is enabled803 // Add admin auth middleware if auth is enabled
802 if svc.authSvc != nil {804 if svc.authSvc != nil {
803- adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, log))805+ // Rate limiter for admin API: 10 req/s with burst of 20
806+ adminLimiter := rate.NewLimiter(rate.Limit(10), 20)
807+ adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
804 }808 }
805 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)809 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
806 registryMux.Handle("/admin/", adminWithMiddleware)810 registryMux.Handle("/admin/", adminWithMiddleware)
M internal/usecase/health/service.go +8 -1
@@ -12,6 +12,9 @@ import (
1212 "gordon/internal/domain"
1313 )
1414
15+// maxConcurrentProbes limits the number of concurrent health probes to prevent resource exhaustion.
16+const maxConcurrentProbes = 10
17+
1518 // Service implements the HealthService interface.
1619 type Service struct {
1720 configSvc in.ConfigService
@@ -105,14 +108,18 @@ func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHe
105108 return results
106109 }
107110
108- // Check routes concurrently
111+ // Check routes concurrently with semaphore to limit resource usage
109112 var mu sync.Mutex
110113 var wg sync.WaitGroup
114+ sem := make(chan struct{}, maxConcurrentProbes)
111115
112116 for _, route := range routes {
113117 wg.Add(1)
114118 go func(r domain.Route) {
115119 defer wg.Done()
120+ sem <- struct{}{} // Acquire semaphore
121+ defer func() { <-sem }() // Release semaphore
122+
116123 health := s.CheckRoute(ctx, r)
117124 mu.Lock()
118125 results[r.Domain] = health
@@ -12,6 +12,9 @@ import (
12 "gordon/internal/domain"12 "gordon/internal/domain"
13 )13 )
14 14
15+// maxConcurrentProbes limits the number of concurrent health probes to prevent resource exhaustion.
16+const maxConcurrentProbes = 10
17+
15 // Service implements the HealthService interface.18 // Service implements the HealthService interface.
16 type Service struct {19 type Service struct {
17 configSvc in.ConfigService20 configSvc in.ConfigService
@@ -105,14 +108,18 @@ func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHe
105 return results108 return results
106 }109 }
107 110
108- // Check routes concurrently111+ // Check routes concurrently with semaphore to limit resource usage
109 var mu sync.Mutex112 var mu sync.Mutex
110 var wg sync.WaitGroup113 var wg sync.WaitGroup
114+ sem := make(chan struct{}, maxConcurrentProbes)
111 115
112 for _, route := range routes {116 for _, route := range routes {
113 wg.Add(1)117 wg.Add(1)
114 go func(r domain.Route) {118 go func(r domain.Route) {
115 defer wg.Done()119 defer wg.Done()
120+ sem <- struct{}{} // Acquire semaphore
121+ defer func() { <-sem }() // Release semaphore
122+
116 health := s.CheckRoute(ctx, r)123 health := s.CheckRoute(ctx, r)
117 mu.Lock()124 mu.Lock()
118 results[r.Domain] = health125 results[r.Domain] = health

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