3 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
10 changed files with 63 additions and 87 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 //nolint:gosec // not a hardcoded credential, loaded from env/file
CORSOrigins string
params *Params
log *slog.Logger

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"` //nolint:gosec // request field, not a hardcoded credential
}
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"},
@@ -176,6 +178,27 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
}
// 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 {
type createRequest struct {
Name string `json:"name"`
@@ -199,27 +222,9 @@ func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
return
}
if req.Name == "" || req.RepoURL == "" {
if validationErr := validateCreateAppRequest(req.Name, req.RepoURL); validationErr != "" {
h.respondJSON(writer, request,
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()},
map[string]string{"error": validationErr},
http.StatusBadRequest)
return

View File

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

View File

@@ -20,8 +20,8 @@ var (
// 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 {
// 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
}
@@ -41,16 +41,19 @@ func validateRepoURL(repoURL string) error {
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)
if err != nil {
return errRepoURLInvalid
}
// Must have a recognized scheme
switch strings.ToLower(parsed.Scheme) {
case "https", "http", "ssh", "git":
// OK
// allowed
default:
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) {
t.Parallel()
@@ -43,13 +47,13 @@ func TestValidateRepoURL(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateRepoURL(tc.url)
err := handlers.ValidateRepoURL(tc.url)
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 {
t.Errorf("validateRepoURL(%q) = %v, want nil", tc.url, err)
t.Errorf("handlers.ValidateRepoURL(%q) = %v, want nil", tc.url, err)
}
})
}

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) //nolint:gosec // URL constructed from trusted config, not user input
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) //nolint:gosec // URL from trusted webhook config
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 //nolint:gosec // field name describes SSH key material, not a hardcoded secret
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>