2 Commits

Author SHA1 Message Date
clawbot
a2087f4898 fix: restrict SCP-like URLs to git user only and reject path traversal
- Changed SCP regex to only accept 'git' as the username
- Added path traversal check: reject URLs containing '..'
- Added test cases for non-git users and path traversal
2026-02-20 02:51:38 -08:00
clawbot
a2fb42520d fix: validate repo URL format on app creation (closes #88) 2026-02-20 02:51:38 -08:00
11 changed files with 173 additions and 167 deletions

View File

@@ -157,10 +157,10 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
} }
func loadOrCreateSessionSecret(log *slog.Logger, dataDir string) (string, error) { func loadOrCreateSessionSecret(log *slog.Logger, dataDir string) (string, error) {
secretPath := filepath.Clean(filepath.Join(dataDir, sessionSecretFile)) secretPath := filepath.Join(dataDir, sessionSecretFile)
// Try to read existing secret // Try to read existing secret
// secretPath is constructed from trusted config (dataDir) and a constant filename. //nolint:gosec // secretPath is constructed from trusted config, not user input
data, err := os.ReadFile(secretPath) data, err := os.ReadFile(secretPath)
if err == nil { if err == nil {
log.Info("loaded session secret from file", "path", secretPath) log.Info("loaded session secret from file", "path", secretPath)

View File

@@ -216,6 +216,15 @@ func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
return 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{ createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
Name: req.Name, Name: req.Name,
RepoURL: req.RepoURL, RepoURL: req.RepoURL,

View File

@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -78,6 +77,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
return 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 == "" { if branch == "" {
branch = "main" branch = "main"
} }
@@ -226,6 +233,17 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
return 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.Name = newName
application.RepoURL = request.FormValue("repo_url") application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch") application.Branch = request.FormValue("branch")
@@ -500,11 +518,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
// Container log output is attacker-controlled (untrusted) data. _, _ = writer.Write([]byte(logs)) // #nosec G705 -- Content-Type is text/plain, no XSS risk
// SanitizeLogs strips ANSI escapes and control characters.
// Content-Type is text/plain; XSS is not possible in this context.
sanitized := SanitizeLogs(logs)
_, _ = io.WriteString(writer, sanitized) // #nosec G705 -- text/plain Content-Type, SanitizeLogs strips control chars
} }
} }
@@ -539,7 +553,7 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
logs := "" logs := ""
if deployment.Logs.Valid { if deployment.Logs.Valid {
logs = SanitizeLogs(deployment.Logs.String) logs = deployment.Logs.String
} }
response := map[string]any{ response := map[string]any{
@@ -586,15 +600,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Check if file exists — logPath is from GetLogFilePath (internal, not user input). // Check if file exists — logPath is constructed internally, not from user input
// filepath.Clean normalizes the path and filepath.Base extracts the filename _, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input
// to prevent directory traversal.
cleanPath := filepath.Clean(logPath)
safeDir := filepath.Dir(cleanPath)
safeName := filepath.Base(cleanPath)
safePath := filepath.Join(safeDir, safeName)
_, err := os.Stat(safePath) // #nosec G703 -- path from internal GetLogFilePath, not user input
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
@@ -602,19 +609,19 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
} }
if err != nil { if err != nil {
h.log.Error("failed to stat log file", "error", err, "path", safePath) h.log.Error("failed to stat log file", "error", err, "path", logPath)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError) http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// Extract filename for Content-Disposition header // Extract filename for Content-Disposition header
filename := safeName filename := filepath.Base(logPath)
writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
http.ServeFile(writer, request, safePath) http.ServeFile(writer, request, logPath)
} }
} }
@@ -673,7 +680,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
} }
response := map[string]any{ response := map[string]any{
"logs": SanitizeLogs(logs), "logs": logs,
"status": status, "status": status,
} }

View 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
}

View 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)
}
})
}
}

View File

@@ -1,30 +0,0 @@
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()
}

View File

@@ -1,84 +0,0 @@
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)
}
})
}
}

View File

@@ -11,16 +11,14 @@ import (
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
) )
// testSessionValue is a dummy value for tests (not a real credential). //nolint:gosec // test credentials
const testSessionValue = "test-value-32-bytes-long-enough!"
func newCORSTestMiddleware(corsOrigins string) *Middleware { func newCORSTestMiddleware(corsOrigins string) *Middleware {
return &Middleware{ return &Middleware{
log: slog.Default(), log: slog.Default(),
params: &Params{ params: &Params{
Config: &config.Config{ Config: &config.Config{
CORSOrigins: corsOrigins, CORSOrigins: corsOrigins,
SessionSecret: testSessionValue, SessionSecret: "test-secret-32-bytes-long-enough",
}, },
}, },
} }

View File

@@ -242,38 +242,21 @@ func (svc *Service) sendNotifications(
} }
} }
// errInvalidURLScheme indicates the webhook URL uses a disallowed scheme.
var errInvalidURLScheme = errors.New("URL scheme not allowed, must be http or https")
// validateWebhookURL validates that a webhook URL is well-formed and uses http/https.
func validateWebhookURL(rawURL string) error {
parsed, err := url.ParseRequestURI(rawURL)
if err != nil {
return fmt.Errorf("malformed URL: %w", err)
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return fmt.Errorf("%w: got %q", errInvalidURLScheme, parsed.Scheme)
}
return nil
}
func (svc *Service) sendNtfy( func (svc *Service) sendNtfy(
ctx context.Context, ctx context.Context,
topic, title, message, priority string, topic, title, message, priority string,
) error { ) error {
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title) svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
urlErr := validateWebhookURL(topic) parsedURL, err := url.ParseRequestURI(topic)
if urlErr != nil { if err != nil {
return fmt.Errorf("invalid ntfy topic URL: %w", urlErr) return fmt.Errorf("invalid ntfy topic URL: %w", err)
} }
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
topic, parsedURL.String(),
bytes.NewBufferString(message), bytes.NewBufferString(message),
) )
if err != nil { if err != nil {
@@ -363,15 +346,15 @@ func (svc *Service) sendSlack(
return fmt.Errorf("failed to marshal slack payload: %w", err) return fmt.Errorf("failed to marshal slack payload: %w", err)
} }
urlErr := validateWebhookURL(webhookURL) parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
if urlErr != nil { if err != nil {
return fmt.Errorf("invalid slack webhook URL: %w", urlErr) return fmt.Errorf("invalid slack webhook URL: %w", err)
} }
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
webhookURL, parsedWebhookURL.String(),
bytes.NewBuffer(body), bytes.NewBuffer(body),
) )
if err != nil { if err != nil {

View File

@@ -369,7 +369,7 @@ document.addEventListener("alpine:init", () => {
init() { init() {
// Read initial logs from script tag (avoids escaping issues) // Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs"); const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.dataset.logs || "Loading..."; this.logs = initialLogsEl?.textContent || "Loading...";
// Set up scroll tracking // Set up scroll tracking
this.$nextTick(() => { this.$nextTick(() => {

View File

@@ -98,7 +98,7 @@
title="Scroll to bottom" title="Scroll to bottom"
>↓ Follow</button> >↓ Follow</button>
</div> </div>
{{if .Logs.Valid}}<div hidden class="initial-logs" data-logs="{{.Logs.String}}"></div>{{end}} {{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>