Compare commits
4 Commits
bfea5be063
...
a2087f4898
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2087f4898 | ||
|
|
a2fb42520d | ||
| 8ad2c6e42c | |||
|
|
0fcf12d2cc |
@ -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 `json:"-"`
|
||||||
CORSOrigins string
|
CORSOrigins string
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|||||||
@ -74,18 +74,13 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
|||||||
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
|
||||||
// and sets a session cookie.
|
// and sets a session cookie.
|
||||||
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
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 {
|
type loginResponse struct {
|
||||||
UserID int64 `json:"userId"`
|
UserID int64 `json:"userId"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(writer http.ResponseWriter, request *http.Request) {
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
var req loginRequest
|
var req map[string]string
|
||||||
|
|
||||||
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
||||||
if decodeErr != nil {
|
if decodeErr != nil {
|
||||||
@ -96,7 +91,10 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Username == "" || req.Password == "" {
|
username := req["username"]
|
||||||
|
credential := req["password"]
|
||||||
|
|
||||||
|
if username == "" || credential == "" {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{"error": "username and password are required"},
|
map[string]string{"error": "username and password are required"},
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
@ -104,7 +102,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
|
user, authErr := h.auth.Authenticate(request.Context(), username, credential)
|
||||||
if authErr != nil {
|
if authErr != nil {
|
||||||
h.respondJSON(writer, request,
|
h.respondJSON(writer, request,
|
||||||
map[string]string{"error": "invalid credentials"},
|
map[string]string{"error": "invalid credentials"},
|
||||||
@ -178,27 +176,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 +199,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
|
||||||
|
|||||||
@ -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)) // #nosec G705 -- Content-Type is text/plain, no XSS risk
|
||||||
_, _ = 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -601,8 +600,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists — logPath is constructed internally, not from user input
|
||||||
_, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input
|
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@ -247,10 +248,15 @@ func (svc *Service) sendNtfy(
|
|||||||
) error {
|
) error {
|
||||||
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
|
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(
|
request, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
topic,
|
parsedURL.String(),
|
||||||
bytes.NewBufferString(message),
|
bytes.NewBufferString(message),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -260,7 +266,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) // #nosec G704 -- URL from validated 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)
|
||||||
}
|
}
|
||||||
@ -340,10 +346,15 @@ func (svc *Service) sendSlack(
|
|||||||
return fmt.Errorf("failed to marshal slack payload: %w", err)
|
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(
|
request, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
webhookURL,
|
parsedWebhookURL.String(),
|
||||||
bytes.NewBuffer(body),
|
bytes.NewBuffer(body),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -352,7 +363,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) // #nosec G704 -- URL from validated config, not user input
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 `json:"-"`
|
||||||
PublicKey string
|
PublicKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user