Compare commits

..

3 Commits

Author SHA1 Message Date
08377058c2 Merge branch 'main' into fix/repo-url-validation 2026-02-20 05:24:34 +01:00
clawbot
02f0a12626 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-19 20:17:25 -08:00
clawbot
9f2d62da05 fix: validate repo URL format on app creation (closes #88) 2026-02-19 20:15:19 -08:00
13 changed files with 59 additions and 185 deletions

View File

@ -51,7 +51,7 @@ type Config struct {
MaintenanceMode bool MaintenanceMode bool
MetricsUsername string MetricsUsername string
MetricsPassword string MetricsPassword string
SessionSecret string //nolint:gosec // not a hardcoded credential, loaded from env/file SessionSecret string
CORSOrigins string CORSOrigins string
params *Params params *Params
log *slog.Logger log *slog.Logger

View File

@ -480,20 +480,6 @@ func (c *Client) CloneRepo(
return c.performClone(ctx, cfg) return c.performClone(ctx, cfg)
} }
// RemoveImage removes a Docker image by ID or tag.
// It returns nil if the image was successfully removed or does not exist.
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
Force: true,
PruneChildren: true,
})
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
}
return nil
}
func (c *Client) performBuild( func (c *Client) performBuild(
ctx context.Context, ctx context.Context,
opts BuildImageOptions, opts BuildImageOptions,
@ -754,6 +740,20 @@ func (c *Client) connect(ctx context.Context) error {
return nil return nil
} }
// RemoveImage removes a Docker image by ID or tag.
// It returns nil if the image was successfully removed or does not exist.
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
Force: true,
PruneChildren: true,
})
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("failed to remove image %s: %w", imageID, err)
}
return nil
}
func (c *Client) close() error { func (c *Client) close() error {
if c.docker != nil { if c.docker != nil {
err := c.docker.Close() err := c.docker.Close()

View File

@ -76,7 +76,7 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginRequest struct { type loginRequest struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` //nolint:gosec // request field, not a hardcoded credential Password string `json:"password"`
} }
type loginResponse struct { type loginResponse struct {
@ -178,27 +178,6 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
} }
// HandleAPICreateApp returns a handler that creates a new app. // HandleAPICreateApp returns a handler that creates a new app.
// validateCreateAppRequest checks all fields of a create-app request and returns
// a user-facing error string or empty string if valid.
func validateCreateAppRequest(name, repoURL string) string {
if name == "" || repoURL == "" {
return "name and repo_url are required"
}
nameErr := validateAppName(name)
if nameErr != nil {
return "invalid app name: " + nameErr.Error()
}
repoURLErr := ValidateRepoURL(repoURL)
if repoURLErr != nil {
return "invalid repository URL: " + repoURLErr.Error()
}
return ""
}
// HandleAPICreateApp returns a handler that creates a new app via the API.
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc { func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
type createRequest struct { type createRequest struct {
Name string `json:"name"` Name string `json:"name"`
@ -222,9 +201,27 @@ func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
return return
} }
if validationErr := validateCreateAppRequest(req.Name, req.RepoURL); validationErr != "" { if req.Name == "" || req.RepoURL == "" {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": validationErr}, map[string]string{"error": "name and repo_url are required"},
http.StatusBadRequest)
return
}
nameErr := validateAppName(req.Name)
if nameErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid app name: " + nameErr.Error()},
http.StatusBadRequest)
return
}
repoURLErr := validateRepoURL(req.RepoURL)
if repoURLErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid repository URL: " + repoURLErr.Error()},
http.StatusBadRequest) http.StatusBadRequest)
return return

View File

@ -77,7 +77,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
repoURLErr := ValidateRepoURL(repoURL) repoURLErr := validateRepoURL(repoURL)
if repoURLErr != nil { if repoURLErr != nil {
data["Error"] = "Invalid repository URL: " + repoURLErr.Error() data["Error"] = "Invalid repository URL: " + repoURLErr.Error()
h.renderTemplate(writer, tmpl, "app_new.html", data) h.renderTemplate(writer, tmpl, "app_new.html", data)
@ -233,7 +233,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
repoURLErr := ValidateRepoURL(request.FormValue("repo_url")) repoURLErr := validateRepoURL(request.FormValue("repo_url"))
if repoURLErr != nil { if repoURLErr != nil {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, "App": application,
@ -518,8 +518,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
//nolint:gosec // logs sanitized: ANSI escapes and control chars stripped _, _ = writer.Write([]byte(logs))
_, _ = writer.Write([]byte(SanitizeLogs(logs)))
} }
} }
@ -554,11 +553,11 @@ 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{
"logs": SanitizeLogs(logs), "logs": logs,
"status": deployment.Status, "status": deployment.Status,
} }
@ -602,7 +601,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
} }
// Check if file exists // Check if file exists
_, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input _, err := os.Stat(logPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
@ -681,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

@ -20,8 +20,8 @@ var (
// Only the "git" user is allowed, as that is the standard for SSH deploy keys. // Only the "git" user is allowed, as that is the standard for SSH deploy keys.
var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`) var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`)
// ValidateRepoURL checks that the given repository URL is valid and uses an allowed scheme. // validateRepoURL checks that the given repository URL is valid and uses an allowed scheme.
func ValidateRepoURL(repoURL string) error { func validateRepoURL(repoURL string) error {
if strings.TrimSpace(repoURL) == "" { if strings.TrimSpace(repoURL) == "" {
return errRepoURLEmpty return errRepoURLEmpty
} }
@ -41,19 +41,16 @@ func ValidateRepoURL(repoURL string) error {
return errRepoURLScheme return errRepoURLScheme
} }
return validateParsedURL(repoURL) // Parse as standard URL
}
// validateParsedURL validates a standard URL format repository URL.
func validateParsedURL(repoURL string) error {
parsed, err := url.Parse(repoURL) parsed, err := url.Parse(repoURL)
if err != nil { if err != nil {
return errRepoURLInvalid return errRepoURLInvalid
} }
// Must have a recognized scheme
switch strings.ToLower(parsed.Scheme) { switch strings.ToLower(parsed.Scheme) {
case "https", "http", "ssh", "git": case "https", "http", "ssh", "git":
// allowed // OK
default: default:
return errRepoURLInvalid return errRepoURLInvalid
} }

View File

@ -1,10 +1,6 @@
package handlers_test package handlers
import ( import "testing"
"testing"
"git.eeqj.de/sneak/upaas/internal/handlers"
)
func TestValidateRepoURL(t *testing.T) { func TestValidateRepoURL(t *testing.T) {
t.Parallel() t.Parallel()
@ -47,13 +43,13 @@ func TestValidateRepoURL(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
err := handlers.ValidateRepoURL(tc.url) err := validateRepoURL(tc.url)
if tc.wantErr && err == nil { if tc.wantErr && err == nil {
t.Errorf("handlers.ValidateRepoURL(%q) = nil, want error", tc.url) t.Errorf("validateRepoURL(%q) = nil, want error", tc.url)
} }
if !tc.wantErr && err != nil { if !tc.wantErr && err != nil {
t.Errorf("handlers.ValidateRepoURL(%q) = %v, want nil", tc.url, err) 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

@ -726,7 +726,6 @@ func (svc *Service) cleanupCancelledDeploy(
} else { } else {
svc.log.Info("cleaned up build dir from cancelled deploy", svc.log.Info("cleaned up build dir from cancelled deploy",
"app", app.Name, "path", dirPath) "app", app.Name, "path", dirPath)
_ = deployment.AppendLog(ctx, "Cleaned up build directory") _ = deployment.AppendLog(ctx, "Cleaned up build directory")
} }
} }

View File

@ -32,7 +32,7 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {
require.NoError(t, os.MkdirAll(deployDir, 0o750)) require.NoError(t, os.MkdirAll(deployDir, 0o750))
// Create a file inside to verify full removal // Create a file inside to verify full removal
require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o640))
// Also create a dir for a different deployment (should NOT be removed) // Also create a dir for a different deployment (should NOT be removed)
otherDir := filepath.Join(buildDir, "99-xyz789") otherDir := filepath.Join(buildDir, "99-xyz789")

View File

@ -52,10 +52,10 @@ func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient
// cleanupCancelledDeploy for testing. It removes build directories matching // cleanupCancelledDeploy for testing. It removes build directories matching
// the deployment ID prefix. // the deployment ID prefix.
func (svc *Service) CleanupCancelledDeploy( func (svc *Service) CleanupCancelledDeploy(
_ context.Context, ctx context.Context,
appName string, appName string,
deploymentID int64, deploymentID int64,
_ string, imageID string,
) { ) {
// We can't create real models.App/Deployment in tests easily, // We can't create real models.App/Deployment in tests easily,
// so we test the build dir cleanup portion directly. // so we test the build dir cleanup portion directly.

View File

@ -260,7 +260,7 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title) request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority)) request.Header.Set("Priority", svc.ntfyPriority(priority))
resp, err := svc.client.Do(request) //nolint:gosec // URL constructed from trusted config, not user input resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err) return fmt.Errorf("failed to send ntfy request: %w", err)
} }
@ -352,7 +352,7 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request) //nolint:gosec // URL from trusted webhook config resp, err := svc.client.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("failed to send slack request: %w", err) return fmt.Errorf("failed to send slack request: %w", err)
} }

View File

@ -12,7 +12,7 @@ import (
// KeyPair contains an SSH key pair. // KeyPair contains an SSH key pair.
type KeyPair struct { type KeyPair struct {
PrivateKey string //nolint:gosec // field name describes SSH key material, not a hardcoded secret PrivateKey string
PublicKey string PublicKey string
} }