diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 398b512..047b6d7 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -216,6 +216,15 @@ func (h *Handlers) HandleAPICreateApp() http.HandlerFunc { return } + repoURLErr := validateRepoURL(req.RepoURL) + if repoURLErr != nil { + h.respondJSON(writer, request, + map[string]string{"error": "invalid repository URL: " + repoURLErr.Error()}, + http.StatusBadRequest) + + return + } + createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{ Name: req.Name, RepoURL: req.RepoURL, diff --git a/internal/handlers/app.go b/internal/handlers/app.go index c258be7..cf86917 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -77,6 +77,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid return } + repoURLErr := validateRepoURL(repoURL) + if repoURLErr != nil { + data["Error"] = "Invalid repository URL: " + repoURLErr.Error() + h.renderTemplate(writer, tmpl, "app_new.html", data) + + return + } + if branch == "" { branch = "main" } @@ -225,6 +233,17 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid return } + repoURLErr := validateRepoURL(request.FormValue("repo_url")) + if repoURLErr != nil { + data := h.addGlobals(map[string]any{ + "App": application, + "Error": "Invalid repository URL: " + repoURLErr.Error(), + }, request) + _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data) + + return + } + application.Name = newName application.RepoURL = request.FormValue("repo_url") application.Branch = request.FormValue("branch") @@ -499,7 +518,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 +553,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 +680,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc { } response := map[string]any{ - "logs": logs, + "logs": SanitizeLogs(logs), "status": status, } diff --git a/internal/handlers/repo_url_validation.go b/internal/handlers/repo_url_validation.go new file mode 100644 index 0000000..0598a93 --- /dev/null +++ b/internal/handlers/repo_url_validation.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "errors" + "net/url" + "regexp" + "strings" +) + +// Repo URL validation errors. +var ( + errRepoURLEmpty = errors.New("repository URL must not be empty") + errRepoURLScheme = errors.New("file:// URLs are not allowed for security reasons") + errRepoURLInvalid = errors.New("repository URL must use https://, http://, ssh://, git://, or git@host:path format") + errRepoURLNoHost = errors.New("repository URL must include a host") + errRepoURLNoPath = errors.New("repository URL must include a path") +) + +// scpLikeRepoRe matches SCP-like git URLs: git@host:path (e.g. git@github.com:user/repo.git). +// Only the "git" user is allowed, as that is the standard for SSH deploy keys. +var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`) + +// validateRepoURL checks that the given repository URL is valid and uses an allowed scheme. +func validateRepoURL(repoURL string) error { + if strings.TrimSpace(repoURL) == "" { + return errRepoURLEmpty + } + + // Reject path traversal in any URL format + if strings.Contains(repoURL, "..") { + return errRepoURLInvalid + } + + // Check for SCP-like git URLs first (git@host:path) + if scpLikeRepoRe.MatchString(repoURL) { + return nil + } + + // Reject file:// explicitly + if strings.HasPrefix(strings.ToLower(repoURL), "file://") { + return errRepoURLScheme + } + + // Parse as standard URL + parsed, err := url.Parse(repoURL) + if err != nil { + return errRepoURLInvalid + } + + // Must have a recognized scheme + switch strings.ToLower(parsed.Scheme) { + case "https", "http", "ssh", "git": + // OK + default: + return errRepoURLInvalid + } + + if parsed.Host == "" { + return errRepoURLNoHost + } + + if parsed.Path == "" || parsed.Path == "/" { + return errRepoURLNoPath + } + + return nil +} diff --git a/internal/handlers/repo_url_validation_test.go b/internal/handlers/repo_url_validation_test.go new file mode 100644 index 0000000..be7416a --- /dev/null +++ b/internal/handlers/repo_url_validation_test.go @@ -0,0 +1,56 @@ +package handlers + +import "testing" + +func TestValidateRepoURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + wantErr bool + }{ + // Valid URLs + {name: "https URL", url: "https://github.com/user/repo.git", wantErr: false}, + {name: "http URL", url: "http://github.com/user/repo.git", wantErr: false}, + {name: "ssh URL", url: "ssh://git@github.com/user/repo.git", wantErr: false}, + {name: "git URL", url: "git://github.com/user/repo.git", wantErr: false}, + {name: "SCP-like URL", url: "git@github.com:user/repo.git", wantErr: false}, + {name: "SCP-like with dots", url: "git@git.example.com:org/repo.git", wantErr: false}, + {name: "https without .git", url: "https://github.com/user/repo", wantErr: false}, + {name: "https with port", url: "https://git.example.com:8443/user/repo.git", wantErr: false}, + + // Invalid URLs + {name: "empty string", url: "", wantErr: true}, + {name: "whitespace only", url: " ", wantErr: true}, + {name: "file URL", url: "file:///etc/passwd", wantErr: true}, + {name: "file URL uppercase", url: "FILE:///etc/passwd", wantErr: true}, + {name: "bare path", url: "/some/local/path", wantErr: true}, + {name: "relative path", url: "../repo", wantErr: true}, + {name: "just a word", url: "notaurl", wantErr: true}, + {name: "ftp URL", url: "ftp://example.com/repo.git", wantErr: true}, + {name: "no host https", url: "https:///path", wantErr: true}, + {name: "no path https", url: "https://github.com", wantErr: true}, + {name: "no path https trailing slash", url: "https://github.com/", wantErr: true}, + {name: "SCP-like non-git user", url: "root@github.com:user/repo.git", wantErr: true}, + {name: "SCP-like arbitrary user", url: "admin@github.com:user/repo.git", wantErr: true}, + {name: "path traversal SCP", url: "git@github.com:../../etc/passwd", wantErr: true}, + {name: "path traversal https", url: "https://github.com/user/../../../etc/passwd", wantErr: true}, + {name: "path traversal in middle", url: "https://github.com/user/repo/../secret", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateRepoURL(tc.url) + if tc.wantErr && err == nil { + t.Errorf("validateRepoURL(%q) = nil, want error", tc.url) + } + + if !tc.wantErr && err != nil { + t.Errorf("validateRepoURL(%q) = %v, want nil", tc.url, err) + } + }) + } +} 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) + } + }) + } +} 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}}