detached 05e46b0

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
88 "fmt"
99 "io"
1010 "net/http"
11+ "net/url"
1112 "strings"
1213 "time"
1314
@@ -137,7 +138,7 @@ func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
137138
138139 // GetRoute returns a specific route by domain.
139140 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)
141142 if err != nil {
142143 return nil, err
143144 }
@@ -161,7 +162,7 @@ func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
161162
162163 // UpdateRoute updates an existing route.
163164 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)
165166 if err != nil {
166167 return err
167168 }
@@ -170,7 +171,7 @@ func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
170171
171172 // RemoveRoute removes a route by domain.
172173 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)
174175 if err != nil {
175176 return err
176177 }
@@ -181,7 +182,7 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
181182
182183 // ListSecrets returns the list of secret keys for a domain.
183184 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)
185186 if err != nil {
186187 return nil, err
187188 }
@@ -199,7 +200,7 @@ func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string
199200
200201 // SetSecrets sets secrets for a domain.
201202 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)
203204 if err != nil {
204205 return err
205206 }
@@ -208,7 +209,7 @@ func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets ma
208209
209210 // DeleteSecret removes a secret from a domain.
210211 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)
212213 if err != nil {
213214 return err
214215 }
@@ -331,7 +332,7 @@ type DeployResult struct {
331332
332333 // Deploy triggers a deployment for the specified domain.
333334 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)
335336 if err != nil {
336337 return nil, err
337338 }
@@ -366,7 +367,7 @@ func (c *Client) GetProcessLogs(ctx context.Context, lines int) ([]string, error
366367
367368 // GetContainerLogs returns container logs for a specific domain.
368369 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)
370371 resp, err := c.request(ctx, http.MethodGet, path, nil)
371372 if err != nil {
372373 return nil, err
@@ -393,7 +394,7 @@ func (c *Client) StreamProcessLogs(ctx context.Context, lines int) (<-chan strin
393394 // StreamContainerLogs returns a channel that streams container log lines via SSE.
394395 // The caller is responsible for reading from the channel until it's closed.
395396 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)
397398 return c.streamLogs(ctx, path)
398399 }
399400
@@ -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, err143 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 err167 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 err176 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, err187 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 err205 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 err214 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, err337 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, err373 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 (
1919 // maxAdminRequestSize is the maximum allowed size for admin API request bodies.
2020 const maxAdminRequestSize = 1 << 20 // 1MB
2121
22+// maxLogLines is the maximum allowed number of log lines that can be requested.
23+const maxLogLines = 10000
24+
2225 // Handler implements the HTTP handler for the admin API.
2326 type Handler struct {
2427 configSvc in.ConfigService
@@ -529,7 +532,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
529532 container, err := h.containerSvc.Deploy(ctx, *route)
530533 if err != nil {
531534 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")
533536 return
534537 }
535538
@@ -572,6 +575,9 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
572575 lines = n
573576 }
574577 }
578+ if lines > maxLogLines {
579+ lines = maxLogLines
580+ }
575581 follow := r.URL.Query().Get("follow") == "true"
576582
577583 // Parse domain from path
@@ -662,7 +668,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line
662668 ch, err := h.logSvc.FollowProcessLogs(ctx, lines)
663669 if err != nil {
664670 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")
666672 flusher.Flush()
667673 return
668674 }
@@ -702,7 +708,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
702708 ch, err := h.logSvc.FollowContainerLogs(ctx, logDomain, lines)
703709 if err != nil {
704710 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")
706712 flusher.Flush()
707713 return
708714 }
@@ -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 // 1MB20 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.ConfigService27 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 return536 return
534 }537 }
535 538
@@ -572,6 +575,9 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
572 lines = n575 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 path583 // 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 return673 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 return713 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
140140 return ch, nil
141141 }
142142
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+
143160 // GetContainerLogs returns the last N lines of container logs for a domain.
144161 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+
145166 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
146167 zerowrap.FieldLayer: "usecase",
147168 zerowrap.FieldUseCase: "GetContainerLogs",
@@ -179,6 +200,10 @@ func (s *Service) GetContainerLogs(ctx context.Context, domain string, lines int
179200
180201 // FollowContainerLogs returns a channel that streams container log lines.
181202 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+
182207 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
183208 zerowrap.FieldLayer: "usecase",
184209 zerowrap.FieldUseCase: "FollowContainerLogs",
@@ -140,8 +140,29 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
140 return ch, nil140 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",

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close