Skip to content

Commit 5e412f7

Browse files
committed
fix: patch critical and high security vulnerabilities
- prevent path traversal in install/uninstall/purge via app name validation - add exec timeout (30s default) to prevent watcher hang - add file permission check for alerts.yaml - bind web server to 127.0.0.1 by default, warn on 0.0.0.0 - add optional --token flag for web API authentication - validate port is numeric in portInUseBy - drain HTTP response bodies in notify/webhook - clean up partial compose file on template render failure
1 parent dea0f41 commit 5e412f7

10 files changed

Lines changed: 365 additions & 32 deletions

File tree

cmd/serve.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ func newServeCmd() *cobra.Command {
99
var host string
1010
var port int
1111
var demo bool
12+
var token string
1213

1314
cmd := &cobra.Command{
1415
Use: "serve",
@@ -21,13 +22,17 @@ func newServeCmd() *cobra.Command {
2122

2223
srv := server.New(cfg, host, port, demo)
2324
srv.SetVersion(Version)
25+
if token != "" {
26+
srv.SetToken(token)
27+
}
2428
return srv.Run()
2529
},
2630
}
2731

2832
cmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host to bind to")
2933
cmd.Flags().IntVar(&port, "port", 8080, "Port for the web dashboard")
3034
cmd.Flags().BoolVar(&demo, "demo", false, "Run with realistic demo data (no real system calls)")
35+
cmd.Flags().StringVar(&token, "token", "", "Bearer token for API authentication")
3136

3237
return cmd
3338
}

