Skip to content

Commit 2104960

Browse files
authored
fix(security): P1-1 + P1-3 — reject flag-shaped names in systemctl + fail2ban calls (#41)
* fix(security): reject systemctl unit names that look like flags The HTTP handler for POST /api/v1/services/{name}/{action} validated the service name with a per-character whitelist that allowed "-" anywhere, including position 0. A name like "--all", "--no-block", or "-h" passed validation and reached exec.Command("systemctl", action, name), where systemctl treats it as a flag instead of a unit. Discrete-arg exec.Command prevents shell injection but not flag injection — the kernel doesn't know which argv slot is "the flags" and which is "the operand." Concrete impact: a caller authenticated to the agent could pass action=start and service=--all, producing `systemctl start --all`, which systemd interprets as "start every available unit." Lower-privilege flags are similar — `--no-block` (don't wait), `--state=...` (filter), etc. Fix: - New validUnitName regex (`^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,255}$`) anchors the first character to alphanumeric so flag-shaped names are rejected at the HTTP boundary. Subsequent characters allow the systemd unit charset; total length unchanged (256 cap). - ControlService passes "--" before the unit name in exec.Command as defense-in-depth. Tests pin down both the legitimate-name pass list (sshd.service, haproxy, getty@tty1.service, etc.) and the attacker-controlled reject list (--all, -h, --no-block, .leading-dot, names with whitespace/semicolons/$()). P1-1 from the 2026-05 security audit. * fix(security): validate fail2ban jail names before exec; add `--` separator getJailStats called `fail2ban-client status <jail>` with the jail name flowing in from getJails(), which parses `fail2ban-client status` output. The only flow today is internal: agent → fail2ban-client → parse → fail2ban-client. There is no HTTP path that lets a caller specify a jail name directly, so this is defense-in-depth rather than a closed exploit. Two scenarios are still worth defending against: 1. A future code path that exposes a jail-name parameter to HTTP input (e.g. "force fail2ban-client to refresh jail X"), where the same flag-injection class as P1-1 (systemctl) would re-emerge. 2. A malicious or malformed fail2ban configuration that produces a weird jail name. fail2ban itself accepts jail names that include characters our parser passes through unchecked, so a jail named "--help" on a misconfigured host would crash the agent's collection loop or worse. Fix: - New validJailName regex (`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`) anchors the first character to alphanumeric. getJailStats rejects mismatches with an explicit error rather than passing them through. - exec.Command passes "--" before the jail name. Tests cover both legitimate jail names (sshd, haproxy-http, my_jail) and the flag-injection class (--help, -h) plus the usual injection characters (newlines, semicolons, $()). P1-3 from the 2026-05 security audit.
1 parent 6a834fe commit 2104960

5 files changed

Lines changed: 130 additions & 12 deletions

File tree

gearbox-agent/internal/gears/metrics/collector.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,13 @@ func formatDuration(d time.Duration) string {
231231

232232
// ControlService starts, stops, or restarts a systemd service.
233233
// Returns the command output and any error.
234+
//
235+
// The "--" separator before the unit name prevents an attacker-chosen unit
236+
// name (e.g. "--all" or "-h") from being interpreted as a systemctl flag.
237+
// This is defense-in-depth on top of validUnitName regex validation at the
238+
// HTTP boundary (plugin.go). See 2026-05 audit P1-1.
234239
func (c *Collector) ControlService(service, action string) (string, error) {
235-
// Use systemctl to control the service
236-
cmd := exec.Command("systemctl", action, service)
240+
cmd := exec.Command("systemctl", action, "--", service)
237241
output, err := cmd.CombinedOutput()
238242
return strings.TrimSpace(string(output)), err
239243
}

gearbox-agent/internal/gears/metrics/collector_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package metrics
22

33
import (
4+
"strings"
45
"testing"
56
"time"
67
)
@@ -347,3 +348,48 @@ func TestCollectServiceStatuses(t *testing.T) {
347348
t.Errorf("expected non-existent service to be inactive")
348349
}
349350
}
351+
352+
// 2026-05 audit P1-1: service names that could be interpreted as systemctl
353+
// flags must be rejected before they reach exec.Command. The HTTP handler
354+
// in plugin.go matches against validUnitName, and ControlService also passes
355+
// "--" before the unit name as defense-in-depth — but the regex is the
356+
// primary gate.
357+
func TestValidUnitName(t *testing.T) {
358+
tests := []struct {
359+
name string
360+
want bool
361+
}{
362+
// Real-world systemd unit names that must continue to work.
363+
{"sshd.service", true},
364+
{"haproxy", true},
365+
{"docker.socket", true},
366+
{"getty@tty1.service", true},
367+
{"systemd-journald.service", true},
368+
{"my_service", true},
369+
{"a", true},
370+
371+
// Attacker-controlled values that must be rejected. The most
372+
// important class: names whose first character is "-" so that
373+
// exec.Command("systemctl", action, name) would treat the name
374+
// as a flag.
375+
{"", false},
376+
{"--all", false},
377+
{"-h", false},
378+
{"--no-block", false},
379+
{"--help", false},
380+
{".leading-dot", false},
381+
{"@leading-at", false},
382+
{"name with space", false},
383+
{"name\nwith-newline", false},
384+
{"name;rm -rf /", false},
385+
{"name$(whoami)", false},
386+
{strings.Repeat("a", 257), false}, // length cap (256 chars allowed)
387+
}
388+
for _, tt := range tests {
389+
t.Run(tt.name, func(t *testing.T) {
390+
if got := validUnitName.MatchString(tt.name); got != tt.want {
391+
t.Errorf("validUnitName.MatchString(%q) = %v, want %v", tt.name, got, tt.want)
392+
}
393+
})
394+
}
395+
}

gearbox-agent/internal/gears/metrics/plugin.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"net/http"
8+
"regexp"
89
"strings"
910
"time"
1011

@@ -13,6 +14,14 @@ import (
1314
"github.com/sarg3nt/gearbox-agent/internal/framework/gear"
1415
)
1516

