diff --git a/internal/handlers/app.go b/internal/handlers/app.go index c258be7..cf60f47 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -499,7 +499,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc { return } - _, _ = writer.Write([]byte(logs)) // #nosec G705 -- Content-Type is text/plain, no XSS risk + _, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain } } @@ -534,7 +534,7 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc { logs := "" if deployment.Logs.Valid { - logs = deployment.Logs.String + logs = SanitizeLogs(deployment.Logs.String) } response := map[string]any{ @@ -661,7 +661,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc { } response := map[string]any{ - "logs": logs, + "logs": SanitizeLogs(logs), "status": status, } diff --git a/internal/handlers/sanitize.go b/internal/handlers/sanitize.go new file mode 100644 index 0000000..91f2ddc --- /dev/null +++ b/internal/handlers/sanitize.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "regexp" + "strings" +) + +// ansiEscapePattern matches ANSI escape sequences (CSI, OSC, and single-character escapes). +var ansiEscapePattern = regexp.MustCompile(`(\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]])`) + +// SanitizeLogs strips ANSI escape sequences and non-printable control characters +// from container log output. Newlines (\n), carriage returns (\r), and tabs (\t) +// are preserved. This ensures that attacker-controlled container output cannot +// inject terminal escape sequences or other dangerous control characters. +func SanitizeLogs(input string) string { + // Strip ANSI escape sequences + result := ansiEscapePattern.ReplaceAllString(input, "") + + // Strip remaining non-printable characters (keep \n, \r, \t) + var b strings.Builder + b.Grow(len(result)) + + for _, r := range result { + if r == '\n' || r == '\r' || r == '\t' || r >= ' ' { + b.WriteRune(r) + } + } + + return b.String() +} diff --git a/internal/handlers/sanitize_test.go b/internal/handlers/sanitize_test.go new file mode 100644 index 0000000..8282082 --- /dev/null +++ b/internal/handlers/sanitize_test.go @@ -0,0 +1,84 @@ +package handlers_test + +import ( + "testing" + + "git.eeqj.de/sneak/upaas/internal/handlers" +) + +func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "plain text unchanged", + input: "hello world\n", + expected: "hello world\n", + }, + { + name: "strips ANSI color codes", + input: "\x1b[31mERROR\x1b[0m: something failed\n", + expected: "ERROR: something failed\n", + }, + { + name: "strips OSC sequences", + input: "\x1b]0;window title\x07normal text\n", + expected: "normal text\n", + }, + { + name: "strips null bytes", + input: "hello\x00world\n", + expected: "helloworld\n", + }, + { + name: "strips bell characters", + input: "alert\x07here\n", + expected: "alerthere\n", + }, + { + name: "preserves tabs", + input: "field1\tfield2\tfield3\n", + expected: "field1\tfield2\tfield3\n", + }, + { + name: "preserves carriage returns", + input: "line1\r\nline2\r\n", + expected: "line1\r\nline2\r\n", + }, + { + name: "strips mixed escape sequences", + input: "\x1b[32m2024-01-01\x1b[0m \x1b[1mINFO\x1b[0m starting\x00\n", + expected: "2024-01-01 INFO starting\n", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "only control characters", + input: "\x00\x01\x02\x03", + expected: "", + }, + { + name: "cursor movement sequences stripped", + input: "\x1b[2J\x1b[H\x1b[3Atext\n", + expected: "text\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := handlers.SanitizeLogs(tt.input) + if got != tt.expected { + t.Errorf("SanitizeLogs(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +}