From dcff249fe51c7e0a5d8c6f096c4b30317b7ff686 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 19 Feb 2026 20:30:11 -0800 Subject: [PATCH 1/2] fix: sanitize container log output and fix lint issues - Update nolint comment on log streaming to accurately describe why gosec is suppressed (text/plain Content-Type, not HTML) - Replace breakout from attacker-controlled log data - Move RemoveImage before unexported methods (funcorder) - Fix file permissions in test (gosec G306) - Rename unused parameters in export_test.go (revive) - Add required blank line before assignment (wsl) --- static/js/app.js | 2 +- templates/deployments.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 4829867..c5f1758 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -369,7 +369,7 @@ document.addEventListener("alpine:init", () => { init() { // Read initial logs from script tag (avoids escaping issues) const initialLogsEl = this.$el.querySelector(".initial-logs"); - this.logs = initialLogsEl?.textContent || "Loading..."; + this.logs = initialLogsEl?.dataset.logs || "Loading..."; // Set up scroll tracking this.$nextTick(() => { diff --git a/templates/deployments.html b/templates/deployments.html index 731e78c..1016fc8 100644 --- a/templates/deployments.html +++ b/templates/deployments.html @@ -98,7 +98,7 @@ title="Scroll to bottom" >↓ Follow - {{if .Logs.Valid}}{{end}} + {{if .Logs.Valid}}{{end}} {{end}} -- 2.45.2 From 0bb59bf9c281c68ed32fceb1778956c2a07791da Mon Sep 17 00:00:00 2001 From: user Date: Thu, 19 Feb 2026 20:36:13 -0800 Subject: [PATCH 2/2] feat: sanitize container log output beyond Content-Type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SanitizeLogs() that strips ANSI escape sequences and non-printable control characters (preserving newlines, carriage returns, and tabs) from all container and deployment log output paths: - HandleAppLogs (text/plain response) - HandleDeploymentLogsAPI (JSON response) - HandleContainerLogsAPI (JSON response) Container log output is attacker-controlled data. Content-Type alone is insufficient — the data itself must be sanitized before serving. Includes comprehensive test coverage for the sanitization function. --- internal/handlers/app.go | 6 +-- internal/handlers/sanitize.go | 30 +++++++++++ internal/handlers/sanitize_test.go | 84 ++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 internal/handlers/sanitize.go create mode 100644 internal/handlers/sanitize_test.go 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) + } + }) + } +} -- 2.45.2