17+
// validUnitName matches a systemd unit name that is safe to pass to systemctl
18+
// without risk of flag injection. Must start with an alphanumeric so a value
19+
// like "--all" or "-h" can't be interpreted as a systemctl flag; subsequent
20+
// characters match the systemd unit charset (alphanumerics plus `.`, `-`,
21+
// `_`, `@`). Length capped at 256 to match the prior length check.
22+
// See 2026-05 security audit, P1-1.
23+
var validUnitName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,255}$`)
24+
1625
func init() {
1726
gear.Register(&Gear{})
1827
}
@@ -267,18 +276,17 @@ func (p *Gear) handleServiceControl(w http.ResponseWriter, r *http.Request) {
267276
return
268277
}
269278

270-
// Validate service name (basic sanity check - prevent command injection)
271-
if req.Service == "" || len(req.Service) > 256 {
279+
// Validate service name. The first character MUST be alphanumeric so a
280+
// crafted name like "--all" or "--no-block" cannot be passed to systemctl
281+
// as a flag (discrete-arg exec prevents shell injection but not flag
282+
// injection). Subsequent characters allow the usual systemd unit charset
283+
// (alphanumeric plus . - _ @). collector.ControlService also passes "--"
284+
// to systemctl before the unit name as defense-in-depth.
285+
// See 2026-05 security audit, P1-1.
286+
if !validUnitName.MatchString(req.Service) {
272287
http.Error(w, "Invalid service name", http.StatusBadRequest)
273288
return
274289
}
275-
// Only allow alphanumeric, dash, underscore, at-sign, and dot in service names
276-
for _, c := range req.Service {
277-
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '@' || c == '.') {
278-
http.Error(w, "Invalid character in service name", http.StatusBadRequest)
279-
return
280-
}
281-
}
282290

283291
// Execute systemctl command
284292
output, err := p.collector.ControlService(req.Service, req.Action)

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ var ErrServiceNotInstalled = errors.New("service not installed")
1919
// Pre-compiled regex for fail2ban log parsing.
2020
var banRegex = regexp.MustCompile(`(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*\[(\w+)\] (Ban|Unban) (\S+)`)
2121

22+
// validJailName matches a fail2ban jail name that is safe to pass to
23+
// fail2ban-client. The first character MUST be alphanumeric so a value
24+
// like "--help" or "-h" cannot be interpreted as a fail2ban-client flag
25+
// (discrete-arg exec prevents shell injection but not flag injection).
26+
// Subsequent characters allow alphanumerics, underscore, and hyphen.
27+
//
28+
// Jail names today flow from the agent's own `fail2ban-client status`
29+
// parse (not from HTTP input), so this is defense-in-depth — if a future
30+
// code path ever accepts a jail name from API input, or if a malicious
31+
// fail2ban config produces a weird jail name, this guard prevents the
32+
// value from being interpreted as a fail2ban-client flag. See 2026-05
33+
// audit P1-3.
34+
var validJailName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
35+
2236
// Fail2BanStats represents overall fail2ban statistics.
2337
type Fail2BanStats struct {
2438
Available bool `json:"available"` // Whether fail2ban is installed
@@ -142,10 +156,21 @@ func (c *Fail2BanCollector) getJails() ([]string, error) {
142156
}
143157

144158
// getJailStats returns statistics for a specific jail.
159+
//
160+
// Defense-in-depth: the jail name is verified to match validJailName before
161+
// being passed to fail2ban-client, and "--" separates flags from the unit
162+
// argument. Today jailName always comes from getJails() (which parses
163+
// fail2ban-client's own output), but a malformed jail configuration on the
164+
// host could otherwise produce a value that fail2ban-client itself would
165+
// interpret as a flag. See 2026-05 audit P1-3.
145166
func (c *Fail2BanCollector) getJailStats(jailName string, includeIPs bool) (JailStats, error) {
146167
stats := JailStats{Name: jailName}
147168

148-
cmd := exec.Command("fail2ban-client", "status", jailName)
169+
if !validJailName.MatchString(jailName) {
170+
return stats, fmt.Errorf("invalid jail name: %q", jailName)
171+
}
172+
173+
cmd := exec.Command("fail2ban-client", "status", "--", jailName)
149174
output, err := cmd.Output()
150175
if err != nil {
151176
return stats, err

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package security
22

33
import (
4+
"strings"
45
"testing"
56
)
67

@@ -104,3 +105,37 @@ func TestJailStatsFields(t *testing.T) {
104105
t.Errorf("BannedIPs len = %d, want 2", len(stats.BannedIPs))
105106
}
106107
}
108+
109+
// 2026-05 audit P1-3: jail names that could be interpreted as fail2ban-client
110+
// flags must be rejected before they reach exec.Command. Today the only flow
111+
// is from getJails() (which parses fail2ban-client's own output), but this
112+
// pins down the validation in case a future code path exposes jail names to
113+
// API input.
114+
func TestValidJailName(t *testing.T) {
115+
tests := []struct {
116+
name string
117+
want bool
118+
}{
119+
{"sshd", true},
120+
{"haproxy-http", true},
121+
{"my_jail", true},
122+
{"jail-1", true},
123+
{"a", true},
124+
125+
{"", false},
126+
{"--help", false},
127+
{"-h", false},
128+
{"jail with space", false},
129+
{"jail\nwith-newline", false},
130+
{"jail;rm -rf /", false},
131+
{"jail$(whoami)", false},
132+
{strings.Repeat("a", 65), false}, // length cap
133+
}
134+
for _, tt := range tests {
135+
t.Run(tt.name, func(t *testing.T) {
136+
if got := validJailName.MatchString(tt.name); got != tt.want {
137+
t.Errorf("validJailName.MatchString(%q) = %v, want %v", tt.name, got, tt.want)
138+
}
139+
})
140+
}
141+
}

0 commit comments

Comments
 (0)