Skip to content

Commit 04a6c83

Browse files
sarg3ntclaude
andcommitted
fix(security): close still-valid Copilot review concerns from merged PRs
Triage of Copilot comments on PRs #38#43 (merged) identified seven items where the concern is still valid in main. Bundled here rather than split into separate per-finding PRs. Dashboard (gearbox/): - login.go: isSafeReturnURL now rejects ASCII control characters (< 0x20, 0x7F). Without this, %0d%0a-encoded paths and embedded tabs could survive the open-redirect allowlist and feed into downstream header-injection attempts (P0-4 follow-up from PR #39). - security_headers.go: validCSPDirective now allows base64 chars (`+`, `=`) so nonce-... / sha256/384/512-... source expressions aren't silently dropped. Both sanitizers now use a shared cspContainsForbidden helper that rejects ALL ASCII control chars and any non-ASCII rune, catching form-feed and unicode whitespace that the previous hand-curated ContainsAny lists missed (P1-4 follow-up from PR #43). Agent (gearbox-agent/): - parser.go: sanitizeName output is now capped at 25 chars so the default backend name {app}_{service}_backend stays inside validBackendName's 63-char limit. A long app or service directory name would otherwise build a default that fails validation and silently drops the whole backend (P0-1 follow-up from PR #39). - websocket.go: canonicalHost uses net.SplitHostPort instead of strings.Index(":"), which corrupted IPv6 host parsing ("[::1]:8405" was treated as host="[" / port=":1]:8405"). (P1-5 follow-up from PR #42). - backoff.go: NewBackoffTracker now clamps zero/negative window, baseDelay, threshold to safe minimums. cleanupLoop's time.NewTicker would otherwise panic on a zero window (P1-7 follow-up from PR #42). - ratelimit.go: "map at capacity" warn log is throttled to one per minute. Without throttling, a distributed scan that fills the client map drove a log-write per denied IP, turning the structured-log pipeline into a secondary disk/ingest DoS surface (P1-6 follow-up from PR #42). - fail2ban.go: comment said "unit argument" but the code handles fail2ban jails — wording drift left over from copy/paste of a systemctl-related comment (P1-3 follow-up from PR #41). Skipped (not material in this deployment): - APIKeyAuth using its own IP extractor rather than the rate limiter's (only diverges behind a trusted proxy; we're not). - Retry-After hardcoded to 60 (UX nit, not security). - Fork-PR handling in claude-security-review workflow (no fork PRs in this repo). - Historical "recommended fix order" wording in 2026-05-findings.md (the section is explicitly retrospective). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c174f39 commit 04a6c83

7 files changed

Lines changed: 102 additions & 16 deletions

File tree

gearbox-agent/internal/api/websocket.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"encoding/json"
55
"log/slog"
6+
"net"
67
"net/http"
78
"net/url"
89
"os"
@@ -50,18 +51,24 @@ func canonicalOrigin(s string) (string, bool) {
5051
// scheme) for comparison against canonicalOrigin output. The scheme is
5152
// derived from r.TLS at the call site, since the Host header itself carries
5253
// no scheme. Same default-port stripping rules as canonicalOrigin.
54+
//
55+
// IPv6 hosts are bracketed in Host headers ("[::1]:8405") — net.SplitHostPort
56+
// strips the brackets correctly; a hand-rolled strings.Index(":") parser
57+
// would split on the first ':' inside the address and corrupt the comparison.
58+
// 2026-05 audit P1-5 follow-up (Copilot review on PR #42).
5359
func canonicalHost(scheme, hostPort string) string {
5460
scheme = strings.ToLower(scheme)
55-
host := strings.ToLower(hostPort)
56-
if i := strings.Index(host, ":"); i >= 0 {
57-
port := host[i+1:]
58-
host = host[:i]
59-
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
60-
return scheme + "://" + host
61-
}
62-
return scheme + "://" + host + ":" + port
61+
host, port, err := net.SplitHostPort(strings.ToLower(hostPort))
62+
if err != nil {
63+
// No port — the whole value is the host. Strip surrounding
64+
// brackets in case it's a bare IPv6 literal.
65+
host = strings.Trim(strings.ToLower(hostPort), "[]")
66+
return scheme + "://" + host
67+
}
68+
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
69+
return scheme + "://" + host
6370
}
64-
return scheme + "://" + host
71+
return scheme + "://" + host + ":" + port
6572
}
6673

