Compare commits
3 Commits
chore/code
...
bfea5be063
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfea5be063 | ||
|
|
214b5f83ba | ||
|
|
b4b2a33089 |
@@ -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
|
||||
@@ -157,10 +157,10 @@ func buildConfig(log *slog.Logger, params *Params) (*Config, 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
|
||||
// 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)
|
||||
if err == nil {
|
||||
log.Info("loaded session secret from file", "path", secretPath)
|
||||
|
||||
@@ -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,18 +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()},
|
||||
map[string]string{"error": validationErr},
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -78,6 +77,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
|
||||
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 == "" {
|
||||
branch = "main"
|
||||
}
|
||||
@@ -226,6 +233,17 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
|
||||
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.RepoURL = request.FormValue("repo_url")
|
||||
application.Branch = request.FormValue("branch")
|
||||
@@ -500,11 +518,8 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Container log output is attacker-controlled (untrusted) data.
|
||||
// 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
|
||||
//nolint:gosec // logs sanitized: ANSI escapes and control chars stripped
|
||||
_, _ = writer.Write([]byte(SanitizeLogs(logs)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +558,7 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"logs": logs,
|
||||
"logs": SanitizeLogs(logs),
|
||||
"status": deployment.Status,
|
||||
}
|
||||
|
||||
@@ -586,15 +601,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists — logPath is from GetLogFilePath (internal, not user input).
|
||||
// filepath.Clean normalizes the path and filepath.Base extracts the filename
|
||||
// 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
|
||||
// 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)
|
||||
|
||||
@@ -602,19 +610,19 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 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-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
http.ServeFile(writer, request, safePath)
|
||||
http.ServeFile(writer, request, logPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
70
internal/handlers/repo_url_validation.go
Normal file
70
internal/handlers/repo_url_validation.go
Normal file
@@ -0,0 +1,70 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "https", "http", "ssh", "git":
|
||||
// allowed
|
||||
default:
|
||||
return errRepoURLInvalid
|
||||
}
|
||||
|
||||
if parsed.Host == "" {
|
||||
return errRepoURLNoHost
|
||||
}
|
||||
|
||||
if parsed.Path == "" || parsed.Path == "/" {
|
||||
return errRepoURLNoPath
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
60
internal/handlers/repo_url_validation_test.go
Normal file
60
internal/handlers/repo_url_validation_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||
)
|
||||
|
||||
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 := handlers.ValidateRepoURL(tc.url)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("handlers.ValidateRepoURL(%q) = nil, want error", tc.url)
|
||||
}
|
||||
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("handlers.ValidateRepoURL(%q) = %v, want nil", tc.url, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,14 @@ import (
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
)
|
||||
|
||||
// testSessionValue is a dummy value for tests (not a real credential).
|
||||
const testSessionValue = "test-value-32-bytes-long-enough!"
|
||||
|
||||
//nolint:gosec // test credentials
|
||||
func newCORSTestMiddleware(corsOrigins string) *Middleware {
|
||||
return &Middleware{
|
||||
log: slog.Default(),
|
||||
params: &Params{
|
||||
Config: &config.Config{
|
||||
CORSOrigins: corsOrigins,
|
||||
SessionSecret: testSessionValue,
|
||||
SessionSecret: "test-secret-32-bytes-long-enough",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -242,34 +241,12 @@ 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(
|
||||
ctx context.Context,
|
||||
topic, title, message, priority string,
|
||||
) error {
|
||||
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
||||
|
||||
urlErr := validateWebhookURL(topic)
|
||||
if urlErr != nil {
|
||||
return fmt.Errorf("invalid ntfy topic URL: %w", urlErr)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
@@ -283,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)
|
||||
}
|
||||
@@ -363,11 +340,6 @@ func (svc *Service) sendSlack(
|
||||
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
||||
}
|
||||
|
||||
urlErr := validateWebhookURL(webhookURL)
|
||||
if urlErr != nil {
|
||||
return fmt.Errorf("invalid slack webhook URL: %w", urlErr)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
@@ -380,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user