fix(security): address path traversal and error disclosure vulnerabilities
- Add url.PathEscape() to all domain/key URL path concatenations in remote client - Add domain validation in log service to prevent injection attacks - Cap log lines parameter to 10000 to prevent memory exhaustion - Sanitize error messages in deploy endpoint and SSE streams
- commit
- 05e46b08b96ae00c332968b167cd1cbe3ed7a825
- author
- brice · · 2026-01-17 07:17 UTC
- parent
- 594856c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +44 -12
M internal/adapters/in/cli/remote/client.go +10 -9
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | 10 | "net/http" | |
| 11 | + | "net/url" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| @@ -137,7 +138,7 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) { | |||
| 137 | 138 | ||
| 138 | 139 | // GetRoute returns a specific route by domain. | |
| 139 | 140 | func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | |
| 140 | - | resp, err := c.request(ctx, http.MethodGet, "/routes/"+routeDomain, nil) | |
| 141 | + | resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil) | |
| 141 | 142 | if err != nil { | |
| 142 | 143 | return nil, err | |
| 143 | 144 | } | |
| @@ -161,7 +162,7 @@ func (c *Client) AddRoute(ctx context.Context, route domain.Route) error { | |||
| 161 | 162 | ||
| 162 | 163 | // UpdateRoute updates an existing route. | |
| 163 | 164 | func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | |
| 164 | - | resp, err := c.request(ctx, http.MethodPut, "/routes/"+route.Domain, route) | |
| 165 | + | resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route) | |
| 165 | 166 | if err != nil { | |
| 166 | 167 | return err | |
| 167 | 168 | } | |
| @@ -170,7 +171,7 @@ func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | |||
| 170 | 171 | ||
| 171 | 172 | // RemoveRoute removes a route by domain. | |
| 172 | 173 | func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |
| 173 | - | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+routeDomain, nil) | |
| 174 | + | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil) | |
| 174 | 175 | if err != nil { | |
| 175 | 176 | return err | |
| 176 | 177 | } | |
| @@ -181,7 +182,7 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |||
| 181 | 182 | ||
| 182 | 183 | // ListSecrets returns the list of secret keys for a domain. | |
| 183 | 184 | func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) { | |
| 184 | - | resp, err := c.request(ctx, http.MethodGet, "/secrets/"+secretDomain, nil) | |
| 185 | + | resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil) | |
| 185 | 186 | if err != nil { | |
| 186 | 187 | return nil, err | |
| 187 | 188 | } | |
| @@ -199,7 +200,7 @@ func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string | |||
| 199 | 200 | ||
| 200 | 201 | // SetSecrets sets secrets for a domain. | |
| 201 | 202 | func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | |
| 202 | - | resp, err := c.request(ctx, http.MethodPost, "/secrets/"+secretDomain, secrets) | |
| 203 | + | resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets) | |
| 203 | 204 | if err != nil { | |
| 204 | 205 | return err | |
| 205 | 206 | } | |
| @@ -208,7 +209,7 @@ func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets ma | |||
| 208 | 209 | ||
| 209 | 210 | // DeleteSecret removes a secret from a domain. | |
| 210 | 211 | func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error { | |
| 211 | - | resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+secretDomain+"/"+key, nil) | |
| 212 | + | resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil) | |
| 212 | 213 | if err != nil { | |
| 213 | 214 | return err | |
| 214 | 215 | } | |
| @@ -331,7 +332,7 @@ type DeployResult struct { | |||
| 331 | 332 | ||
| 332 | 333 | // Deploy triggers a deployment for the specified domain. | |
| 333 | 334 | func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) { | |
| 334 | - | resp, err := c.request(ctx, http.MethodPost, "/deploy/"+deployDomain, nil) | |
| 335 | + | resp, err := c.request(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil) | |
| 335 | 336 | if err != nil { | |
| 336 | 337 | return nil, err | |
| 337 | 338 | } | |
| @@ -366,7 +367,7 @@ func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error | |||
| 366 | 367 | ||
| 367 | 368 | // GetContainerLogs returns container logs for a specific domain. | |
| 368 | 369 | func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | |
| 369 | - | path := fmt.Sprintf("/logs/%s?lines=%d", logDomain, lines) | |
| 370 | + | path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines) | |
| 370 | 371 | resp, err := c.request(ctx, http.MethodGet, path, nil) | |
| 371 | 372 | if err != nil { | |
| 372 | 373 | return nil, err | |
| @@ -393,7 +394,7 @@ func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan strin | |||
| 393 | 394 | // StreamContainerLogs returns a channel that streams container log lines via SSE. | |
| 394 | 395 | // The caller is responsible for reading from the channel until it's closed. | |
| 395 | 396 | func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |
| 396 | - | path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", logDomain, lines) | |
| 397 | + | path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines) | |
| 397 | 398 | return c.streamLogs(ctx, path) | |
| 398 | 399 | } | |
| 399 | 400 | ||
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | "net/http" | 10 | "net/http" | ||
| 11 | + | "net/url" | |||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "time" | 13 | "time" | ||
| 13 | 14 | ||||
| @@ -137,7 +138,7 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) { | |||||
| 137 | 138 | ||||
| 138 | // GetRoute returns a specific route by domain. | 139 | // GetRoute returns a specific route by domain. | ||
| 139 | func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | 140 | func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) { | ||
| 140 | - | resp, err := c.request(ctx, http.MethodGet, "/routes/"+routeDomain, nil) | 141 | + | resp, err := c.request(ctx, http.MethodGet, "/routes/"+url.PathEscape(routeDomain), nil) |
| 141 | if err != nil { | 142 | if err != nil { | ||
| 142 | return nil, err | 143 | return nil, err | ||
| 143 | } | 144 | } | ||
| @@ -161,7 +162,7 @@ func (c *Client) AddRoute(ctx context.Context, route domain.Route) error { | |||||
| 161 | 162 | ||||
| 162 | // UpdateRoute updates an existing route. | 163 | // UpdateRoute updates an existing route. | ||
| 163 | func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | 164 | func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | ||
| 164 | - | resp, err := c.request(ctx, http.MethodPut, "/routes/"+route.Domain, route) | 165 | + | resp, err := c.request(ctx, http.MethodPut, "/routes/"+url.PathEscape(route.Domain), route) |
| 165 | if err != nil { | 166 | if err != nil { | ||
| 166 | return err | 167 | return err | ||
| 167 | } | 168 | } | ||
| @@ -170,7 +171,7 @@ func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error { | |||||
| 170 | 171 | ||||
| 171 | // RemoveRoute removes a route by domain. | 172 | // RemoveRoute removes a route by domain. | ||
| 172 | func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | 173 | func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | ||
| 173 | - | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+routeDomain, nil) | 174 | + | resp, err := c.request(ctx, http.MethodDelete, "/routes/"+url.PathEscape(routeDomain), nil) |
| 174 | if err != nil { | 175 | if err != nil { | ||
| 175 | return err | 176 | return err | ||
| 176 | } | 177 | } | ||
| @@ -181,7 +182,7 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |||||
| 181 | 182 | ||||
| 182 | // ListSecrets returns the list of secret keys for a domain. | 183 | // ListSecrets returns the list of secret keys for a domain. | ||
| 183 | func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) { | 184 | func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) { | ||
| 184 | - | resp, err := c.request(ctx, http.MethodGet, "/secrets/"+secretDomain, nil) | 185 | + | resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil) |
| 185 | if err != nil { | 186 | if err != nil { | ||
| 186 | return nil, err | 187 | return nil, err | ||
| 187 | } | 188 | } | ||
| @@ -199,7 +200,7 @@ func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string | |||||
| 199 | 200 | ||||
| 200 | // SetSecrets sets secrets for a domain. | 201 | // SetSecrets sets secrets for a domain. | ||
| 201 | func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | 202 | func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error { | ||
| 202 | - | resp, err := c.request(ctx, http.MethodPost, "/secrets/"+secretDomain, secrets) | 203 | + | resp, err := c.request(ctx, http.MethodPost, "/secrets/"+url.PathEscape(secretDomain), secrets) |
| 203 | if err != nil { | 204 | if err != nil { | ||
| 204 | return err | 205 | return err | ||
| 205 | } | 206 | } | ||
| @@ -208,7 +209,7 @@ func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets ma | |||||
| 208 | 209 | ||||
| 209 | // DeleteSecret removes a secret from a domain. | 210 | // DeleteSecret removes a secret from a domain. | ||
| 210 | func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error { | 211 | func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error { | ||
| 211 | - | resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+secretDomain+"/"+key, nil) | 212 | + | resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+url.PathEscape(secretDomain)+"/"+url.PathEscape(key), nil) |
| 212 | if err != nil { | 213 | if err != nil { | ||
| 213 | return err | 214 | return err | ||
| 214 | } | 215 | } | ||
| @@ -331,7 +332,7 @@ type DeployResult struct { | |||||
| 331 | 332 | ||||
| 332 | // Deploy triggers a deployment for the specified domain. | 333 | // Deploy triggers a deployment for the specified domain. | ||
| 333 | func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) { | 334 | func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) { | ||
| 334 | - | resp, err := c.request(ctx, http.MethodPost, "/deploy/"+deployDomain, nil) | 335 | + | resp, err := c.request(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil) |
| 335 | if err != nil { | 336 | if err != nil { | ||
| 336 | return nil, err | 337 | return nil, err | ||
| 337 | } | 338 | } | ||
| @@ -366,7 +367,7 @@ func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error | |||||
| 366 | 367 | ||||
| 367 | // GetContainerLogs returns container logs for a specific domain. | 368 | // GetContainerLogs returns container logs for a specific domain. | ||
| 368 | func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | 369 | func (c *Client) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) { | ||
| 369 | - | path := fmt.Sprintf("/logs/%s?lines=%d", logDomain, lines) | 370 | + | path := fmt.Sprintf("/logs/%s?lines=%d", url.PathEscape(logDomain), lines) |
| 370 | resp, err := c.request(ctx, http.MethodGet, path, nil) | 371 | resp, err := c.request(ctx, http.MethodGet, path, nil) | ||
| 371 | if err != nil { | 372 | if err != nil { | ||
| 372 | return nil, err | 373 | return nil, err | ||
| @@ -393,7 +394,7 @@ func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan strin | |||||
| 393 | // StreamContainerLogs returns a channel that streams container log lines via SSE. | 394 | // StreamContainerLogs returns a channel that streams container log lines via SSE. | ||
| 394 | // The caller is responsible for reading from the channel until it's closed. | 395 | // The caller is responsible for reading from the channel until it's closed. | ||
| 395 | func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | 396 | func (c *Client) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | ||
| 396 | - | path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", logDomain, lines) | 397 | + | path := fmt.Sprintf("/logs/%s?lines=%d&follow=true", url.PathEscape(logDomain), lines) |
| 397 | return c.streamLogs(ctx, path) | 398 | return c.streamLogs(ctx, path) | ||
| 398 | } | 399 | } | ||
| 399 | 400 | ||||
M internal/adapters/in/http/admin/handler.go +9 -3
| @@ -19,6 +19,9 @@ import ( | |||
| 19 | 19 | // maxAdminRequestSize is the maximum allowed size for admin API request bodies. | |
| 20 | 20 | const maxAdminRequestSize = 1 << 20 // 1MB | |
| 21 | 21 | ||
| 22 | + | // maxLogLines is the maximum allowed number of log lines that can be requested. | |
| 23 | + | const maxLogLines = 10000 | |
| 24 | + | ||
| 22 | 25 | // Handler implements the HTTP handler for the admin API. | |
| 23 | 26 | type Handler struct { | |
| 24 | 27 | configSvc in.ConfigService | |
| @@ -529,7 +532,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||
| 529 | 532 | container, err := h.containerSvc.Deploy(ctx, *route) | |
| 530 | 533 | if err != nil { | |
| 531 | 534 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | |
| 532 | - | h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to deploy: %v", err)) | |
| 535 | + | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | |
| 533 | 536 | return | |
| 534 | 537 | } | |
| 535 | 538 | ||
| @@ -572,6 +575,9 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string | |||
| 572 | 575 | lines = n | |
| 573 | 576 | } | |
| 574 | 577 | } | |
| 578 | + | if lines > maxLogLines { | |
| 579 | + | lines = maxLogLines | |
| 580 | + | } | |
| 575 | 581 | follow := r.URL.Query().Get("follow") == "true" | |
| 576 | 582 | ||
| 577 | 583 | // Parse domain from path | |
| @@ -662,7 +668,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line | |||
| 662 | 668 | ch, err := h.logSvc.FollowProcessLogs(ctx, lines) | |
| 663 | 669 | if err != nil { | |
| 664 | 670 | log.Warn().Err(err).Msg("failed to follow process logs") | |
| 665 | - | _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error()) | |
| 671 | + | _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n") | |
| 666 | 672 | flusher.Flush() | |
| 667 | 673 | return | |
| 668 | 674 | } | |
| @@ -702,7 +708,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||
| 702 | 708 | ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines) | |
| 703 | 709 | if err != nil { | |
| 704 | 710 | log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs") | |
| 705 | - | _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error()) | |
| 711 | + | _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n") | |
| 706 | 712 | flusher.Flush() | |
| 707 | 713 | return | |
| 708 | 714 | } | |
| @@ -19,6 +19,9 @@ import ( | |||||
| 19 | // maxAdminRequestSize is the maximum allowed size for admin API request bodies. | 19 | // maxAdminRequestSize is the maximum allowed size for admin API request bodies. | ||
| 20 | const maxAdminRequestSize = 1 << 20 // 1MB | 20 | const maxAdminRequestSize = 1 << 20 // 1MB | ||
| 21 | 21 | ||||
| 22 | + | // maxLogLines is the maximum allowed number of log lines that can be requested. | |||
| 23 | + | const maxLogLines = 10000 | |||
| 24 | + | ||||
| 22 | // Handler implements the HTTP handler for the admin API. | 25 | // Handler implements the HTTP handler for the admin API. | ||
| 23 | type Handler struct { | 26 | type Handler struct { | ||
| 24 | configSvc in.ConfigService | 27 | configSvc in.ConfigService | ||
| @@ -529,7 +532,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||||
| 529 | container, err := h.containerSvc.Deploy(ctx, *route) | 532 | container, err := h.containerSvc.Deploy(ctx, *route) | ||
| 530 | if err != nil { | 533 | if err != nil { | ||
| 531 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | 534 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | ||
| 532 | - | h.sendError(w, http.StatusInternalServerError, fmt.Sprintf("failed to deploy: %v", err)) | 535 | + | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") |
| 533 | return | 536 | return | ||
| 534 | } | 537 | } | ||
| 535 | 538 | ||||
| @@ -572,6 +575,9 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string | |||||
| 572 | lines = n | 575 | lines = n | ||
| 573 | } | 576 | } | ||
| 574 | } | 577 | } | ||
| 578 | + | if lines > maxLogLines { | |||
| 579 | + | lines = maxLogLines | |||
| 580 | + | } | |||
| 575 | follow := r.URL.Query().Get("follow") == "true" | 581 | follow := r.URL.Query().Get("follow") == "true" | ||
| 576 | 582 | ||||
| 577 | // Parse domain from path | 583 | // Parse domain from path | ||
| @@ -662,7 +668,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line | |||||
| 662 | ch, err := h.logSvc.FollowProcessLogs(ctx, lines) | 668 | ch, err := h.logSvc.FollowProcessLogs(ctx, lines) | ||
| 663 | if err != nil { | 669 | if err != nil { | ||
| 664 | log.Warn().Err(err).Msg("failed to follow process logs") | 670 | log.Warn().Err(err).Msg("failed to follow process logs") | ||
| 665 | - | _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error()) | 671 | + | _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream logs\n\n") |
| 666 | flusher.Flush() | 672 | flusher.Flush() | ||
| 667 | return | 673 | return | ||
| 668 | } | 674 | } | ||
| @@ -702,7 +708,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo | |||||
| 702 | ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines) | 708 | ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines) | ||
| 703 | if err != nil { | 709 | if err != nil { | ||
| 704 | log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs") | 710 | log.Warn().Err(err).Str("domain", logDomain).Msg("failed to follow container logs") | ||
| 705 | - | _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", err.Error()) | 711 | + | _, _ = fmt.Fprintf(w, "event: error\ndata: failed to stream container logs\n\n") |
| 706 | flusher.Flush() | 712 | flusher.Flush() | ||
| 707 | return | 713 | return | ||
| 708 | } | 714 | } | ||
M internal/usecase/logs/service.go +25 -0
| @@ -140,8 +140,29 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch | |||
| 140 | 140 | return ch, nil | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | // validateDomain checks that domain is safe for use in logs and error messages. | |
| 144 | + | func validateDomain(domain string) error { | |
| 145 | + | if domain == "" { | |
| 146 | + | return fmt.Errorf("domain cannot be empty") | |
| 147 | + | } | |
| 148 | + | if len(domain) > 253 { | |
| 149 | + | return fmt.Errorf("domain too long") | |
| 150 | + | } | |
| 151 | + | if strings.Contains(domain, "..") { | |
| 152 | + | return fmt.Errorf("invalid domain") | |
| 153 | + | } | |
| 154 | + | if strings.ContainsAny(domain, "\x00\n\r") { | |
| 155 | + | return fmt.Errorf("invalid domain") | |
| 156 | + | } | |
| 157 | + | return nil | |
| 158 | + | } | |
| 159 | + | ||
| 143 | 160 | // GetContainerLogs returns the last N lines of container logs for a domain. | |
| 144 | 161 | func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) { | |
| 162 | + | if err := validateDomain(domain); err != nil { | |
| 163 | + | return nil, err | |
| 164 | + | } | |
| 165 | + | ||
| 145 | 166 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 146 | 167 | zerowrap.FieldLayer: "usecase", | |
| 147 | 168 | zerowrap.FieldUseCase: "GetContainerLogs", | |
| @@ -179,6 +200,10 @@ func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int | |||
| 179 | 200 | ||
| 180 | 201 | // FollowContainerLogs returns a channel that streams container log lines. | |
| 181 | 202 | func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) { | |
| 203 | + | if err := validateDomain(domain); err != nil { | |
| 204 | + | return nil, err | |
| 205 | + | } | |
| 206 | + | ||
| 182 | 207 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 183 | 208 | zerowrap.FieldLayer: "usecase", | |
| 184 | 209 | zerowrap.FieldUseCase: "FollowContainerLogs", | |
| @@ -140,8 +140,29 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch | |||||
| 140 | return ch, nil | 140 | return ch, nil | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | + | // validateDomain checks that domain is safe for use in logs and error messages. | |||
| 144 | + | func validateDomain(domain string) error { | |||
| 145 | + | if domain == "" { | |||
| 146 | + | return fmt.Errorf("domain cannot be empty") | |||
| 147 | + | } | |||
| 148 | + | if len(domain) > 253 { | |||
| 149 | + | return fmt.Errorf("domain too long") | |||
| 150 | + | } | |||
| 151 | + | if strings.Contains(domain, "..") { | |||
| 152 | + | return fmt.Errorf("invalid domain") | |||
| 153 | + | } | |||
| 154 | + | if strings.ContainsAny(domain, "\x00\n\r") { | |||
| 155 | + | return fmt.Errorf("invalid domain") | |||
| 156 | + | } | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 143 | // GetContainerLogs returns the last N lines of container logs for a domain. | 160 | // GetContainerLogs returns the last N lines of container logs for a domain. | ||
| 144 | func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) { | 161 | func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int) ([]string, error) { | ||
| 162 | + | if err := validateDomain(domain); err != nil { | |||
| 163 | + | return nil, err | |||
| 164 | + | } | |||
| 165 | + | ||||
| 145 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 166 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 146 | zerowrap.FieldLayer: "usecase", | 167 | zerowrap.FieldLayer: "usecase", | ||
| 147 | zerowrap.FieldUseCase: "GetContainerLogs", | 168 | zerowrap.FieldUseCase: "GetContainerLogs", | ||
| @@ -179,6 +200,10 @@ func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int | |||||
| 179 | 200 | ||||
| 180 | // FollowContainerLogs returns a channel that streams container log lines. | 201 | // FollowContainerLogs returns a channel that streams container log lines. | ||
| 181 | func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) { | 202 | func (s *Service) FollowContainerLogs(ctx context.Context, domain string, initialLines int) (<-chan string, error) { | ||
| 203 | + | if err := validateDomain(domain); err != nil { | |||
| 204 | + | return nil, err | |||
| 205 | + | } | |||
| 206 | + | ||||
| 182 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 207 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 183 | zerowrap.FieldLayer: "usecase", | 208 | zerowrap.FieldLayer: "usecase", | ||
| 184 | zerowrap.FieldUseCase: "FollowContainerLogs", | 209 | zerowrap.FieldUseCase: "FollowContainerLogs", | ||