Skip to content

Commit ff336e2

Browse files
sarg3ntclaude
andauthored
fix(security): P2 agent batch (P2-5/7/9/10) (#51)
* fix(security): strip version + uptime from /health response The unauthenticated /health endpoint returned: {"status":"ok","version":"1.2.3","uptime":"2h30m15s","timestamp":...} `version` is populated from compile-time ldflags. A remote scanner probing /health learns the exact agent build and can correlate against public CVEs without needing to authenticate. `uptime` reveals whether the agent has been recently restarted (i.e. whether a patch was applied). `timestamp` is mostly harmless but adds another datum to a fingerprint. Authenticated callers that legitimately need the version still get it from /api/v1/metadata. The Handlers struct keeps the `version` and `startTime` fields so the NewHandlers signature doesn't churn — they're just no longer exposed via /health. Test asserts the response body is exactly `{"status":"ok"}` and contains none of the substrings "1.2.3-leakytest", "uptime", or "timestamp". P2-5 from the 2026-05 security audit. * fix(security): force TLS 1.3 minimum on the agent HTTPS listener http.Server with no explicit TLSConfig falls through to Go's default, which is TLS 1.2 floor. Acceptable per RFC, but it leaves the agent willing to negotiate cipher suites that have been deprecated for years (CBC modes, RC4 if a downgrade happens, etc.). Both ends of the agent <-> dashboard channel are software we control; there's no legitimate reason to support pre-1.3. Explicit MinVersion: tls.VersionTLS13. A client that genuinely cannot speak TLS 1.3 (Go ≥ 1.12, OpenSSL ≥ 1.1.1, Node ≥ 10.13, basically anything from the last 8 years) will fail the handshake — which is the correct behavior. The dashboard binary that talks to this agent is built with the same Go toolchain; it speaks 1.3 by default. P2-7 from the 2026-05 security audit. * fix(security): tighten apt package-name validation; add `--` separator The /api/v1/packages/install and /packages/remove handlers validated input as "non-empty AND ≤200 chars" before passing to apt-get. The package-manager layer (isValidPackageName) already had the strict Debian-style regex + leading-hyphen rejection, so the practical exploit window was small — but errors surfaced as generic 500s and the boundary check was misleadingly weak. Two changes: 1. Handlers now call isValidPackageName directly. Invalid names return 400 with a clear message instead of falling through to a 500 from the package-manager layer. 2. apt-get and apt-mark invocations get an explicit "--" between flags and the package operand: apt-get install -y -- <name> apt-get remove -y -- <name> apt-get install -y --allow-downgrades -- <pkgs...> apt-mark hold -- <name> apt-mark unhold -- <name> Defense-in-depth: a future loosening of isValidPackageName (or a missed validation site, or a new caller that forgets to call it) still can't smuggle a "package" name that apt-get would parse as a flag. Tests cover the attack class: --allow-downgrades, --reinstall, -y, --help, .bashrc, name with spaces / semicolons / $() / newlines / slashes. P2-9 from the 2026-05 security audit. * fix(security): validate haproxy.acl.path + haproxy.acl.header at parse time The audit's P2-10 framing was "drop the dead ACLPath/ACLHeader fields," but those labels are documented in ubuntu-ha-proxy-install/docs/architecture.md and ubuntu-ha-proxy-install/docs/example-docker-compose.yml as supported forward-looking features ("Path-based routing", "Custom ACL conditions"). Deleting the BackendConfig fields would break that documented API. Instead, validate them now so the latent injection class (same shape as P0-1 BackendName and P0-2 ACLIP) is closed before any future PR wires them into a generated directive like: acl req_path path_beg <path> acl req_hdr_cnt(<header>) gt 0 Two new validators in parser.go: - validACLPath: leading slash + URL-path-safe charset (`^/[a-zA-Z0-9/_.\-~%]*$`). Rejects embedded newlines, semicolons, whitespace, query strings, fragments. - validACLHeader: RFC 7230 HTTP header token characters (`^[a-zA-Z0-9!#$%&'*+\-.^_`+"`"+`|~]+$`). Rejects newlines, colons, slashes, spaces. extractBackendConfig now rejects the whole backend if either field has a non-empty malformed value. Empty values (the overwhelmingly common case) skip validation as today. Tests cover the legitimate-shape pass list, the injection class (newline + HAProxy directive payload), and the common-injection- character reject list. P2-10 from the 2026-05 security audit. * fix(security): address P2 agent batch Copilot review - pm_apt.go: add "--" separator to BuildInstallCommand + InstallUpdates install paths. Was already on InstallPackage/RemovePackage but the two bulk-install paths were the actual gap — defense-in-depth against a caller that skips isValidPackageName. - handlers.go: remove dead Handlers.version and Handlers.startTime fields. They were set but never read once the /health response was stripped to {status: "ok"}. Update HealthResponse doc to drop the inaccurate "available via /api/v1/metadata" claim — MetadataResponse exposes HAProxy metadata, not agent version/uptime. - server.go: rewrite the TLS-1.3-floor comment. The previous text claimed Go's TLS 1.2 defaults included CBC and RC4; RC4 has been removed from Go's defaults for years and the default cipher suites are mostly AEAD. The concrete reason (shrink negotiation surface, guarantee AEAD + forward secrecy, prevent downgrade) is what matters. - docs/: regenerate Swagger schema so the published OpenAPI doc matches the stripped HealthResponse (was still advertising version, uptime, timestamp fields). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3a1cf94 commit ff336e2

12 files changed

Lines changed: 847 additions & 615 deletions

File tree

gearbox-agent/docs/docs.go

Lines changed: 198 additions & 210 deletions
Large diffs are not rendered by default.

gearbox-agent/docs/swagger.json

Lines changed: 198 additions & 210 deletions
Large diffs are not rendered by default.

gearbox-agent/docs/swagger.yaml

Lines changed: 147 additions & 155 deletions
Large diffs are not rendered by default.

gearbox-agent/internal/api/handlers.go

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,42 +18,36 @@ type MetadataProvider interface {
1818
// Handlers holds HTTP handlers and their dependencies.
1919
type Handlers struct {
2020
metadataProvider MetadataProvider
21-
version string
22-
startTime time.Time
2321
}
2422

2523
// NewHandlers creates a new Handlers instance.
26-
func NewHandlers(metadataProvider MetadataProvider, version string) *Handlers {
24+
func NewHandlers(metadataProvider MetadataProvider) *Handlers {
2725
return &Handlers{
2826
metadataProvider: metadataProvider,
29-
version: version,
30-
startTime: time.Now(),
3127
}
3228
}
3329

3430
// HealthResponse represents the health check response.
31+
//
32+
// Only "status" is returned on the unauthenticated /health endpoint to avoid
33+
// leaking version / uptime to remote scanners probing for known-vulnerable
34+
// agent versions. Version and build info are still available on the build
35+
// itself (--version) and through service logs, but are not exposed over the
36+
// network on unauthenticated endpoints. See 2026-05 security audit P2-5.
3537
type HealthResponse struct {
36-
Status string `json:"status" example:"ok"`
37-
Version string `json:"version" example:"1.0.0"`
38-
Uptime string `json:"uptime" example:"2h30m15s"`
39-
Timestamp time.Time `json:"timestamp" example:"2024-01-17T10:30:00Z"`
38+
Status string `json:"status" example:"ok"`
4039
}
4140

4241
// Health handles GET /health (no auth required).
4342
//
4443
// @Summary Health check
45-
// @Description Returns the health status of the gearbox-agent service. No authentication required.
44+
// @Description Returns the health status of the gearbox-agent service. No authentication required. Only "status" is exposed; version and uptime are intentionally omitted from this unauthenticated endpoint to avoid fingerprinting by remote scanners.
4645
// @Tags Health
4746
// @Produce json
4847
// @Success 200 {object} HealthResponse "Service is healthy"
4948
// @Router /health [get]
5049
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
51-
resp := HealthResponse{
52-
Status: "ok",
53-
Version: h.version,
54-
Uptime: time.Since(h.startTime).Round(time.Second).String(),
55-
Timestamp: time.Now(),
56-
}
50+
resp := HealthResponse{Status: "ok"}
5751

5852
w.Header().Set("Content-Type", "application/json")
5953
json.NewEncoder(w).Encode(resp)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/sarg3nt/gearbox-agent/internal/framework/services/haproxy"
12+
)
13+
14+
// stubMetadataProvider is the minimal MetadataProvider needed for handler
15+
// construction. Not actually exercised by the /health test.
16+
type stubMetadataProvider struct{}
17+
18+
func (stubMetadataProvider) GetLastSyncTime() time.Time { return time.Time{} }
19+
func (stubMetadataProvider) GetLastError() error { return nil }
20+
func (stubMetadataProvider) GetMetadata() *haproxy.Metadata { return nil }
21+
22+
// 2026-05 audit P2-5: the unauthenticated /health endpoint must not leak
23+
// version or uptime. A remote scanner probing for known-vulnerable agent
24+
// versions should get "ok" and nothing else.
25+
func TestHealth_NoVersionLeak(t *testing.T) {
26+
h := NewHandlers(stubMetadataProvider{})
27+
28+
req := httptest.NewRequest("GET", "/health", nil)
29+
rr := httptest.NewRecorder()
30+
h.Health(rr, req)
31+
32+
if rr.Code != http.StatusOK {
33+
t.Fatalf("status = %d, want 200", rr.Code)
34+
}
35+
36+
body := rr.Body.String()
37+
for _, leak := range []string{"1.2.3-leakytest", "uptime", "timestamp"} {
38+
if strings.Contains(strings.ToLower(body), strings.ToLower(leak)) {
39+
t.Errorf("/health response contains %q (potential info leak): %s", leak, body)
40+
}
41+
}
42+
43+
// Sanity: the actual response should be a small {"status":"ok"} JSON.
44+
var got map[string]any
45+
if err := json.Unmarshal([]byte(body), &got); err != nil {
46+
t.Fatalf("response is not valid JSON: %v (%q)", err, body)
47+
}
48+
if got["status"] != "ok" {
49+
t.Errorf("status = %v, want \"ok\"", got["status"])
50+
}
51+
if len(got) != 1 {
52+
t.Errorf("response has %d fields; want exactly 1 (status): %v", len(got), got)
53+
}
54+
}

gearbox-agent/internal/api/server.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package api
55

66
import (
77
"context"
8+
"crypto/tls"
89
"fmt"
910
"log/slog"
1011
"net/http"
@@ -69,7 +70,7 @@ type ServerConfig struct {
6970
// NewServer creates a new API server.
7071
func NewServer(cfg ServerConfig) *Server {
7172
// Create handlers
72-
handlers := NewHandlers(cfg.MetadataProvider, cfg.Version)
73+
handlers := NewHandlers(cfg.MetadataProvider)
7374

7475
// Create chi router
7576
r := chi.NewRouter()
@@ -157,6 +158,16 @@ func NewServer(cfg ServerConfig) *Server {
157158
ReadTimeout: 15 * time.Second,
158159
WriteTimeout: 15 * time.Second,
159160
IdleTimeout: 60 * time.Second,
161+
// Explicit TLS 1.3 floor. Go's default minimum is TLS 1.2 (still
162+
// negotiable by misconfigured or older clients); both ends of the
163+
// agent <-> dashboard channel are controlled by us, so we have
164+
// no reason to leave TLS 1.2 reachable. Pinning to 1.3 shrinks
165+
// the negotiation attack surface (no downgrade, fewer cipher
166+
// suites, AEAD-only, forward secrecy guaranteed). See 2026-05
167+
// security audit P2-7.
168+
TLSConfig: &tls.Config{
169+
MinVersion: tls.VersionTLS13,
170+
},
160171
},
161172
router: r,
162173
logger: cfg.Logger,

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ var (
4343
// newline / directive injection from a Docker Compose label into the
4444
// generated haproxy.cfg (see 2026-05 security audit, P0-1).
4545
validBackendName = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9_.\-]{0,62}$`)
46+
// validACLPath matches a URL path component for an HAProxy path-based
47+
// ACL: leading `/`, then a safe URL-path character set. Used for the
48+
// `haproxy.acl.path` label, which is currently parsed into BackendConfig
49+
// but not yet wired into the generator. Validating now closes the
50+
// latent injection class — if a future PR wires this into a generated
51+
// `acl req_path path_beg %s` directive, no flag/newline can slip
52+
// through. See 2026-05 security audit, P2-10.
53+
validACLPath = regexp.MustCompile(`^/[a-zA-Z0-9/_.\-~%]*$`)
54+
// validACLHeader matches an HTTP header name (RFC 7230 token chars).
55+
// Same forward-looking rationale as validACLPath: parsed but not yet
56+
// generated, validated now.
57+
validACLHeader = regexp.MustCompile(`^[a-zA-Z0-9!#$%&'*+\-.^_` + "`" + `|~]+$`)
4658
)
4759

4860
// validateACLIPList validates a comma-separated list of IPs/CIDRs against
@@ -312,6 +324,31 @@ func (p *Parser) extractBackendConfig(labels map[string]string, appName, service
312324
return nil
313325
}
314326

327+
// 2026-05 audit P2-10: haproxy.acl.path and haproxy.acl.header are
328+
// documented labels (see ubuntu-ha-proxy-install/docs/) that gearbox-
329+
// agent parses today but does NOT yet emit into the generated config.
330+
// Validate them here so the latent injection class is closed BEFORE
331+
// any future PR wires them into a directive like
332+
// `acl req_path path_beg <path>` or `acl req_hdr_cnt(<header>) gt 0`.
333+
// Empty is accepted (the common case); non-empty must match the
334+
// allow-list pattern, otherwise the backend is rejected.
335+
aclPath := labels[LabelPrefix+"acl.path"]
336+
if aclPath != "" && !validACLPath.MatchString(aclPath) {
337+
p.logger.Warn("Invalid haproxy.acl.path value; rejecting backend",
338+
"app", appName,
339+
"service", serviceName,
340+
)
341+
return nil
342+
}
343+
aclHeader := labels[LabelPrefix+"acl.header"]
344+
if aclHeader != "" && !validACLHeader.MatchString(aclHeader) {
345+
p.logger.Warn("Invalid haproxy.acl.header value; rejecting backend",
346+
"app", appName,
347+
"service", serviceName,
348+
)
349+
return nil
350+
}
351+
315352
// Get and validate configurable values with safe defaults
316353
mode := getValidatedOrDefault(labels, LabelPrefix+"backend.mode", "http", validMode)
317354
balance := getValidatedOrDefault(labels, LabelPrefix+"backend.balance", "roundrobin", validBalance)
@@ -334,8 +371,8 @@ func (p *Parser) extractBackendConfig(labels map[string]string, appName, service
334371
CheckFall: checkFall,
335372
CheckRise: checkRise,
336373
SSLRedirect: labels[LabelPrefix+"ssl.redirect"] != "false",
337-
ACLPath: labels[LabelPrefix+"acl.path"],
338-
ACLHeader: labels[LabelPrefix+"acl.header"],
374+
ACLPath: aclPath,
375+
ACLHeader: aclHeader,
339376
Public: labels[LabelPrefix+"public"] == "true",
340377
RateLimit: rateLimit,
341378
ACLIP: aclIP,

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,3 +757,104 @@ func TestParseFile_InjectionViaACLIP(t *testing.T) {
757757
t.Errorf("ParseFile() returned %d backend(s) for an injection payload; want 0 (rejected)", len(backends))
758758
}
759759
}
760+
761+
// 2026-05 audit P2-10: haproxy.acl.path and haproxy.acl.header are
762+
// validated at parse time even though no current generator emits them.
763+
// Closes the latent injection class before a future PR wires them in.
764+
func TestValidACLPath(t *testing.T) {
765+
tests := []struct {
766+
name string
767+
input string
768+
want bool
769+
}{
770+
{"empty", "", true}, // empty handled by call-site short-circuit
771+
{"root", "/", true},
772+
{"single-segment", "/api", true},
773+
{"multi-segment", "/api/v1/users", true},
774+
{"trailing-slash", "/api/", true},
775+
{"with-dash-underscore", "/api/_v2-beta", true},
776+
{"url-encoded-allowed", "/api/%20space", true},
777+
778+
{"no-leading-slash", "api", false},
779+
{"newline-injection", "/api\n http-request deny", false},
780+
{"semicolon", "/api; default-src *", false},
781+
{"space", "/api /v1", false},
782+
{"backslash", "/api\\v1", false},
783+
{"query-string", "/api?x=1", false},
784+
{"fragment", "/api#section", false},
785+
}
786+
for _, tt := range tests {
787+
t.Run(tt.name, func(t *testing.T) {
788+
if tt.input == "" {
789+
return // call-site skips validation for empty
790+
}
791+
if got := validACLPath.MatchString(tt.input); got != tt.want {
792+
t.Errorf("validACLPath.MatchString(%q) = %v, want %v", tt.input, got, tt.want)
793+
}
794+
})
795+
}
796+
}
797+
798+
func TestValidACLHeader(t *testing.T) {
799+
tests := []struct {
800+
name string
801+
input string
802+
want bool
803+
}{
804+
{"simple", "X-Custom-Header", true},
805+
{"all-lower", "authorization", true},
806+
{"underscore", "X_Custom", true},
807+
{"dot-allowed-by-rfc", "X.Forwarded.For", true},
808+
809+
{"empty", "", false},
810+
{"space", "X Custom", false},
811+
{"newline-injection", "X-Custom\nhttp-request deny", false},
812+
{"semicolon", "X-Custom;evil", false},
813+
{"colon", "X-Custom:value", false},
814+
{"slash", "X-Custom/extra", false},
815+
}
816+
for _, tt := range tests {
817+
t.Run(tt.name, func(t *testing.T) {
818+
if got := validACLHeader.MatchString(tt.input); got != tt.want {
819+
t.Errorf("validACLHeader.MatchString(%q) = %v, want %v", tt.input, got, tt.want)
820+
}
821+
})
822+
}
823+
}
824+
825+
func TestParseFile_InjectionViaACLPath(t *testing.T) {
826+
// 2026-05 audit P2-10: even though the generator doesn't emit
827+
// haproxy.acl.path today, a malicious value must not be stored on
828+
// BackendConfig — otherwise a future generator wire-up would re-open
829+
// the P0-1/P0-2 class of injection.
830+
tmpDir := t.TempDir()
831+
appDir := filepath.Join(tmpDir, "aclpath")
832+
if err := os.MkdirAll(appDir, 0755); err != nil {
833+
t.Fatalf("MkdirAll: %v", err)
834+
}
835+
836+
composeContent := "services:\n" +
837+
" web:\n" +
838+
" image: nginx\n" +
839+
" labels:\n" +
840+
" haproxy.enable: \"true\"\n" +
841+
" haproxy.hostname: \"evil.example.com\"\n" +
842+
" haproxy.backend.server: \"10.0.0.1:80\"\n" +
843+
" haproxy.acl.path: |\n" +
844+
" /api\n" +
845+
" http-request deny\n"
846+
847+
composePath := filepath.Join(appDir, "docker-compose.yml")
848+
if err := os.WriteFile(composePath, []byte(composeContent), 0644); err != nil {
849+
t.Fatalf("WriteFile: %v", err)
850+
}
851+
852+
parser := NewParser(tmpDir, testLogger())
853+
backends, err := parser.ParseFile(composePath)
854+
if err != nil {
855+
t.Fatalf("ParseFile: %v", err)
856+
}
857+
if len(backends) != 0 {
858+
t.Errorf("ParseFile returned %d backend(s); want 0 (rejected on aclpath injection)", len(backends))
859+
}
860+
}

gearbox-agent/internal/gears/updates/apt_runner.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,13 @@ func (r *AptRunner) runAptInstall(ctx context.Context, op *AptOperation, securit
219219
// Build apt command
220220
var args []string
221221
if len(packages) > 0 {
222-
// Install specific packages
223-
args = append([]string{"install", "-y"}, packages...)
222+
// Install specific packages. "--" between the flags and the package
223+
// list prevents a flag-shaped element from being interpreted as an
224+
// apt-get option (2026-05 audit P2-9). The packages slice flows
225+
// from API input; even though every consumer validates with
226+
// isValidPackageName, the explicit separator is a cheap
227+
// defense-in-depth gate.
228+
args = append([]string{"install", "-y", "--"}, packages...)
224229
r.publishLine(op.ID, fmt.Sprintf("Installing %d specific package(s)...", len(packages)))
225230
} else if securityOnly {
226231
// Security updates only - use unattended-upgrade
@@ -502,7 +507,9 @@ func (r *AptRunner) runRestoreSnapshot(ctx context.Context, op *AptOperation, sn
502507
r.publishLine(op.ID, fmt.Sprintf("Downgrading %d package(s) to snapshot versions...", len(downgrades)))
503508
r.publishLine(op.ID, "")
504509

505-
args := append([]string{"install", "-y", "--allow-downgrades"}, downgrades...)
510+
// "--" before downgrades for the same reason as the install path
511+
// above. See 2026-05 audit P2-9.
512+
args := append([]string{"install", "-y", "--allow-downgrades", "--"}, downgrades...)
506513
downgradeCmd := exec.CommandContext(ctx, "apt-get", args...)
507514
downgradeCmd.Env = append(os.Environ(),
508515
"DEBIAN_FRONTEND=noninteractive",
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package updates
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// 2026-05 audit P2-9: isValidPackageName must reject any name a shell user
9+
// would never plausibly type AND any name that apt-get would interpret as
10+
// a flag. The handler-level boundary validation now relies on this.
11+
func TestIsValidPackageName(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
want bool
15+
}{
16+
// Accepted: real Debian-style package names.
17+
{"nginx", true},
18+
{"libssl3", true},
19+
{"python3.11", true},
20+
{"linux-headers-6.1.0-12-amd64", true},
21+
{"libstdc++6", true},
22+
{"foo+bar", true},
23+
{"a", true},
24+
25+
// Rejected: empty / length / character set.
26+
{"", false},
27+
{strings.Repeat("a", 257), false},
28+
{"name with space", false},
29+
{"name;rm /etc/passwd", false},
30+
{"name$(whoami)", false},
31+
{"name\nwith-newline", false},
32+
{"name/with/slash", false},
33+
34+
// Rejected: would land as a flag if passed to apt-get install.
35+
// This is the actual P2-9 attack class.
36+
{"--allow-downgrades", false},
37+
{"--reinstall", false},
38+
{"-y", false},
39+
{"--help", false},
40+
41+
// Rejected: leading dot would resolve relative paths in some
42+
// apt internals.
43+
{".bashrc", false},
44+
}
45+
for _, tt := range tests {
46+
t.Run(tt.name, func(t *testing.T) {
47+
if got := isValidPackageName(tt.name); got != tt.want {
48+
t.Errorf("isValidPackageName(%q) = %v, want %v", tt.name, got, tt.want)
49+
}
50+
})
51+
}
52+
}

0 commit comments

Comments
 (0)