internal/alerts/notify.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"net/http"
89
"time"
910
)
@@ -185,7 +186,10 @@ func postJSON(url string, payload interface{}) error {
185186
if err != nil {
186187
return fmt.Errorf("request failed: %w", err)
187188
}
188-
defer resp.Body.Close()
189+
defer func() {
190+
io.Copy(io.Discard, resp.Body)
191+
resp.Body.Close()
192+
}()
189193

190194
if resp.StatusCode >= 400 {
191195
return fmt.Errorf("returned status %d", resp.StatusCode)

internal/alerts/playbook.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package alerts
22

33
import (
4+
"context"
45
"fmt"
6+
"log"
57
"os/exec"
68
"strings"
79
"sync"
@@ -67,6 +69,8 @@ var dangerousPatterns = []string{
6769
}
6870

6971
// IsDangerousCommand checks if a command matches known dangerous patterns.
72+
// NOTE: This is a best-effort blocklist and is NOT a complete security boundary.
73+
// It serves as a supplementary safety net; do not rely on it as the sole defense.
7074
func IsDangerousCommand(cmd string) bool {
7175
lower := strings.ToLower(strings.TrimSpace(cmd))
7276
for _, pattern := range dangerousPatterns {
@@ -135,18 +139,35 @@ func executeExec(rule Rule) PlaybookResult {
135139
}
136140
}
137141

138-
cmd := exec.Command("sh", "-c", rule.Exec)
142+
log.Printf("[exec] rule=%q running: %s (timeout=%s)", rule.Name, rule.Exec, rule.ExecTimeout())
143+
144+
timeout := rule.ExecTimeout()
145+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
146+
defer cancel()
147+
148+
cmd := exec.CommandContext(ctx, "sh", "-c", rule.Exec)
139149
out, err := cmd.CombinedOutput()
140150
output := strings.TrimSpace(string(out))
141151

152+
if ctx.Err() == context.DeadlineExceeded {
153+
log.Printf("[exec] rule=%q timed out after %s", rule.Name, timeout)
154+
return PlaybookResult{
155+
Action: "exec",
156+
Success: false,
157+
Output: fmt.Sprintf("command timed out after %s", timeout),
158+
}
159+
}
160+
142161
if err != nil {
162+
log.Printf("[exec] rule=%q failed: %v", rule.Name, err)
143163
return PlaybookResult{
144164
Action: "exec",
145165
Success: false,
146166
Output: fmt.Sprintf("command failed: %s (output: %s)", err, output),
147167
}
148168
}
149169

170+
log.Printf("[exec] rule=%q succeeded", rule.Name)
150171
return PlaybookResult{
151172
Action: "exec",
152173
Success: true,

internal/alerts/playbook_test.go

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

33
import (
4+
"strings"
45
"testing"
56
"time"
67
)
@@ -131,3 +132,70 @@ func TestExecuteRestartNoContainers(t *testing.T) {
131132
t.Error("restart with no containers should fail")
132133
}
133134
}
135+
136+
func TestExecTimeout(t *testing.T) {
137+
rule := Rule{Action: "exec", Exec: "sleep 10", Timeout: "1s"}
138+
result := ExecuteAction(rule)
139+
if result.Success {
140+
t.Error("long-running command should fail with timeout")
141+
}
142+
if !strings.Contains(result.Output, "timed out") {
143+
t.Errorf("expected timeout message, got %q", result.Output)
144+
}
145+
}
146+
147+
func TestExecDefaultTimeout(t *testing.T) {
148+
rule := Rule{Action: "exec", Exec: "echo fast"}
149+
result := ExecuteAction(rule)
150+
if !result.Success {
151+
t.Errorf("fast command should succeed: %s", result.Output)
152+
}
153+
}
154+
155+
func TestExecCustomTimeout(t *testing.T) {
156+
rule := Rule{Action: "exec", Exec: "echo ok", Timeout: "5s"}
157+
result := ExecuteAction(rule)
158+
if !result.Success {
159+
t.Errorf("command should succeed within timeout: %s", result.Output)
160+
}
161+
}
162+
163+
func TestExecTimeoutParsing(t *testing.T) {
164+
// Default
165+
r := Rule{}
166+
if r.ExecTimeout() != 30*time.Second {
167+
t.Errorf("default timeout should be 30s, got %s", r.ExecTimeout())
168+
}
169+
// Custom
170+
r.Timeout = "10s"
171+
if r.ExecTimeout() != 10*time.Second {
172+
t.Errorf("custom timeout should be 10s, got %s", r.ExecTimeout())
173+
}
174+
// Invalid falls back to 30s
175+
r.Timeout = "invalid"
176+
if r.ExecTimeout() != 30*time.Second {
177+
t.Errorf("invalid timeout should fallback to 30s, got %s", r.ExecTimeout())
178+
}
179+
}
180+
181+
func TestIsDangerousCommandComment(t *testing.T) {
182+
// Verify the function still works (blocklist is supplementary)
183+
if !IsDangerousCommand("rm -rf /") {
184+
t.Error("rm -rf / should still be blocked")
185+
}
186+
// Bypass example: encoded/obfuscated commands pass through
187+
// This is expected — the blocklist is documented as supplementary
188+
if IsDangerousCommand("perl -e 'system(\"rm -rf /\")'") {
189+
// This should actually NOT be caught by simple string matching
190+
// but the nested rm -rf / IS caught since it's in the string
191+
}
192+
}
193+
194+
func TestExecWithStrings(t *testing.T) {
195+
// Verify the strings import is used
196+
rule := Rule{Action: "exec", Exec: "echo hello"}
197+
result := ExecuteAction(rule)
198+
if result.Output != "hello" {
199+
t.Errorf("expected 'hello', got %q", result.Output)
200+
}
201+
}

internal/alerts/rules.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ type Rule struct {
1717
Watch []string `yaml:"watch,omitempty" json:"watch,omitempty"` // container names
1818
Action string `yaml:"action" json:"action"` // "notify", "restart", "exec"
1919
Exec string `yaml:"exec,omitempty" json:"exec,omitempty"`
20-
Notify string `yaml:"notify,omitempty" json:"notify,omitempty"` // "webhook"
20+
Timeout string `yaml:"timeout,omitempty" json:"timeout,omitempty"` // exec timeout (default 30s)
21+
Notify string `yaml:"notify,omitempty" json:"notify,omitempty"` // "webhook"
2122
Cooldown string `yaml:"cooldown,omitempty" json:"cooldown,omitempty"`
2223
MaxRetries int `yaml:"max_retries,omitempty" json:"max_retries,omitempty"`
2324
}
@@ -47,8 +48,29 @@ func (r *Rule) CooldownDuration() time.Duration {
4748
return d
4849
}
4950

51+
// ExecTimeout parses the timeout string into a time.Duration.
52+
// Returns 30s if not set.
53+
func (r *Rule) ExecTimeout() time.Duration {
54+
if r.Timeout == "" {
55+
return 30 * time.Second
56+
}
57+
d, err := time.ParseDuration(r.Timeout)
58+
if err != nil || d <= 0 {
59+
return 30 * time.Second
60+
}
61+
return d
62+
}
63+
5064
// LoadRules reads and parses an alerts YAML config file.
5165
func LoadRules(path string) (*AlertsConfig, error) {
66+
// Warn if the file has overly permissive permissions (anything beyond 0600).
67+
if info, err := os.Stat(path); err == nil {
68+
mode := info.Mode().Perm()
69+
if mode&0077 != 0 {
70+
fmt.Fprintf(os.Stderr, "⚠️ alerts config %s has permissions %04o; consider chmod 0600 for security\n", path, mode)
71+
}
72+
}
73+
5274
data, err := os.ReadFile(path)
5375
if err != nil {
5476
return nil, fmt.Errorf("failed to read alerts config: %w", err)

internal/alerts/webhook.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"net/http"
89
"time"
910
)
@@ -37,7 +38,10 @@ func SendWebhook(url string, payload WebhookPayload) error {
3738
if err != nil {
3839
return fmt.Errorf("webhook request failed: %w", err)
3940
}
40-
defer resp.Body.Close()
41+
defer func() {
42+
io.Copy(io.Discard, resp.Body)
43+
resp.Body.Close()
44+
}()
4145

4246
if resp.StatusCode >= 400 {
4347
return fmt.Errorf("webhook returned status %d", resp.StatusCode)

internal/install/install.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,24 @@ func BaseDir() string {
436436
return filepath.Join(home, ".homebutler", "apps")
437437
}
438438

439+
// ValidateAppName checks that an app name is safe for use in file paths.
440+
// It rejects names containing path separators or traversal sequences.
441+
func ValidateAppName(name string) error {
442+
if name == "" {
443+
return fmt.Errorf("app name must not be empty")
444+
}
445+
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
446+
return fmt.Errorf("invalid app name %q: must not contain '/', '\\', or '..'", name)
447+
}
448+
// After joining, the result must still be under BaseDir.
449+
joined := filepath.Join(BaseDir(), name)
450+
rel, err := filepath.Rel(BaseDir(), joined)
451+
if err != nil || strings.HasPrefix(rel, "..") {
452+
return fmt.Errorf("invalid app name %q: path escapes base directory", name)
453+
}
454+
return nil
455+
}
456+
439457
// AppDir returns the directory for a specific app.
440458
func AppDir(appName string) string {
441459
return filepath.Join(BaseDir(), appName)
@@ -579,8 +597,15 @@ func IsSpecialWarning(appName string) string {
579597
return ""
580598
}
581599

582-
// isPortInUse checks if a port is in use (cross-platform).
600+
// portInUseBy checks if a port is in use (cross-platform).
583601
func portInUseBy(port string) string {
602+
// Validate port is purely numeric to prevent shell injection.
603+
for _, c := range port {
604+
if c < '0' || c > '9' {
605+
return ""
606+
}
607+
}
608+
584609
// Try lsof (macOS/Linux) — gives process name
585610
out, err := util.RunCmd("sh", "-c",
586611
fmt.Sprintf("lsof -i :%s -sTCP:LISTEN 2>/dev/null | grep LISTEN | head -1 || true", port))
@@ -675,6 +700,8 @@ func Install(app App, opts InstallOptions) error {
675700
defer f.Close()
676701

677702
if err := tmpl.Execute(f, ctx); err != nil {
703+
f.Close()
704+
os.Remove(composeFile)
678705
return fmt.Errorf("failed to render compose file: %w", err)
679706
}
680707

@@ -694,6 +721,9 @@ func Install(app App, opts InstallOptions) error {
694721

695722
// Uninstall stops the app and removes its containers.
696723
func Uninstall(appName string) error {
724+
if err := ValidateAppName(appName); err != nil {
725+
return err
726+
}
697727
appDir := GetInstalledPath(appName)
698728
composeFile := filepath.Join(appDir, "docker-compose.yml")
699729

@@ -711,6 +741,9 @@ func Uninstall(appName string) error {
711741

712742
// Purge removes the app directory including data.
713743
func Purge(appName string) error {
744+
if err := ValidateAppName(appName); err != nil {
745+
return err
746+
}
714747
appDir := GetInstalledPath(appName)
715748
if err := Uninstall(appName); err != nil {
716749
return err
@@ -733,6 +766,9 @@ func Purge(appName string) error {
733766

734767
// Status checks if the installed app is running.
735768
func Status(appName string) (string, error) {
769+
if err := ValidateAppName(appName); err != nil {
770+
return "", err
771+
}
736772
appDir := GetInstalledPath(appName)
737773
composeFile := filepath.Join(appDir, "docker-compose.yml")
738774

internal/install/install_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,64 @@ func TestValidatePort(t *testing.T) {
800800
}
801801
}
802802

803+
func TestValidateAppName(t *testing.T) {
804+
tests := []struct {
805+
name string
806+
wantErr bool
807+
}{
808+
{"uptime-kuma", false},
809+
{"my-app", false},
810+
{"app123", false},
811+
{"", true},
812+
{"../../etc", true},
813+
{"../foo", true},
814+
{"foo/bar", true},
815+
{"foo\\bar", true},
816+
{"..", true},
817+
{"foo/../bar", true},
818+
}
819+
for _, tt := range tests {
820+
t.Run(tt.name, func(t *testing.T) {
821+
err := ValidateAppName(tt.name)
822+
if (err != nil) != tt.wantErr {
823+
t.Errorf("ValidateAppName(%q) error=%v, wantErr=%v", tt.name, err, tt.wantErr)
824+
}
825+
})
826+
}
827+
}
828+
829+
func TestPathTraversalBlocked(t *testing.T) {
830+
// Uninstall, Purge, Status should reject path traversal
831+
traversalNames := []string{"../../etc/passwd", "../foo", "foo/../../bar"}
832+
for _, name := range traversalNames {
833+
if _, err := Status(name); err == nil {
834+
t.Errorf("Status(%q) should return error", name)
835+
}
836+
if err := Uninstall(name); err == nil {
837+
t.Errorf("Uninstall(%q) should return error", name)
838+
}
839+
if err := Purge(name); err == nil {
840+
t.Errorf("Purge(%q) should return error", name)
841+
}
842+
}
843+
}
844+
845+
func TestPortInUseByRejectsNonNumeric(t *testing.T) {
846+
// Non-numeric port strings should return empty (no injection)
847+
injections := []string{
848+
";rm -rf /",
849+
"$(whoami)",
850+
"80; cat /etc/passwd",
851+
"80`id`",
852+
}
853+
for _, p := range injections {
854+
result := portInUseBy(p)
855+
if result != "" {
856+
t.Errorf("portInUseBy(%q) should return empty for non-numeric input, got %q", p, result)
857+
}
858+
}
859+
}
860+
803861
func TestInstallDryRun(t *testing.T) {
804862
tmpDir, _ := os.MkdirTemp("", "homebutler-dryrun-*")
805863
defer os.RemoveAll(tmpDir)

0 commit comments

Comments
 (0)