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
14 changed files with 38 additions and 188 deletions

View File

@@ -1,26 +0,0 @@
name: Check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
- name: Run make check
run: make check

View File

@@ -51,7 +51,7 @@ type Config struct {
MaintenanceMode bool
MetricsUsername string
MetricsPassword string
SessionSecret string `json:"-"`
SessionSecret string
CORSOrigins string
params *Params
log *slog.Logger

View File

@@ -480,20 +480,6 @@ func (c *Client) CloneRepo(
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(
ctx context.Context,
opts BuildImageOptions,
@@ -754,6 +740,20 @@ func (c *Client) connect(ctx context.Context) error {
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 {
if c.docker != nil {
err := c.docker.Close()

View File

@@ -74,13 +74,18 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
// and sets a session cookie.
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req map[string]string
var req loginRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
@@ -91,10 +96,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return
}
username := req["username"]
credential := req["password"]
if username == "" || credential == "" {
if req.Username == "" || req.Password == "" {
h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"},
http.StatusBadRequest)
@@ -102,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return
}
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
if authErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid credentials"},

View File

@@ -518,7 +518,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return
}
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain
_, _ = writer.Write([]byte(logs))
}
}
@@ -553,7 +553,7 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
logs := ""
if deployment.Logs.Valid {
logs = SanitizeLogs(deployment.Logs.String)
logs = deployment.Logs.String
}
response := map[string]any{
@@ -600,8 +600,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return
}
// Check if file exists — logPath is constructed internally, not from user input
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input
// Check if file exists
_, err := os.Stat(logPath)
if os.IsNotExist(err) {
http.NotFound(writer, request)
@@ -680,7 +680,7 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
}
response := map[string]any{
"logs": SanitizeLogs(logs),
"logs": logs,
"status": status,
}

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 {
svc.log.Info("cleaned up build dir from cancelled deploy",
"app", app.Name, "path", dirPath)
_ = 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))
// 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)
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
// the deployment ID prefix.
func (svc *Service) CleanupCancelledDeploy(
_ context.Context,
ctx context.Context,
appName string,
deploymentID int64,
_ string,
imageID string,
) {
// We can't create real models.App/Deployment in tests easily,
// so we test the build dir cleanup portion directly.

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"time"
"go.uber.org/fx"
@@ -248,15 +247,10 @@ func (svc *Service) sendNtfy(
) error {
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
parsedURL, err := url.ParseRequestURI(topic)
if err != nil {
return fmt.Errorf("invalid ntfy topic URL: %w", err)
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
parsedURL.String(),
topic,
bytes.NewBufferString(message),
)
if err != nil {
@@ -266,7 +260,7 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority))
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request)
if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err)
}
@@ -346,15 +340,10 @@ func (svc *Service) sendSlack(
return fmt.Errorf("failed to marshal slack payload: %w", err)
}
parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
if err != nil {
return fmt.Errorf("invalid slack webhook URL: %w", err)
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
parsedWebhookURL.String(),
webhookURL,
bytes.NewBuffer(body),
)
if err != nil {
@@ -363,7 +352,7 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input
resp, err := svc.client.Do(request)
if err != nil {
return fmt.Errorf("failed to send slack request: %w", err)
}

View File

@@ -12,7 +12,7 @@ import (
// KeyPair contains an SSH key pair.
type KeyPair struct {
PrivateKey string `json:"-"`
PrivateKey string
PublicKey string
}

View File

@@ -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?.dataset.logs || "Loading...";
this.logs = initialLogsEl?.textContent || "Loading...";
// Set up scroll tracking
this.$nextTick(() => {

View File

@@ -98,7 +98,7 @@
title="Scroll to bottom"
>↓ Follow</button>
</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>
{{end}}
</div>