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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 748 | 748 | cfg.TokenSecret = []byte(tokenSecretPath) | |
| 749 | 749 | } | |
| 750 | 750 | } 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") | |
| 753 | 752 | } | |
| 754 | 753 | ||
| 755 | 754 | 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 generation | 751 | + | 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, nil | 754 | 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 | |||
| 1264 | 1264 | h.sendError(w, http.StatusBadRequest, "domain required in path") | |
| 1265 | 1265 | 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 | 1272 | // Get the route for this domain | |
| 1269 | 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 | 1321 | h.sendError(w, http.StatusBadRequest, "domain required in path") | |
| 1318 | 1322 | return | |
| 1319 | 1323 | } | |
| 1320 | - | if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") { | |
| 1324 | + | if err := validation.ValidateDomainParam(restartDomain); err != nil { | |
| 1321 | 1325 | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 1322 | 1326 | return | |
| 1323 | 1327 | } | |
| @@ -1433,6 +1437,13 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string | |||
| 1433 | 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 | 1447 | if logDomain == "" { | |
| 1437 | 1448 | // Gordon process logs | |
| 1438 | 1449 | 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 | return | 1265 | 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 domain | 1272 | // 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 | return | 1322 | 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 | return | 1326 | 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 logs | 1448 | // 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 ( | |||
| 11 | 11 | "github.com/bnema/zerowrap" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | "github.com/bnema/gordon/pkg/validation" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 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 | 79 | 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 | 87 | name := strings.TrimPrefix(path, "/preview/") | |
| 82 | 88 | if name == "" || name == "/preview" { | |
| 83 | 89 | h.sendError(w, http.StatusBadRequest, "preview name required in path") | |
| 84 | 90 | 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 | 97 | switch r.Method { | |
| 88 | 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 | 100 | if err := h.previewSvc.Delete(ctx, name); err != nil { | |
| 95 | 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 | 111 | h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name}) | |
| 106 | 112 | ||
| 107 | 113 | 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) | |
| 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 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) | |
| 121 | 124 | ||
| 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) | |
| 126 | 126 | ||
| 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 | + | } | |
| 132 | 133 | ||
| 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 | + | } | |
| 142 | 144 | ||
| 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 | + | } | |
| 145 | 154 | ||
| 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 | |
| 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 | } | |
| @@ -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 | return | 79 | 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 | return | 90 | 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 previewExtendRequest | 121 | + | 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 | - | return | 130 | + | 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 | - | return | 141 | + | 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) { | |||
| 115 | 115 | return | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | + | // Limit request body size | |
| 119 | + | r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB | |
| 120 | + | ||
| 118 | 121 | // Parse request body | |
| 119 | 122 | var req PasswordRequest | |
| 120 | 123 | 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 | return | 115 | 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 body | 121 | // Parse request body | ||
| 119 | var req PasswordRequest | 122 | 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 | |||
| 163 | 163 | // and could conflict with Gordon's own security headers on browser-facing | |
| 164 | 164 | // routes. We strip them here to ensure clean, predictable registry responses. | |
| 165 | 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 | 166 | // Remove browser-oriented security headers injected by the upstream | |
| 170 | 167 | // registry. These are unnecessary for Docker client API traffic and | |
| 171 | 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 | 210 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | |
| 214 | 211 | func modifyResponse(maxResponseSize int64) func(*http.Response) error { | |
| 215 | 212 | return func(resp *http.Response) error { | |
| 216 | - | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 217 | - | ||
| 218 | 213 | if maxResponseSize > 0 { | |
| 219 | 214 | if resp.ContentLength > maxResponseSize { | |
| 220 | 215 | 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-facing | 163 | // 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 upstream | 166 | // Remove browser-oriented security headers injected by the upstream | ||
| 170 | // registry. These are unnecessary for Docker client API traffic and | 167 | // 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) { | |||
| 197 | 197 | handler.ServeHTTP(w, req) | |
| 198 | 198 | ||
| 199 | 199 | assert.Equal(t, http.StatusOK, w.Code) | |
| 200 | - | assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By")) | |
| 201 | 200 | } | |
| @@ -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 | |||
| 161 | 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 | 171 | hostConfig := &container.HostConfig{ | |
| 165 | 172 | PortBindings: portBindings, | |
| 166 | 173 | AutoRemove: config.AutoRemove, | |
| 167 | 174 | Binds: binds, | |
| 168 | 175 | NetworkMode: container.NetworkMode(config.NetworkMode), | |
| 176 | + | Resources: resources, | |
| 169 | 177 | } | |
| 170 | 178 | ||
| 171 | 179 | // 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 container | 179 | // 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 | |||
| 222 | 222 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |
| 223 | 223 | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 224 | 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 | 232 | envFile, err := s.validateEnvFilePath(domainName) | |
| 226 | 233 | if err != nil { | |
| 227 | 234 | return err | |
| @@ -363,6 +370,13 @@ func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, | |||
| 363 | 370 | // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically. | |
| 364 | 371 | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 365 | 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 | 380 | envFile, err := s.validateAttachmentEnvFilePath(containerName) | |
| 367 | 381 | if err != nil { | |
| 368 | 382 | 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 err | 234 | 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 err | 382 | return err | ||
M internal/app/run.go +81 -10
| @@ -166,6 +166,12 @@ type Config struct { | |||
| 166 | 166 | } `mapstructure:"prune"` | |
| 167 | 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 | 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 | 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 | 1093 | authConfig := auth.Config{ | |
| 1084 | 1094 | Enabled: cfg.Auth.Enabled, | |
| 1085 | 1095 | AuthType: authType, | |
| @@ -1124,8 +1134,7 @@ func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBac | |||
| 1124 | 1134 | } | |
| 1125 | 1135 | ||
| 1126 | 1136 | 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") | |
| 1129 | 1138 | } | |
| 1130 | 1139 | ||
| 1131 | 1140 | return "", nil | |
| @@ -1155,8 +1164,13 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack | |||
| 1155 | 1164 | // 2. Secrets backend (pass/sops - encrypted) | |
| 1156 | 1165 | // 3. Config file path (least preferred) | |
| 1157 | 1166 | ||
| 1167 | + | const minTokenSecretLength = 32 | |
| 1168 | + | ||
| 1158 | 1169 | // Check environment variable first | |
| 1159 | 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 | 1174 | log.Debug().Msg("using token secret from environment variable") | |
| 1161 | 1175 | return []byte(envSecret), nil | |
| 1162 | 1176 | } | |
| @@ -1171,6 +1185,10 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack | |||
| 1171 | 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 | 1192 | return []byte(secret), nil | |
| 1175 | 1193 | } | |
| 1176 | 1194 | ||
| @@ -1275,6 +1293,29 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||
| 1275 | 1293 | ||
| 1276 | 1294 | // createContainerService creates the container service with configuration. | |
| 1277 | 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 | 1319 | containerConfig := container.Config{ | |
| 1279 | 1320 | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 1280 | 1321 | RegistryDomain: cfg.Server.RegistryDomain, | |
| @@ -1298,6 +1339,9 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1298 | 1339 | DrainDelay: v.GetDuration("deploy.drain_delay"), | |
| 1299 | 1340 | DrainMode: v.GetString("deploy.drain_mode"), | |
| 1300 | 1341 | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | |
| 1342 | + | DefaultMemoryLimit: defaultMemoryLimit, | |
| 1343 | + | DefaultNanoCPUs: defaultNanoCPUs, | |
| 1344 | + | DefaultPidsLimit: cfg.Containers.PidsLimit, | |
| 1301 | 1345 | } | |
| 1302 | 1346 | if v.IsSet("deploy.drain_delay") { | |
| 1303 | 1347 | containerConfig.DrainDelayConfigured = true | |
| @@ -1542,7 +1586,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1542 | 1586 | ) | |
| 1543 | 1587 | ||
| 1544 | 1588 | registryMux := http.NewServeMux() | |
| 1545 | - | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log) | |
| 1589 | + | registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log) | |
| 1546 | 1590 | registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log)) | |
| 1547 | 1591 | ||
| 1548 | 1592 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | |
| @@ -1675,12 +1719,22 @@ func registerAuthRoutes( | |||
| 1675 | 1719 | trustedNets []*net.IPNet, | |
| 1676 | 1720 | cidrAllowlistMiddleware func(http.Handler) http.Handler, | |
| 1677 | 1721 | rateLimitMiddleware func(http.Handler) http.Handler, | |
| 1722 | + | cfg Config, | |
| 1678 | 1723 | log zerowrap.Logger, | |
| 1679 | 1724 | ) { | |
| 1680 | 1725 | if svc.authHandler == nil { | |
| 1681 | 1726 | 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 | 1738 | // Auth endpoints are NOT protected by auth - they're where clients authenticate | |
| 1685 | 1739 | // but still need rate limiting to prevent brute force attacks. | |
| 1686 | 1740 | authMiddlewares := []func(http.Handler) http.Handler{ | |
| @@ -1691,7 +1745,7 @@ func registerAuthRoutes( | |||
| 1691 | 1745 | if cidrAllowlistMiddleware != nil { | |
| 1692 | 1746 | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |
| 1693 | 1747 | } | |
| 1694 | - | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |
| 1748 | + | authMiddlewares = append(authMiddlewares, authRateLimitMiddleware) | |
| 1695 | 1749 | authWithMiddleware := otelhttp.NewHandler( | |
| 1696 | 1750 | middleware.Chain(authMiddlewares...)(svc.authHandler), | |
| 1697 | 1751 | "gordon.auth", | |
| @@ -1774,18 +1828,28 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1774 | 1828 | ||
| 1775 | 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 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{} | |
| 1783 | 1833 | var tlsSrv *http.Server | |
| 1784 | 1834 | var tlsReady <-chan struct{} | |
| 1785 | 1835 | if cfg.Server.TLSEnabled { | |
| 1786 | 1836 | if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" { | |
| 1787 | 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 | 1853 | tlsSrv, tlsReady = startTLSServer( | |
| 1790 | 1854 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 1791 | 1855 | proxyHandler, | |
| @@ -1795,6 +1859,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1795 | 1859 | errChan, | |
| 1796 | 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 | 1871 | // 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 "", nil | 1140 | 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 first | 1169 | // 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), nil | 1175 | 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), nil | 1192 | 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 = true | 1347 | 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 applications | 1592 | // 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 | return | 1726 | 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 authenticate | 1738 | // 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 can | 1831 | + | 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.Server | 1833 | 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 ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 16 | cfg := Config{} | |
| 17 | 17 | 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 = false | 17 | cfg.Auth.Enabled = false | ||
M internal/domain/container.go +3 -0
| @@ -58,6 +58,9 @@ type ContainerConfig struct { | |||
| 58 | 58 | NetworkMode string // Network to join | |
| 59 | 59 | Hostname string // Container hostname for DNS | |
| 60 | 60 | 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 | 66 | // ContainerStatus represents the current state of a container. | |
| @@ -58,6 +58,9 @@ type ContainerConfig struct { | |||||
| 58 | NetworkMode string // Network to join | 58 | NetworkMode string // Network to join | ||
| 59 | Hostname string // Container hostname for DNS | 59 | Hostname string // Container hostname for DNS | ||
| 60 | Aliases []string // Additional network aliases | 60 | 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 | |||
| 105 | 105 | } | |
| 106 | 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 | 111 | if err != nil { | |
| 110 | 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 | 118 | labels, err := ParseImageLabels(configData) | |
| 114 | 119 | 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 { | |||
| 51 | 51 | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | |
| 52 | 52 | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | |
| 53 | 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 | 59 | var tracer = otel.Tracer("gordon.container") | |
| @@ -229,6 +232,10 @@ type containerConfigInput struct { | |||
| 229 | 232 | } | |
| 230 | 233 | ||
| 231 | 234 | func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig { | |
| 235 | + | s.mu.RLock() | |
| 236 | + | cfg := s.config | |
| 237 | + | s.mu.RUnlock() | |
| 238 | + | ||
| 232 | 239 | containerName := s.deploymentContainerName(in.Domain, in.Existing) | |
| 233 | 240 | labels := map[string]string{ | |
| 234 | 241 | domain.LabelDomain: in.Domain, | |
| @@ -266,6 +273,9 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe | |||
| 266 | 273 | Hostname: in.Domain, | |
| 267 | 274 | Labels: labels, | |
| 268 | 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 | 2243 | envHash := hashEnvironment(envVars) | |
| 2234 | 2244 | ||
| 2245 | + | s.mu.RLock() | |
| 2246 | + | cfg := s.config | |
| 2247 | + | s.mu.RUnlock() | |
| 2248 | + | ||
| 2235 | 2249 | // Create container with attachment labels (use actual ref, track original in labels) | |
| 2236 | 2250 | config := &domain.ContainerConfig{ | |
| 2237 | 2251 | Image: actualImageRef, | |
| @@ -2248,6 +2262,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2248 | 2262 | domain.LabelEnvHash: envHash, | |
| 2249 | 2263 | domain.LabelImage: serviceImage, | |
| 2250 | 2264 | }, | |
| 2265 | + | MemoryLimit: cfg.DefaultMemoryLimit, | |
| 2266 | + | NanoCPUs: cfg.DefaultNanoCPUs, | |
| 2267 | + | PidsLimit: cfg.DefaultPidsLimit, | |
| 2251 | 2268 | } | |
| 2252 | 2269 | ||
| 2253 | 2270 | 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 { | |||
| 127 | 127 | 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 | 145 | // ValidatePath sanitizes and validates a path component to prevent traversal attacks. | |
| 131 | 146 | // Returns the cleaned path or an error if the path is unsafe. | |
| 132 | 147 | func ValidatePath(path string) (string, error) { | |
| @@ -127,6 +127,21 @@ func ValidateUUID(uuid string) error { | |||||
| 127 | return nil | 127 | 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) { | |||
| 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 | 232 | func TestValidatePath(t *testing.T) { | |
| 205 | 233 | tests := []struct { | |
| 206 | 234 | 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 string | 234 | name string | ||