detached 28746f8

fix(security): harden auth, container limits, input validation, and proxy

- Remove hardcoded JWT secret fallback in CLI
- Enforce minimum 32-byte JWT signing secret
- Always-on rate limiting for auth endpoints
- Reject deprecated plaintext password config
- Add default container resource limits (memory, CPU, PIDs)
- Add MaxBytesReader to /auth/password endpoint
- Limit config blob size to prevent OOM
- Reject newlines in secret values to prevent env injection
- Enforce preview TTL upper bound (30 days)
- Validate domain path parameters against traversal/injection
- Remove X-Proxied-By information disclosure header
- Redirect HTTP to HTTPS when TLS is enabled
commit
28746f82ecd8270c204431e3faaab769be84e7b1
author
brice · · 2026-03-22 05:24 UTC
committer
brice · · 2026-03-22 17:35 UTC
parent
3fb38f6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +244 -58
M internal/adapters/in/cli/auth.go +1 -2
@@ -748,8 +748,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
748748 cfg.TokenSecret = []byte(tokenSecretPath)
749749 }
750750 } else {
751- // Generate a default secret for CLI token generation
752- cfg.TokenSecret = []byte("gordon-cli-default-secret")
751+ return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret")
753752 }
754753
755754 return cfg, nil
@@ -748,8 +748,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
748 cfg.TokenSecret = []byte(tokenSecretPath)748 cfg.TokenSecret = []byte(tokenSecretPath)
749 }749 }
750 } else {750 } else {
751- // Generate a default secret for CLI token generation751+ return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret")
752- cfg.TokenSecret = []byte("gordon-cli-default-secret")
753 }752 }
754 753
755 return cfg, nil754 return cfg, nil
M internal/adapters/in/http/admin/handler.go +12 -1
@@ -1264,6 +1264,10 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
12641264 h.sendError(w, http.StatusBadRequest, "domain required in path")
12651265 return
12661266 }
1267+ if err := validation.ValidateDomainParam(deployDomain); err != nil {
1268+ h.sendError(w, http.StatusBadRequest, "invalid domain")
1269+ return
1270+ }
12671271
12681272 // Get the route for this domain
12691273 route, err := h.configSvc.GetRoute(ctx, deployDomain)
@@ -1317,7 +1321,7 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str
13171321 h.sendError(w, http.StatusBadRequest, "domain required in path")
13181322 return
13191323 }
1320- if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
1324+ if err := validation.ValidateDomainParam(restartDomain); err != nil {
13211325 h.sendError(w, http.StatusBadRequest, "invalid domain")
13221326 return
13231327 }
@@ -1433,6 +1437,13 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
14331437 logDomain = ""
14341438 }
14351439
1440+ if logDomain != "" {
1441+ if err := validation.ValidateDomainParam(logDomain); err != nil {
1442+ h.sendError(w, http.StatusBadRequest, "invalid domain")
1443+ return
1444+ }
1445+ }
1446+
14361447 if logDomain == "" {
14371448 // Gordon process logs
14381449 h.handleProcessLogs(w, r, lines, follow)
@@ -1264,6 +1264,10 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
1264 h.sendError(w, http.StatusBadRequest, "domain required in path")1264 h.sendError(w, http.StatusBadRequest, "domain required in path")
1265 return1265 return
1266 }1266 }
1267+ if err := validation.ValidateDomainParam(deployDomain); err != nil {
1268+ h.sendError(w, http.StatusBadRequest, "invalid domain")
1269+ return
1270+ }
1267 1271
1268 // Get the route for this domain1272 // Get the route for this domain
1269 route, err := h.configSvc.GetRoute(ctx, deployDomain)1273 route, err := h.configSvc.GetRoute(ctx, deployDomain)
@@ -1317,7 +1321,7 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str
1317 h.sendError(w, http.StatusBadRequest, "domain required in path")1321 h.sendError(w, http.StatusBadRequest, "domain required in path")
1318 return1322 return
1319 }1323 }
1320- if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {1324+ if err := validation.ValidateDomainParam(restartDomain); err != nil {
1321 h.sendError(w, http.StatusBadRequest, "invalid domain")1325 h.sendError(w, http.StatusBadRequest, "invalid domain")
1322 return1326 return
1323 }1327 }
@@ -1433,6 +1437,13 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
1433 logDomain = ""1437 logDomain = ""
1434 }1438 }
1435 1439
1440+ if logDomain != "" {
1441+ if err := validation.ValidateDomainParam(logDomain); err != nil {
1442+ h.sendError(w, http.StatusBadRequest, "invalid domain")
1443+ return
1444+ }
1445+ }
1446+
1436 if logDomain == "" {1447 if logDomain == "" {
1437 // Gordon process logs1448 // Gordon process logs
1438 h.handleProcessLogs(w, r, lines, follow)1449 h.handleProcessLogs(w, r, lines, follow)
M internal/adapters/in/http/admin/preview_handler.go +55 -37
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/zerowrap"
1212
1313 "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/pkg/validation"
1415 )
1516
1617 // previewService is a minimal interface for preview management used by the admin handler.
@@ -78,18 +79,23 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
7879 return
7980 }
8081
82+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
83+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
84+ return
85+ }
86+
8187 name := strings.TrimPrefix(path, "/preview/")
8288 if name == "" || name == "/preview" {
8389 h.sendError(w, http.StatusBadRequest, "preview name required in path")
8490 return
8591 }
92+ if err := validation.ValidateDomainParam(name); err != nil {
93+ h.sendError(w, http.StatusBadRequest, "invalid domain")
94+ return
95+ }
8696
8797 switch r.Method {
8898 case http.MethodDelete:
89- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
90- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
91- return
92- }
9399
94100 if err := h.previewSvc.Delete(ctx, name); err != nil {
95101 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")
@@ -105,45 +111,57 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
105111 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
106112
107113 case http.MethodPatch:
108- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
109- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
110- return
111- }
114+ h.handlePreviewExtend(w, r, name)
112115
113- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
116+ default:
117+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
118+ }
119+}
114120
115- var req previewExtendRequest
116- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
117- log.Warn().Err(err).Msg("invalid preview extend JSON")
118- h.sendError(w, http.StatusBadRequest, "invalid JSON")
119- return
120- }
121+func (h *Handler) handlePreviewExtend(w http.ResponseWriter, r *http.Request, name string) {
122+ ctx := r.Context()
123+ log := zerowrap.FromCtx(ctx)
121124
122- if req.TTL == "" {
123- h.sendError(w, http.StatusBadRequest, "ttl is required")
124- return
125- }
125+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
126126
127- ttl, err := time.ParseDuration(req.TTL)
128- if err != nil {
129- h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")
130- return
131- }
127+ var req previewExtendRequest
128+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
129+ log.Warn().Err(err).Msg("invalid preview extend JSON")
130+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
131+ return
132+ }
132133
133- if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
134- log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
135- if errors.Is(err, domain.ErrPreviewNotFound) {
136- h.sendError(w, http.StatusNotFound, "preview not found")
137- } else {
138- h.sendError(w, http.StatusInternalServerError, "failed to extend preview")
139- }
140- return
141- }
134+ if req.TTL == "" {
135+ h.sendError(w, http.StatusBadRequest, "ttl is required")
136+ return
137+ }
138+
139+ ttl, err := time.ParseDuration(req.TTL)
140+ if err != nil {
141+ h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")
142+ return
143+ }
142144
143- log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")
144- h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})
145+ if ttl <= 0 {
146+ h.sendError(w, http.StatusBadRequest, "TTL must be positive")
147+ return
148+ }
149+ const maxPreviewTTL = 30 * 24 * time.Hour // 30 days
150+ if ttl > maxPreviewTTL {
151+ h.sendError(w, http.StatusBadRequest, "TTL exceeds maximum of 30 days")
152+ return
153+ }
145154
146- default:
147- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
155+ if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
156+ log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
157+ if errors.Is(err, domain.ErrPreviewNotFound) {
158+ h.sendError(w, http.StatusNotFound, "preview not found")
159+ } else {
160+ h.sendError(w, http.StatusInternalServerError, "failed to extend preview")
161+ }
162+ return
148163 }
164+
165+ log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")
166+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})
149167 }
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12 12
13 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/pkg/validation"
14 )15 )
15 16
16 // previewService is a minimal interface for preview management used by the admin handler.17 // previewService is a minimal interface for preview management used by the admin handler.
@@ -78,18 +79,23 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
78 return79 return
79 }80 }
80 81
82+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
83+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
84+ return
85+ }
86+
81 name := strings.TrimPrefix(path, "/preview/")87 name := strings.TrimPrefix(path, "/preview/")
82 if name == "" || name == "/preview" {88 if name == "" || name == "/preview" {
83 h.sendError(w, http.StatusBadRequest, "preview name required in path")89 h.sendError(w, http.StatusBadRequest, "preview name required in path")
84 return90 return
85 }91 }
92+ if err := validation.ValidateDomainParam(name); err != nil {
93+ h.sendError(w, http.StatusBadRequest, "invalid domain")
94+ return
95+ }
86 96
87 switch r.Method {97 switch r.Method {
88 case http.MethodDelete:98 case http.MethodDelete:
89- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
90- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
91- return
92- }
93 99
94 if err := h.previewSvc.Delete(ctx, name); err != nil {100 if err := h.previewSvc.Delete(ctx, name); err != nil {
95 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")101 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")
@@ -105,45 +111,57 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
105 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})111 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
106 112
107 case http.MethodPatch:113 case http.MethodPatch:
108- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {114+ h.handlePreviewExtend(w, r, name)
109- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
110- return
111- }
112 115
113- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)116+ default:
117+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
118+ }
119+}
114 120
115- var req previewExtendRequest121+func (h *Handler) handlePreviewExtend(w http.ResponseWriter, r *http.Request, name string) {
116- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {122+ ctx := r.Context()
117- log.Warn().Err(err).Msg("invalid preview extend JSON")123+ log := zerowrap.FromCtx(ctx)
118- h.sendError(w, http.StatusBadRequest, "invalid JSON")
119- return
120- }
121 124
122- if req.TTL == "" {125+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
123- h.sendError(w, http.StatusBadRequest, "ttl is required")
124- return
125- }
126 126
127- ttl, err := time.ParseDuration(req.TTL)127+ var req previewExtendRequest
128- if err != nil {128+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
129- h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")129+ log.Warn().Err(err).Msg("invalid preview extend JSON")
130- return130+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
131- }131+ return
132+ }
132 133
133- if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {134+ if req.TTL == "" {
134- log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")135+ h.sendError(w, http.StatusBadRequest, "ttl is required")
135- if errors.Is(err, domain.ErrPreviewNotFound) {136+ return
136- h.sendError(w, http.StatusNotFound, "preview not found")137+ }
137- } else {138+
138- h.sendError(w, http.StatusInternalServerError, "failed to extend preview")139+ ttl, err := time.ParseDuration(req.TTL)
139- }140+ if err != nil {
140- return141+ h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")
141- }142+ return
143+ }
142 144
143- log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")145+ if ttl <= 0 {
144- h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})146+ h.sendError(w, http.StatusBadRequest, "TTL must be positive")
147+ return
148+ }
149+ const maxPreviewTTL = 30 * 24 * time.Hour // 30 days
150+ if ttl > maxPreviewTTL {
151+ h.sendError(w, http.StatusBadRequest, "TTL exceeds maximum of 30 days")
152+ return
153+ }
145 154
146- default:155+ if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
147- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")156+ log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
157+ if errors.Is(err, domain.ErrPreviewNotFound) {
158+ h.sendError(w, http.StatusNotFound, "preview not found")
159+ } else {
160+ h.sendError(w, http.StatusInternalServerError, "failed to extend preview")
161+ }
162+ return
148 }163 }
164+
165+ log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")
166+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})
149 }167 }
M internal/adapters/in/http/auth/handler.go +3 -0
@@ -115,6 +115,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
115115 return
116116 }
117117
118+ // Limit request body size
119+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
120+
118121 // Parse request body
119122 var req PasswordRequest
120123 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -115,6 +115,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
115 return115 return
116 }116 }
117 117
118+ // Limit request body size
119+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
120+
118 // Parse request body121 // Parse request body
119 var req PasswordRequest122 var req PasswordRequest
120 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {123 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
M internal/adapters/in/http/proxy/forwarder.go +0 -5
@@ -163,9 +163,6 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
163163 // and could conflict with Gordon's own security headers on browser-facing
164164 // routes. We strip them here to ensure clean, predictable registry responses.
165165 ModifyResponse: func(resp *http.Response) error {
166- resp.Header.Set("X-Proxied-By", "Gordon")
167- resp.Header.Set("X-Registry-Backend", "gordon-registry")
168-
169166 // Remove browser-oriented security headers injected by the upstream
170167 // registry. These are unnecessary for Docker client API traffic and
171168 // would conflict with headers set on browser-facing proxy routes.
@@ -213,8 +210,6 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
213210 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
214211 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
215212 return func(resp *http.Response) error {
216- resp.Header.Set("X-Proxied-By", "Gordon")
217-
218213 if maxResponseSize > 0 {
219214 if resp.ContentLength > maxResponseSize {
220215 resp.Body.Close()
@@ -163,9 +163,6 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
163 // and could conflict with Gordon's own security headers on browser-facing163 // and could conflict with Gordon's own security headers on browser-facing
164 // routes. We strip them here to ensure clean, predictable registry responses.164 // routes. We strip them here to ensure clean, predictable registry responses.
165 ModifyResponse: func(resp *http.Response) error {165 ModifyResponse: func(resp *http.Response) error {
166- resp.Header.Set("X-Proxied-By", "Gordon")
167- resp.Header.Set("X-Registry-Backend", "gordon-registry")
168-
169 // Remove browser-oriented security headers injected by the upstream166 // Remove browser-oriented security headers injected by the upstream
170 // registry. These are unnecessary for Docker client API traffic and167 // registry. These are unnecessary for Docker client API traffic and
171 // would conflict with headers set on browser-facing proxy routes.168 // would conflict with headers set on browser-facing proxy routes.
@@ -213,8 +210,6 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
213 // modifyResponse returns a function that adds proxy headers and enforces response size limits.210 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
214 func modifyResponse(maxResponseSize int64) func(*http.Response) error {211 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
215 return func(resp *http.Response) error {212 return func(resp *http.Response) error {
216- resp.Header.Set("X-Proxied-By", "Gordon")
217-
218 if maxResponseSize > 0 {213 if maxResponseSize > 0 {
219 if resp.ContentLength > maxResponseSize {214 if resp.ContentLength > maxResponseSize {
220 resp.Body.Close()215 resp.Body.Close()
M internal/adapters/in/http/proxy/forwarder_test.go +0 -1
@@ -197,5 +197,4 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
197197 handler.ServeHTTP(w, req)
198198
199199 assert.Equal(t, http.StatusOK, w.Code)
200- assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By"))
201200 }
@@ -197,5 +197,4 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
197 handler.ServeHTTP(w, req)197 handler.ServeHTTP(w, req)
198 198
199 assert.Equal(t, http.StatusOK, w.Code)199 assert.Equal(t, http.StatusOK, w.Code)
200- assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By"))
201 }200 }
M internal/adapters/out/docker/runtime.go +8 -0
@@ -161,11 +161,19 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
161161 Labels: config.Labels,
162162 }
163163
164+ resources := container.Resources{
165+ Memory: config.MemoryLimit,
166+ NanoCPUs: config.NanoCPUs,
167+ }
168+ if config.PidsLimit > 0 {
169+ resources.PidsLimit = &config.PidsLimit
170+ }
164171 hostConfig := &container.HostConfig{
165172 PortBindings: portBindings,
166173 AutoRemove: config.AutoRemove,
167174 Binds: binds,
168175 NetworkMode: container.NetworkMode(config.NetworkMode),
176+ Resources: resources,
169177 }
170178
171179 // Create network configuration for container
@@ -161,11 +161,19 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
161 Labels: config.Labels,161 Labels: config.Labels,
162 }162 }
163 163
164+ resources := container.Resources{
165+ Memory: config.MemoryLimit,
166+ NanoCPUs: config.NanoCPUs,
167+ }
168+ if config.PidsLimit > 0 {
169+ resources.PidsLimit = &config.PidsLimit
170+ }
164 hostConfig := &container.HostConfig{171 hostConfig := &container.HostConfig{
165 PortBindings: portBindings,172 PortBindings: portBindings,
166 AutoRemove: config.AutoRemove,173 AutoRemove: config.AutoRemove,
167 Binds: binds,174 Binds: binds,
168 NetworkMode: container.NetworkMode(config.NetworkMode),175 NetworkMode: container.NetworkMode(config.NetworkMode),
176+ Resources: resources,
169 }177 }
170 178
171 // Create network configuration for container179 // Create network configuration for container
M internal/adapters/out/domainsecrets/store.go +14 -0
@@ -222,6 +222,13 @@ func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, e
222222 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
223223 // It writes to a temporary file first, syncs it, then renames to the final path.
224224 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
225+ // Validate secret values before any file operations
226+ for key, value := range secrets {
227+ if strings.ContainsAny(value, "\n\r") {
228+ return fmt.Errorf("secret value for %q contains newline characters", key)
229+ }
230+ }
231+
225232 envFile, err := s.validateEnvFilePath(domainName)
226233 if err != nil {
227234 return err
@@ -363,6 +370,13 @@ func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string,
363370 // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
364371 // It writes to a temporary file first, syncs it, then renames to the final path.
365372 func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
373+ // Validate secret values before any file operations
374+ for key, value := range secrets {
375+ if strings.ContainsAny(value, "\n\r") {
376+ return fmt.Errorf("secret value for %q contains newline characters", key)
377+ }
378+ }
379+
366380 envFile, err := s.validateAttachmentEnvFilePath(containerName)
367381 if err != nil {
368382 return err
@@ -222,6 +222,13 @@ func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, e
222 // writeSecretsAtomic writes all secrets to the domain's env file atomically.222 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
223 // It writes to a temporary file first, syncs it, then renames to the final path.223 // It writes to a temporary file first, syncs it, then renames to the final path.
224 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {224 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
225+ // Validate secret values before any file operations
226+ for key, value := range secrets {
227+ if strings.ContainsAny(value, "\n\r") {
228+ return fmt.Errorf("secret value for %q contains newline characters", key)
229+ }
230+ }
231+
225 envFile, err := s.validateEnvFilePath(domainName)232 envFile, err := s.validateEnvFilePath(domainName)
226 if err != nil {233 if err != nil {
227 return err234 return err
@@ -363,6 +370,13 @@ func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string,
363 // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.370 // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
364 // It writes to a temporary file first, syncs it, then renames to the final path.371 // It writes to a temporary file first, syncs it, then renames to the final path.
365 func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {372 func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
373+ // Validate secret values before any file operations
374+ for key, value := range secrets {
375+ if strings.ContainsAny(value, "\n\r") {
376+ return fmt.Errorf("secret value for %q contains newline characters", key)
377+ }
378+ }
379+
366 envFile, err := s.validateAttachmentEnvFilePath(containerName)380 envFile, err := s.validateAttachmentEnvFilePath(containerName)
367 if err != nil {381 if err != nil {
368 return err382 return err
M internal/app/run.go +81 -10
@@ -166,6 +166,12 @@ type Config struct {
166166 } `mapstructure:"prune"`
167167 } `mapstructure:"images"`
168168
169+ Containers struct {
170+ MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
171+ CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
172+ PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
173+ } `mapstructure:"containers"`
174+
169175 Telemetry telemetry.Config `mapstructure:"telemetry"`
170176 }
171177
@@ -1080,6 +1086,10 @@ func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowra
10801086 }
10811087
10821088 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
1089+ if cfg.Auth.Password != "" {
1090+ return auth.Config{}, fmt.Errorf("auth.password is no longer supported; use auth.password_hash with a secrets backend instead")
1091+ }
1092+
10831093 authConfig := auth.Config{
10841094 Enabled: cfg.Auth.Enabled,
10851095 AuthType: authType,
@@ -1124,8 +1134,7 @@ func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBac
11241134 }
11251135
11261136 if cfg.Auth.Password != "" {
1127- log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
1128- return cfg.Auth.Password, nil
1137+ return "", fmt.Errorf("auth.password is no longer supported; use auth.password_hash with a secrets backend instead")
11291138 }
11301139
11311140 return "", nil
@@ -1155,8 +1164,13 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
11551164 // 2. Secrets backend (pass/sops - encrypted)
11561165 // 3. Config file path (least preferred)
11571166
1167+ const minTokenSecretLength = 32
1168+
11581169 // Check environment variable first
11591170 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
1171+ if len(envSecret) < minTokenSecretLength {
1172+ return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret))
1173+ }
11601174 log.Debug().Msg("using token secret from environment variable")
11611175 return []byte(envSecret), nil
11621176 }
@@ -1171,6 +1185,10 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
11711185 return nil, log.WrapErr(err, "failed to load token secret")
11721186 }
11731187
1188+ if len(secret) < minTokenSecretLength {
1189+ return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret))
1190+ }
1191+
11741192 return []byte(secret), nil
11751193 }
11761194
@@ -1275,6 +1293,29 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
12751293
12761294 // createContainerService creates the container service with configuration.
12771295 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1296+ // Parse and validate container resource limits from config
1297+ if cfg.Containers.CPULimit < 0 {
1298+ return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
1299+ }
1300+ if cfg.Containers.PidsLimit < 0 {
1301+ return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
1302+ }
1303+ var defaultMemoryLimit int64
1304+ if cfg.Containers.MemoryLimit != "" {
1305+ parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)
1306+ if err != nil {
1307+ return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
1308+ }
1309+ if parsed <= 0 {
1310+ return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
1311+ }
1312+ defaultMemoryLimit = parsed
1313+ }
1314+ var defaultNanoCPUs int64
1315+ if cfg.Containers.CPULimit > 0 {
1316+ defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)
1317+ }
1318+
12781319 containerConfig := container.Config{
12791320 RegistryAuthEnabled: cfg.Auth.Enabled,
12801321 RegistryDomain: cfg.Server.RegistryDomain,
@@ -1298,6 +1339,9 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
12981339 DrainDelay: v.GetDuration("deploy.drain_delay"),
12991340 DrainMode: v.GetString("deploy.drain_mode"),
13001341 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1342+ DefaultMemoryLimit: defaultMemoryLimit,
1343+ DefaultNanoCPUs: defaultNanoCPUs,
1344+ DefaultPidsLimit: cfg.Containers.PidsLimit,
13011345 }
13021346 if v.IsSet("deploy.drain_delay") {
13031347 containerConfig.DrainDelayConfigured = true
@@ -1542,7 +1586,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
15421586 )
15431587
15441588 registryMux := http.NewServeMux()
1545- registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log)
1589+ registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log)
15461590 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
15471591
15481592 // SECURITY: No CORS middleware on the proxy chain. Backend applications
@@ -1675,12 +1719,22 @@ func registerAuthRoutes(
16751719 trustedNets []*net.IPNet,
16761720 cidrAllowlistMiddleware func(http.Handler) http.Handler,
16771721 rateLimitMiddleware func(http.Handler) http.Handler,
1722+ cfg Config,
16781723 log zerowrap.Logger,
16791724 ) {
16801725 if svc.authHandler == nil {
16811726 return
16821727 }
16831728
1729+ // Auth endpoints always get rate limiting, even if global rate limiting is disabled.
1730+ // This prevents brute-force attacks against password/token endpoints.
1731+ authRateLimitMiddleware := rateLimitMiddleware
1732+ if !cfg.API.RateLimit.Enabled {
1733+ authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log)
1734+ authIPLimiter := ratelimit.NewMemoryStore(5, 10, log)
1735+ authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log)
1736+ }
1737+
16841738 // Auth endpoints are NOT protected by auth - they're where clients authenticate
16851739 // but still need rate limiting to prevent brute force attacks.
16861740 authMiddlewares := []func(http.Handler) http.Handler{
@@ -1691,7 +1745,7 @@ func registerAuthRoutes(
16911745 if cidrAllowlistMiddleware != nil {
16921746 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
16931747 }
1694- authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1748+ authMiddlewares = append(authMiddlewares, authRateLimitMiddleware)
16951749 authWithMiddleware := otelhttp.NewHandler(
16961750 middleware.Chain(authMiddlewares...)(svc.authHandler),
16971751 "gordon.auth",
@@ -1774,18 +1828,28 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
17741828
17751829 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)
17761830
1777- // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can
1778- // accept h2c traffic from clients and load balancers (e.g. Cloudflare).
1779- var proxyProtos http.Protocols
1780- proxyProtos.SetHTTP1(true)
1781- proxyProtos.SetUnencryptedHTTP2(true)
1782- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
1831+ var proxySrv *http.Server
1832+ var proxyReady <-chan struct{}
17831833 var tlsSrv *http.Server
17841834 var tlsReady <-chan struct{}
17851835 if cfg.Server.TLSEnabled {
17861836 if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
17871837 return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
17881838 }
1839+ // When TLS is enabled, HTTP port becomes redirect-only.
1840+ tlsPort := cfg.Server.TLSPort
1841+ redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1842+ host := r.Host
1843+ if h, _, err := net.SplitHostPort(host); err == nil {
1844+ host = h
1845+ }
1846+ if tlsPort != 443 {
1847+ host = net.JoinHostPort(host, strconv.Itoa(tlsPort))
1848+ }
1849+ target := "https://" + host + r.RequestURI
1850+ http.Redirect(w, r, target, http.StatusMovedPermanently)
1851+ })
1852+ proxySrv, proxyReady = startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectHandler, "proxy-redirect", nil, errChan, log)
17891853 tlsSrv, tlsReady = startTLSServer(
17901854 fmt.Sprintf(":%d", cfg.Server.TLSPort),
17911855 proxyHandler,
@@ -1795,6 +1859,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
17951859 errChan,
17961860 log,
17971861 )
1862+ } else {
1863+ // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can
1864+ // accept h2c traffic from clients and load balancers (e.g. Cloudflare).
1865+ var proxyProtos http.Protocols
1866+ proxyProtos.SetHTTP1(true)
1867+ proxyProtos.SetUnencryptedHTTP2(true)
1868+ proxySrv, proxyReady = startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
17981869 }
17991870
18001871 // Wait for all enabled servers to bind their ports before auto-starting containers.
@@ -166,6 +166,12 @@ type Config struct {
166 } `mapstructure:"prune"`166 } `mapstructure:"prune"`
167 } `mapstructure:"images"`167 } `mapstructure:"images"`
168 168
169+ Containers struct {
170+ MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
171+ CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
172+ PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
173+ } `mapstructure:"containers"`
174+
169 Telemetry telemetry.Config `mapstructure:"telemetry"`175 Telemetry telemetry.Config `mapstructure:"telemetry"`
170 }176 }
171 177
@@ -1080,6 +1086,10 @@ func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowra
1080 }1086 }
1081 1087
1082 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {1088 func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
1089+ if cfg.Auth.Password != "" {
1090+ return auth.Config{}, fmt.Errorf("auth.password is no longer supported; use auth.password_hash with a secrets backend instead")
1091+ }
1092+
1083 authConfig := auth.Config{1093 authConfig := auth.Config{
1084 Enabled: cfg.Auth.Enabled,1094 Enabled: cfg.Auth.Enabled,
1085 AuthType: authType,1095 AuthType: authType,
@@ -1124,8 +1134,7 @@ func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBac
1124 }1134 }
1125 1135
1126 if cfg.Auth.Password != "" {1136 if cfg.Auth.Password != "" {
1127- log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")1137+ return "", fmt.Errorf("auth.password is no longer supported; use auth.password_hash with a secrets backend instead")
1128- return cfg.Auth.Password, nil
1129 }1138 }
1130 1139
1131 return "", nil1140 return "", nil
@@ -1155,8 +1164,13 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
1155 // 2. Secrets backend (pass/sops - encrypted)1164 // 2. Secrets backend (pass/sops - encrypted)
1156 // 3. Config file path (least preferred)1165 // 3. Config file path (least preferred)
1157 1166
1167+ const minTokenSecretLength = 32
1168+
1158 // Check environment variable first1169 // Check environment variable first
1159 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {1170 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
1171+ if len(envSecret) < minTokenSecretLength {
1172+ return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret))
1173+ }
1160 log.Debug().Msg("using token secret from environment variable")1174 log.Debug().Msg("using token secret from environment variable")
1161 return []byte(envSecret), nil1175 return []byte(envSecret), nil
1162 }1176 }
@@ -1171,6 +1185,10 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
1171 return nil, log.WrapErr(err, "failed to load token secret")1185 return nil, log.WrapErr(err, "failed to load token secret")
1172 }1186 }
1173 1187
1188+ if len(secret) < minTokenSecretLength {
1189+ return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret))
1190+ }
1191+
1174 return []byte(secret), nil1192 return []byte(secret), nil
1175 }1193 }
1176 1194
@@ -1275,6 +1293,29 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1275 1293
1276 // createContainerService creates the container service with configuration.1294 // createContainerService creates the container service with configuration.
1277 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {1295 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1296+ // Parse and validate container resource limits from config
1297+ if cfg.Containers.CPULimit < 0 {
1298+ return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
1299+ }
1300+ if cfg.Containers.PidsLimit < 0 {
1301+ return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
1302+ }
1303+ var defaultMemoryLimit int64
1304+ if cfg.Containers.MemoryLimit != "" {
1305+ parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)
1306+ if err != nil {
1307+ return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
1308+ }
1309+ if parsed <= 0 {
1310+ return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
1311+ }
1312+ defaultMemoryLimit = parsed
1313+ }
1314+ var defaultNanoCPUs int64
1315+ if cfg.Containers.CPULimit > 0 {
1316+ defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)
1317+ }
1318+
1278 containerConfig := container.Config{1319 containerConfig := container.Config{
1279 RegistryAuthEnabled: cfg.Auth.Enabled,1320 RegistryAuthEnabled: cfg.Auth.Enabled,
1280 RegistryDomain: cfg.Server.RegistryDomain,1321 RegistryDomain: cfg.Server.RegistryDomain,
@@ -1298,6 +1339,9 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1298 DrainDelay: v.GetDuration("deploy.drain_delay"),1339 DrainDelay: v.GetDuration("deploy.drain_delay"),
1299 DrainMode: v.GetString("deploy.drain_mode"),1340 DrainMode: v.GetString("deploy.drain_mode"),
1300 DrainTimeout: v.GetDuration("deploy.drain_timeout"),1341 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1342+ DefaultMemoryLimit: defaultMemoryLimit,
1343+ DefaultNanoCPUs: defaultNanoCPUs,
1344+ DefaultPidsLimit: cfg.Containers.PidsLimit,
1301 }1345 }
1302 if v.IsSet("deploy.drain_delay") {1346 if v.IsSet("deploy.drain_delay") {
1303 containerConfig.DrainDelayConfigured = true1347 containerConfig.DrainDelayConfigured = true
@@ -1542,7 +1586,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1542 )1586 )
1543 1587
1544 registryMux := http.NewServeMux()1588 registryMux := http.NewServeMux()
1545- registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log)1589+ registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log)
1546 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))1590 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
1547 1591
1548 // SECURITY: No CORS middleware on the proxy chain. Backend applications1592 // SECURITY: No CORS middleware on the proxy chain. Backend applications
@@ -1675,12 +1719,22 @@ func registerAuthRoutes(
1675 trustedNets []*net.IPNet,1719 trustedNets []*net.IPNet,
1676 cidrAllowlistMiddleware func(http.Handler) http.Handler,1720 cidrAllowlistMiddleware func(http.Handler) http.Handler,
1677 rateLimitMiddleware func(http.Handler) http.Handler,1721 rateLimitMiddleware func(http.Handler) http.Handler,
1722+ cfg Config,
1678 log zerowrap.Logger,1723 log zerowrap.Logger,
1679 ) {1724 ) {
1680 if svc.authHandler == nil {1725 if svc.authHandler == nil {
1681 return1726 return
1682 }1727 }
1683 1728
1729+ // Auth endpoints always get rate limiting, even if global rate limiting is disabled.
1730+ // This prevents brute-force attacks against password/token endpoints.
1731+ authRateLimitMiddleware := rateLimitMiddleware
1732+ if !cfg.API.RateLimit.Enabled {
1733+ authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log)
1734+ authIPLimiter := ratelimit.NewMemoryStore(5, 10, log)
1735+ authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log)
1736+ }
1737+
1684 // Auth endpoints are NOT protected by auth - they're where clients authenticate1738 // Auth endpoints are NOT protected by auth - they're where clients authenticate
1685 // but still need rate limiting to prevent brute force attacks.1739 // but still need rate limiting to prevent brute force attacks.
1686 authMiddlewares := []func(http.Handler) http.Handler{1740 authMiddlewares := []func(http.Handler) http.Handler{
@@ -1691,7 +1745,7 @@ func registerAuthRoutes(
1691 if cidrAllowlistMiddleware != nil {1745 if cidrAllowlistMiddleware != nil {
1692 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)1746 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1693 }1747 }
1694- authMiddlewares = append(authMiddlewares, rateLimitMiddleware)1748+ authMiddlewares = append(authMiddlewares, authRateLimitMiddleware)
1695 authWithMiddleware := otelhttp.NewHandler(1749 authWithMiddleware := otelhttp.NewHandler(
1696 middleware.Chain(authMiddlewares...)(svc.authHandler),1750 middleware.Chain(authMiddlewares...)(svc.authHandler),
1697 "gordon.auth",1751 "gordon.auth",
@@ -1774,18 +1828,28 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
1774 1828
1775 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)1829 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)
1776 1830
1777- // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can1831+ var proxySrv *http.Server
1778- // accept h2c traffic from clients and load balancers (e.g. Cloudflare).1832+ var proxyReady <-chan struct{}
1779- var proxyProtos http.Protocols
1780- proxyProtos.SetHTTP1(true)
1781- proxyProtos.SetUnencryptedHTTP2(true)
1782- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
1783 var tlsSrv *http.Server1833 var tlsSrv *http.Server
1784 var tlsReady <-chan struct{}1834 var tlsReady <-chan struct{}
1785 if cfg.Server.TLSEnabled {1835 if cfg.Server.TLSEnabled {
1786 if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {1836 if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1787 return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")1837 return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1788 }1838 }
1839+ // When TLS is enabled, HTTP port becomes redirect-only.
1840+ tlsPort := cfg.Server.TLSPort
1841+ redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1842+ host := r.Host
1843+ if h, _, err := net.SplitHostPort(host); err == nil {
1844+ host = h
1845+ }
1846+ if tlsPort != 443 {
1847+ host = net.JoinHostPort(host, strconv.Itoa(tlsPort))
1848+ }
1849+ target := "https://" + host + r.RequestURI
1850+ http.Redirect(w, r, target, http.StatusMovedPermanently)
1851+ })
1852+ proxySrv, proxyReady = startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectHandler, "proxy-redirect", nil, errChan, log)
1789 tlsSrv, tlsReady = startTLSServer(1853 tlsSrv, tlsReady = startTLSServer(
1790 fmt.Sprintf(":%d", cfg.Server.TLSPort),1854 fmt.Sprintf(":%d", cfg.Server.TLSPort),
1791 proxyHandler,1855 proxyHandler,
@@ -1795,6 +1859,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
1795 errChan,1859 errChan,
1796 log,1860 log,
1797 )1861 )
1862+ } else {
1863+ // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can
1864+ // accept h2c traffic from clients and load balancers (e.g. Cloudflare).
1865+ var proxyProtos http.Protocols
1866+ proxyProtos.SetHTTP1(true)
1867+ proxyProtos.SetUnencryptedHTTP2(true)
1868+ proxySrv, proxyReady = startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
1798 }1869 }
1799 1870
1800 // Wait for all enabled servers to bind their ports before auto-starting containers.1871 // Wait for all enabled servers to bind their ports before auto-starting containers.
M internal/app/run_auth_config_test.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
14- t.Setenv(TokenSecretEnvVar, "test-token-secret")
14+ t.Setenv(TokenSecretEnvVar, "test-token-secret-that-is-at-least-32-bytes-long")
1515
1616 cfg := Config{}
1717 cfg.Auth.Enabled = false
@@ -11,7 +11,7 @@ import (
11 )11 )
12 12
13 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {13 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
14- t.Setenv(TokenSecretEnvVar, "test-token-secret")14+ t.Setenv(TokenSecretEnvVar, "test-token-secret-that-is-at-least-32-bytes-long")
15 15
16 cfg := Config{}16 cfg := Config{}
17 cfg.Auth.Enabled = false17 cfg.Auth.Enabled = false
M internal/domain/container.go +3 -0
@@ -58,6 +58,9 @@ type ContainerConfig struct {
5858 NetworkMode string // Network to join
5959 Hostname string // Container hostname for DNS
6060 Aliases []string // Additional network aliases
61+ MemoryLimit int64 // Memory limit in bytes (0 = no limit)
62+ NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
63+ PidsLimit int64 // Max number of PIDs (0 = no limit)
6164 }
6265
6366 // ContainerStatus represents the current state of a container.
@@ -58,6 +58,9 @@ type ContainerConfig struct {
58 NetworkMode string // Network to join58 NetworkMode string // Network to join
59 Hostname string // Container hostname for DNS59 Hostname string // Container hostname for DNS
60 Aliases []string // Additional network aliases60 Aliases []string // Additional network aliases
61+ MemoryLimit int64 // Memory limit in bytes (0 = no limit)
62+ NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
63+ PidsLimit int64 // Max number of PIDs (0 = no limit)
61 }64 }
62 65
63 // ContainerStatus represents the current state of a container.66 // ContainerStatus represents the current state of a container.
M internal/usecase/auto/labels.go +6 -1
@@ -105,10 +105,15 @@ func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.Blo
105105 }
106106 defer reader.Close()
107107
108- configData, err := io.ReadAll(reader)
108+ const maxConfigBlobSize = 1 << 20 // 1 MB
109+ limited := io.LimitReader(reader, maxConfigBlobSize+1)
110+ configData, err := io.ReadAll(limited)
109111 if err != nil {
110112 return nil, fmt.Errorf("failed to read config blob: %w", err)
111113 }
114+ if len(configData) > maxConfigBlobSize {
115+ return nil, fmt.Errorf("config blob exceeds maximum size of %d bytes", maxConfigBlobSize)
116+ }
112117
113118 labels, err := ParseImageLabels(configData)
114119 if err != nil {
@@ -105,10 +105,15 @@ func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.Blo
105 }105 }
106 defer reader.Close()106 defer reader.Close()
107 107
108- configData, err := io.ReadAll(reader)108+ const maxConfigBlobSize = 1 << 20 // 1 MB
109+ limited := io.LimitReader(reader, maxConfigBlobSize+1)
110+ configData, err := io.ReadAll(limited)
109 if err != nil {111 if err != nil {
110 return nil, fmt.Errorf("failed to read config blob: %w", err)112 return nil, fmt.Errorf("failed to read config blob: %w", err)
111 }113 }
114+ if len(configData) > maxConfigBlobSize {
115+ return nil, fmt.Errorf("config blob exceeds maximum size of %d bytes", maxConfigBlobSize)
116+ }
112 117
113 labels, err := ParseImageLabels(configData)118 labels, err := ParseImageLabels(configData)
114 if err != nil {119 if err != nil {
M internal/usecase/container/service.go +17 -0
@@ -51,6 +51,9 @@ type Config struct {
5151 StabilizationDelay time.Duration // Post-switch monitoring window (default 2s)
5252 TCPProbeTimeout time.Duration // TCP probe timeout (default 30s)
5353 HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s)
54+ DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit)
55+ DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit)
56+ DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit)
5457 }
5558
5659 var tracer = otel.Tracer("gordon.container")
@@ -229,6 +232,10 @@ type containerConfigInput struct {
229232 }
230233
231234 func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {
235+ s.mu.RLock()
236+ cfg := s.config
237+ s.mu.RUnlock()
238+
232239 containerName := s.deploymentContainerName(in.Domain, in.Existing)
233240 labels := map[string]string{
234241 domain.LabelDomain: in.Domain,
@@ -266,6 +273,9 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
266273 Hostname: in.Domain,
267274 Labels: labels,
268275 AutoRemove: false,
276+ MemoryLimit: cfg.DefaultMemoryLimit,
277+ NanoCPUs: cfg.DefaultNanoCPUs,
278+ PidsLimit: cfg.DefaultPidsLimit,
269279 }
270280 }
271281
@@ -2232,6 +2242,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22322242 }
22332243 envHash := hashEnvironment(envVars)
22342244
2245+ s.mu.RLock()
2246+ cfg := s.config
2247+ s.mu.RUnlock()
2248+
22352249 // Create container with attachment labels (use actual ref, track original in labels)
22362250 config := &domain.ContainerConfig{
22372251 Image: actualImageRef,
@@ -2248,6 +2262,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22482262 domain.LabelEnvHash: envHash,
22492263 domain.LabelImage: serviceImage,
22502264 },
2265+ MemoryLimit: cfg.DefaultMemoryLimit,
2266+ NanoCPUs: cfg.DefaultNanoCPUs,
2267+ PidsLimit: cfg.DefaultPidsLimit,
22512268 }
22522269
22532270 container, err := s.runtime.CreateContainer(ctx, config)
@@ -51,6 +51,9 @@ type Config struct {
51 StabilizationDelay time.Duration // Post-switch monitoring window (default 2s)51 StabilizationDelay time.Duration // Post-switch monitoring window (default 2s)
52 TCPProbeTimeout time.Duration // TCP probe timeout (default 30s)52 TCPProbeTimeout time.Duration // TCP probe timeout (default 30s)
53 HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s)53 HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s)
54+ DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit)
55+ DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit)
56+ DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit)
54 }57 }
55 58
56 var tracer = otel.Tracer("gordon.container")59 var tracer = otel.Tracer("gordon.container")
@@ -229,6 +232,10 @@ type containerConfigInput struct {
229 }232 }
230 233
231 func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {234 func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {
235+ s.mu.RLock()
236+ cfg := s.config
237+ s.mu.RUnlock()
238+
232 containerName := s.deploymentContainerName(in.Domain, in.Existing)239 containerName := s.deploymentContainerName(in.Domain, in.Existing)
233 labels := map[string]string{240 labels := map[string]string{
234 domain.LabelDomain: in.Domain,241 domain.LabelDomain: in.Domain,
@@ -266,6 +273,9 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
266 Hostname: in.Domain,273 Hostname: in.Domain,
267 Labels: labels,274 Labels: labels,
268 AutoRemove: false,275 AutoRemove: false,
276+ MemoryLimit: cfg.DefaultMemoryLimit,
277+ NanoCPUs: cfg.DefaultNanoCPUs,
278+ PidsLimit: cfg.DefaultPidsLimit,
269 }279 }
270 }280 }
271 281
@@ -2232,6 +2242,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2232 }2242 }
2233 envHash := hashEnvironment(envVars)2243 envHash := hashEnvironment(envVars)
2234 2244
2245+ s.mu.RLock()
2246+ cfg := s.config
2247+ s.mu.RUnlock()
2248+
2235 // Create container with attachment labels (use actual ref, track original in labels)2249 // Create container with attachment labels (use actual ref, track original in labels)
2236 config := &domain.ContainerConfig{2250 config := &domain.ContainerConfig{
2237 Image: actualImageRef,2251 Image: actualImageRef,
@@ -2248,6 +2262,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2248 domain.LabelEnvHash: envHash,2262 domain.LabelEnvHash: envHash,
2249 domain.LabelImage: serviceImage,2263 domain.LabelImage: serviceImage,
2250 },2264 },
2265+ MemoryLimit: cfg.DefaultMemoryLimit,
2266+ NanoCPUs: cfg.DefaultNanoCPUs,
2267+ PidsLimit: cfg.DefaultPidsLimit,
2251 }2268 }
2252 2269
2253 container, err := s.runtime.CreateContainer(ctx, config)2270 container, err := s.runtime.CreateContainer(ctx, config)
M pkg/validation/path.go +15 -0
@@ -127,6 +127,21 @@ func ValidateUUID(uuid string) error {
127127 return nil
128128 }
129129
130+// ValidateDomainParam validates a domain name extracted from a URL path parameter.
131+// Rejects path traversal, null bytes, and control characters.
132+func ValidateDomainParam(domain string) error {
133+ if domain == "" {
134+ return fmt.Errorf("domain parameter is empty")
135+ }
136+ if strings.Contains(domain, "..") {
137+ return fmt.Errorf("domain contains path traversal sequence")
138+ }
139+ if strings.ContainsAny(domain, "\x00\n\r") {
140+ return fmt.Errorf("domain contains invalid characters")
141+ }
142+ return nil
143+}
144+
130145 // ValidatePath sanitizes and validates a path component to prevent traversal attacks.
131146 // Returns the cleaned path or an error if the path is unsafe.
132147 func ValidatePath(path string) (string, error) {
@@ -127,6 +127,21 @@ func ValidateUUID(uuid string) error {
127 return nil127 return nil
128 }128 }
129 129
130+// ValidateDomainParam validates a domain name extracted from a URL path parameter.
131+// Rejects path traversal, null bytes, and control characters.
132+func ValidateDomainParam(domain string) error {
133+ if domain == "" {
134+ return fmt.Errorf("domain parameter is empty")
135+ }
136+ if strings.Contains(domain, "..") {
137+ return fmt.Errorf("domain contains path traversal sequence")
138+ }
139+ if strings.ContainsAny(domain, "\x00\n\r") {
140+ return fmt.Errorf("domain contains invalid characters")
141+ }
142+ return nil
143+}
144+
130 // ValidatePath sanitizes and validates a path component to prevent traversal attacks.145 // ValidatePath sanitizes and validates a path component to prevent traversal attacks.
131 // Returns the cleaned path or an error if the path is unsafe.146 // Returns the cleaned path or an error if the path is unsafe.
132 func ValidatePath(path string) (string, error) {147 func ValidatePath(path string) (string, error) {
M pkg/validation/path_test.go +28 -0
@@ -201,6 +201,34 @@ func TestValidateUUID(t *testing.T) {
201201 }
202202 }
203203
204+func TestValidateDomainParam(t *testing.T) {
205+ tests := []struct {
206+ name string
207+ input string
208+ valid bool
209+ }{
210+ {"valid domain", "example.com", true},
211+ {"valid subdomain", "app.example.com", true},
212+ {"path traversal", "../etc/passwd", false},
213+ {"null byte", "example\x00.com", false},
214+ {"newline", "example\n.com", false},
215+ {"carriage return", "example\r.com", false},
216+ {"empty", "", false},
217+ {"double dot in middle", "foo..bar", false},
218+ }
219+ for _, tt := range tests {
220+ t.Run(tt.name, func(t *testing.T) {
221+ err := ValidateDomainParam(tt.input)
222+ if tt.valid && err != nil {
223+ t.Errorf("expected valid, got error: %v", err)
224+ }
225+ if !tt.valid && err == nil {
226+ t.Errorf("expected error for input %q", tt.input)
227+ }
228+ })
229+ }
230+}
231+
204232 func TestValidatePath(t *testing.T) {
205233 tests := []struct {
206234 name string
@@ -201,6 +201,34 @@ func TestValidateUUID(t *testing.T) {
201 }201 }
202 }202 }
203 203
204+func TestValidateDomainParam(t *testing.T) {
205+ tests := []struct {
206+ name string
207+ input string
208+ valid bool
209+ }{
210+ {"valid domain", "example.com", true},
211+ {"valid subdomain", "app.example.com", true},
212+ {"path traversal", "../etc/passwd", false},
213+ {"null byte", "example\x00.com", false},
214+ {"newline", "example\n.com", false},
215+ {"carriage return", "example\r.com", false},
216+ {"empty", "", false},
217+ {"double dot in middle", "foo..bar", false},
218+ }
219+ for _, tt := range tests {
220+ t.Run(tt.name, func(t *testing.T) {
221+ err := ValidateDomainParam(tt.input)
222+ if tt.valid && err != nil {
223+ t.Errorf("expected valid, got error: %v", err)
224+ }
225+ if !tt.valid && err == nil {
226+ t.Errorf("expected error for input %q", tt.input)
227+ }
228+ })
229+ }
230+}
231+
204 func TestValidatePath(t *testing.T) {232 func TestValidatePath(t *testing.T) {
205 tests := []struct {233 tests := []struct {
206 name string234 name string

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