Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions internal/handlers/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,53 @@ func TestHandleAPILogsStreamGET_Security(t *testing.T) {
})
}
}

func TestHandleViewLogsGET_Security(t *testing.T) {
// Setup temporary config
tmpDir := t.TempDir()
config.SetConfigDir(tmpDir)

// Create config.json
cfgPath := filepath.Join(tmpDir, "config.json")
cfgData := []byte(`{
"services": [
{
"name": "MixedService",
"container_names": "bad name; touch /tmp/pwned, valid-name"
}
]
}`)
err := os.WriteFile(cfgPath, cfgData, 0644)
if err != nil {
t.Fatalf("Failed to write config file: %v", err)
}

// Create dummy status.json
statusPath := filepath.Join(tmpDir, "status.json")
statusData := []byte(`{"services": []}`)
err = os.WriteFile(statusPath, statusData, 0644)
if err != nil {
t.Fatalf("Failed to write status file: %v", err)
}

// Init templates with a dummy template
// Create dummy template file in a temp directory for the test
tplDir := t.TempDir()
err = os.WriteFile(filepath.Join(tplDir, "config.html"), []byte("<html><body>{{.Logs}}</body></html>"), 0644)
if err != nil {
t.Fatalf("Failed to write template file: %v", err)
}
InitTemplates(tplDir)

req := httptest.NewRequest("GET", "/view_logs/0", nil)
req.SetPathValue("index", "0")

rr := httptest.NewRecorder()

HandleViewLogsGET(rr, req)

body := rr.Body.String()
if !strings.Contains(body, "Logs for bad name; touch /tmp/pwned: [Invalid container name]") {
t.Errorf("expected malicious container to be blocked and logged as invalid, but got: %q", body)
}
}
25 changes: 18 additions & 7 deletions internal/handlers/web_handlers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handlers

import (
"context"
"fmt"
"net/http"
"os/exec"
Expand Down Expand Up @@ -245,7 +246,7 @@ func handleUpdateService(w http.ResponseWriter, r *http.Request, username string
oldName := cfg.Services[idx].Name
newName := r.FormValue("name")
insecureSkip := r.FormValue("insecure_skip_verify") == "on"

providers := getNotificationProviders()
enableWebhook := r.FormValue("enable_webhook") == "on" && providers["webhook"]
enableTeams := r.FormValue("enable_teams") == "on" && providers["teams"]
Expand Down Expand Up @@ -383,12 +384,14 @@ func HandleForceRestartPOST(w http.ResponseWriter, r *http.Request) {
restartSucceeded = false
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// #nosec G204
cmd := exec.Command("docker", "restart", c)
cmd := exec.CommandContext(ctx, "docker", "restart", c)
if err := cmd.Run(); err != nil {
monitor.LogAction(user, fmt.Sprintf("Error restarting container %s: %v", c, err), "error")
restartSucceeded = false
}
cancel()
}

if restartSucceeded {
Expand Down Expand Up @@ -473,12 +476,20 @@ func HandleViewLogsGET(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(&logsBuilder, "Logs for %s: [Invalid container name]\n\n", c)
continue
}
// #nosec G204
cmd := exec.Command("docker", "logs", "--tail", "10", c)
out, _ := cmd.CombinedOutput()
outStr := string(out)
// Container name is validated by config.IsValidContainerName above, and the
// "--" end-of-options separator prevents the value from being interpreted as
// a docker flag (e.g. "-v", "--since"). This neutralises the command-injection
// vector flagged by gosec.
// #nosec G204 G702 -- validated, non-flag argument passed after "--"
cmd := exec.CommandContext(r.Context(), "docker", "logs", "--tail", "10", "--", c)
out, err := cmd.CombinedOutput()
outStr := strings.TrimSpace(string(out))
if outStr == "" {
outStr = "No logs available"
if err != nil {
outStr = fmt.Sprintf("Error retrieving logs: %v", err)
} else {
outStr = "No logs available"
}
}
fmt.Fprintf(&logsBuilder, "Logs for %s:\n%s\n\n", c, outStr)
}
Expand Down
Loading