Compare commits

..

7 Commits

Author SHA1 Message Date
clawbot
bfea5be063 fix: resolve lint issues for make check compliance 2026-02-19 23:42:28 -08:00
clawbot
214b5f83ba 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 23:39:25 -08:00
clawbot
b4b2a33089 fix: validate repo URL format on app creation (closes #88) 2026-02-19 23:39:25 -08:00
3a4e999382 Merge pull request 'revert: undo PR #98 (CI + linter config changes)' (#99) from revert/pr-98 into main
Reviewed-on: #99
2026-02-20 05:37:49 +01:00
clawbot
728b29ef16 Revert "Merge pull request 'feat: add Gitea Actions CI for make check (closes #96)' (#98) from feat/ci-make-check into main"
This reverts commit f61d4d0f91, reversing
changes made to 06e8e66443.
2026-02-19 20:36:22 -08:00
f61d4d0f91 Merge pull request 'feat: add Gitea Actions CI for make check (closes #96)' (#98) from feat/ci-make-check into main
Some checks failed
check / check (push) Failing after 2s
Reviewed-on: #98
2026-02-20 05:33:24 +01:00
clawbot
8ec04fdadb feat: add Gitea Actions CI for make check (closes #96)
Some checks failed
check / check (pull_request) Failing after 16s
- Add .gitea/workflows/check.yml running make check on PRs and pushes to main
- Fix .golangci.yml for golangci-lint v2 config format (was using v1 keys)
- Migrate linters-settings to linters.settings, remove deprecated exclude-use-default
- Exclude gosec false positives (G117, G703, G704, G705) with documented rationale
- Increase lll line-length from 88 to 120 (88 was too restrictive for idiomatic Go)
- Increase dupl threshold from 100 to 150 (similar CRUD handlers are intentional)
- Fix funcorder: move RemoveImage before unexported methods in docker/client.go
- Fix wsl_v5: add required blank line in deploy.go
- Fix revive unused-parameter in export_test.go
- Fix gosec G306: tighten test file permissions to 0600
- Add html.EscapeString for log output, filepath.Clean for log path
- Remove stale //nolint:funlen directives no longer needed with v2 config
2026-02-19 20:29:21 -08:00
13 changed files with 185 additions and 59 deletions

View File

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

View File

@ -480,6 +480,20 @@ 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,
@ -740,20 +754,6 @@ 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"` Password string `json:"password"` //nolint:gosec // request field, not a hardcoded credential
} }
type loginResponse struct { type loginResponse struct {
@ -178,6 +178,27 @@ 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"`
@ -201,27 +222,9 @@ func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
return return
} }
if req.Name == "" || req.RepoURL == "" { if validationErr := validateCreateAppRequest(req.Name, req.RepoURL); validationErr != "" {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "name and repo_url are required"}, map[string]string{"error": validationErr},
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,7 +518,8 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
_, _ = writer.Write([]byte(logs)) //nolint:gosec // logs sanitized: ANSI escapes and control chars stripped
_, _ = writer.Write([]byte(SanitizeLogs(logs)))
} }
} }
@ -553,11 +554,11 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
logs := "" logs := ""
if deployment.Logs.Valid { if deployment.Logs.Valid {
logs = deployment.Logs.String logs = SanitizeLogs(deployment.Logs.String)
} }
response := map[string]any{ response := map[string]any{
"logs": logs, "logs": SanitizeLogs(logs),
"status": deployment.Status, "status": deployment.Status,
} }
@ -601,7 +602,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
} }
// Check if file exists // Check if file exists
_, err := os.Stat(logPath) _, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
@ -680,7 +681,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
} }
response := map[string]any{ response := map[string]any{
"logs": logs, "logs": SanitizeLogs(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,16 +41,19 @@ func validateRepoURL(repoURL string) error {
return errRepoURLScheme return errRepoURLScheme
} }
// Parse as standard URL return validateParsedURL(repoURL)
}
// 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":
// OK // allowed
default: default:
return errRepoURLInvalid return errRepoURLInvalid
} }

View File

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

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

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

View File

@ -726,6 +726,7 @@ 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"), 0o640)) require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600))
// 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(
ctx context.Context, _ context.Context,
appName string, appName string,
deploymentID int64, deploymentID int64,
imageID string, _ 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) resp, err := svc.client.Do(request) //nolint:gosec // URL constructed from trusted config, not user input
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) resp, err := svc.client.Do(request) //nolint:gosec // URL from trusted webhook config
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 PrivateKey string //nolint:gosec // field name describes SSH key material, not a hardcoded secret
PublicKey string PublicKey string
} }