Merge branch 'main' into ci/check-workflow-only
Some checks failed
Check / check (pull_request) Failing after 6m16s
Some checks failed
Check / check (pull_request) Failing after 6m16s
This commit is contained in:
commit
a5d703a670
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
67
internal/handlers/repo_url_validation.go
Normal file
67
internal/handlers/repo_url_validation.go
Normal file
@ -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
|
||||
}
|
||||
56
internal/handlers/repo_url_validation_test.go
Normal file
56
internal/handlers/repo_url_validation_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
30
internal/handlers/sanitize.go
Normal file
30
internal/handlers/sanitize.go
Normal file
@ -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()
|
||||
}
|
||||
84
internal/handlers/sanitize_test.go
Normal file
84
internal/handlers/sanitize_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -98,7 +98,7 @@
|
||||
title="Scroll to bottom"
|
||||
>↓ Follow</button>
|
||||
</div>
|
||||
{{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
|
||||
{{if .Logs.Valid}}<div hidden class="initial-logs" data-logs="{{.Logs.String}}"></div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user