6774
const (

gearbox-agent/internal/framework/middleware/backoff.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,24 @@ type failureRecord struct {
4747
// window = 5 minutes
4848
// baseDelay = 10 seconds
4949
// maxDelay = 30 minutes
50+
//
51+
// Invalid inputs (non-positive durations or threshold) are clamped to
52+
// safe defaults rather than panicking — cleanupLoop's time.NewTicker
53+
// would otherwise panic on a zero/negative window. 2026-05 audit P1-7
54+
// follow-up (Copilot review on PR #42).
5055
func NewBackoffTracker(threshold int, window, baseDelay, maxDelay time.Duration, logger *slog.Logger) *BackoffTracker {
56+
if threshold < 1 {
57+
threshold = 1
58+
}
59+
if window <= 0 {
60+
window = 5 * time.Minute
61+
}
62+
if baseDelay <= 0 {
63+
baseDelay = 10 * time.Second
64+
}
65+
if maxDelay < baseDelay {
66+
maxDelay = baseDelay
67+
}
5168
bt := &BackoffTracker{
5269
failures: make(map[string]*failureRecord),
5370
threshold: threshold,

gearbox-agent/internal/framework/middleware/ratelimit.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,19 @@ type RateLimiter struct {
3636
logger *slog.Logger
3737
trustProxy bool // Whether to trust X-Forwarded-For headers
3838
stopCleanup chan struct{} // Signal to stop cleanup goroutine
39+
40+
// capacityWarnInterval throttles the "map at capacity" warning so a
41+
// distributed scanner can't turn the structured-log pipeline into a
42+
// disk/ingest amplification vector. 2026-05 audit P1-6 follow-up
43+
// (Copilot review on PR #42).
44+
lastCapacityWarn time.Time
3945
}
4046

47+
// capacityWarnInterval is the minimum time between "map at capacity"
48+
// warnings. One per minute is enough for ops visibility (the condition
49+
// is sticky once it starts) and slow enough to make log-DoS impractical.
50+
const capacityWarnInterval = time.Minute
51+
4152
type clientBucket struct {
4253
tokens float64
4354
lastUpdate time.Time
@@ -90,11 +101,19 @@ func (rl *RateLimiter) Allow(ip string) bool {
90101
// Still full after premature cleanup. Deny rather than
91102
// allocate. The denied IP gets back in next time someone
92103
// else's bucket goes stale.
93-
if rl.logger != nil {
104+
//
105+
// Throttle the warn log: under a distributed scan this
106+
// fires on every new IP, so unbounded logging would let
107+
// the attacker drive disk/ingest cost (a secondary DoS).
108+
// One warning per capacityWarnInterval is enough — the
109+
// condition is sticky once it starts.
110+
if rl.logger != nil && now.Sub(rl.lastCapacityWarn) >= capacityWarnInterval {
94111
rl.logger.Warn("Rate limiter map at capacity; denying new client",
95112
"ip", ip,
96113
"map_size", len(rl.clients),
114+
"throttled_until", now.Add(capacityWarnInterval).Format(time.RFC3339),
97115
)
116+
rl.lastCapacityWarn = now
98117
}
99118
return false
100119
}

gearbox-agent/internal/framework/services/compose/parser.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,15 +384,30 @@ func (p *Parser) parseDependsOn(dependsOn any) []string {
384384
}
385385

386386
// sanitizeName sanitizes a name for use in HAProxy backend names.
387+
//
388+
// The output is capped at maxSanitizedNameLen so that the default backend
389+
// name built by callers (`{app}_{service}_backend`, ~8 chars of fixed
390+
// suffix + 1 separator) reliably fits inside validBackendName's 63-char
391+
// cap. Without this, a very long app or service directory name would
392+
// build a default that fails the validator and silently drops the whole
393+
// backend. 2026-05 audit P0-1 follow-up (Copilot review on PR #39).
387394
func sanitizeName(name string) string {
395+
const maxSanitizedNameLen = 25 // 25 + 1 + 25 + 1 + "backend" (7) = 59 ≤ 63
388396
// Replace invalid characters with underscore
389397
re := regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
390398
sanitized := re.ReplaceAllString(name, "_")
391399
// Replace multiple consecutive underscores
392400
re = regexp.MustCompile(`_+`)
393401
sanitized = re.ReplaceAllString(sanitized, "_")
394402
// Remove leading/trailing underscores
395-
return strings.Trim(sanitized, "_")
403+
sanitized = strings.Trim(sanitized, "_")
404+
if len(sanitized) > maxSanitizedNameLen {
405+
sanitized = sanitized[:maxSanitizedNameLen]
406+
// Trim a trailing separator that the truncation may have exposed
407+
// (e.g. ".my-app." → ".my-app").
408+
sanitized = strings.TrimRight(sanitized, "_.-")
409+
}
410+
return sanitized
396411
}
397412

398413
// getOrDefault returns the value for a key or a default if not present.

gearbox-agent/internal/gears/security/fail2ban.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ func (c *Fail2BanCollector) getJails() ([]string, error) {
158158
// getJailStats returns statistics for a specific jail.
159159
//
160160
// Defense-in-depth: the jail name is verified to match validJailName before
161-
// being passed to fail2ban-client, and "--" separates flags from the unit
161+
// being passed to fail2ban-client, and "--" separates flags from the jail
162162
// argument. Today jailName always comes from getJails() (which parses
163163
// fail2ban-client's own output), but a malformed jail configuration on the
164164
// host could otherwise produce a value that fail2ban-client itself would

gearbox/internal/framework/handler/login.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,15 @@ func isSafeReturnURL(s string) bool {
3434
return false
3535
}
3636
for i := 0; i < len(s); i++ {
37-
if s[i] == '\\' {
37+
c := s[i]
38+
if c == '\\' {
39+
return false
40+
}
41+
// Reject ASCII control characters (including \r, \n, \t, NUL,
42+
// DEL). Paths containing these are not valid URLs and have been
43+
// used in header-injection / open-redirect sneak paths. 2026-05
44+
// audit P0-4 follow-up (Copilot review on PR #39).
45+
if c < 0x20 || c == 0x7f {
3846
return false
3947
}
4048
}

gearbox/internal/framework/middleware/security_headers.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,28 @@ import (
1919
// or newline. We keep the validator deliberately conservative: only
2020
// alphanumerics, a small punctuation set, and quoted keywords.
2121
//
22+
// `+` and `=` are included so nonce-... and sha256/sha384/sha512-...
23+
// expressions whose payload uses the standard base64 alphabet aren't
24+
// silently dropped. 2026-05 audit P1-4 follow-up (Copilot review on
25+
// PR #43).
26+
//
2227
// 2026-05 audit P1-4.
23-
var validCSPDirective = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]+(\s+[a-zA-Z0-9'_:/.\-*]+)+$`)
28+
var validCSPDirective = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]+(\s+[a-zA-Z0-9'_:/.\-*+=]+)+$`)
29+
30+
// cspContainsForbidden returns true when s contains any byte that has no
31+
// business appearing in a CSP header value. A hand-curated whitespace
32+
// list misses codepoints like form-feed (\f) and the various unicode
33+
// space chars, so we reject ASCII control chars (< 0x20, 0x7F) and any
34+
// rune outside ASCII as a precaution against header smuggling via the
35+
// CSP env vars. 2026-05 audit P1-4 follow-up (Copilot review on PR #43).
36+
func cspContainsForbidden(s string) bool {
37+
for _, r := range s {
38+
if r < 0x20 || r == 0x7f || r > 0x7e {
39+
return true
40+
}
41+
}
42+
return false
43+
}
2444

2545
// sanitizeCSPExtraSource accepts a single entry from the
2646
// CSP_EXTRA_SOURCES comma-separated env var and returns the trimmed
@@ -33,7 +53,7 @@ func sanitizeCSPExtraSource(s string) (string, bool) {
3353
if trimmed == "" {
3454
return "", false
3555
}
36-
if strings.ContainsAny(trimmed, ";\r\n\t") {
56+
if strings.ContainsAny(trimmed, ";") || cspContainsForbidden(trimmed) {
3757
return "", false
3858
}
3959
if !validCSPDirective.MatchString(trimmed) {
@@ -52,7 +72,7 @@ func sanitizeCSPReportURI(s string) (string, bool) {
5272
if trimmed == "" {
5373
return "", false
5474
}
55-
if strings.ContainsAny(trimmed, " \t\r\n;") {
75+
if strings.ContainsAny(trimmed, " ;") || cspContainsForbidden(trimmed) {
5676
return "", false
5777
}
5878
u, err := url.Parse(trimmed)

0 commit comments

Comments
 (0)