Compare commits
3 Commits
bfea5be063
...
08377058c2
| Author | SHA1 | Date | |
|---|---|---|---|
| 08377058c2 | |||
|
|
02f0a12626 | ||
|
|
9f2d62da05 |
@ -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
|
||||||
CORSOrigins string
|
CORSOrigins string
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|||||||
@ -480,20 +480,6 @@ 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,
|
||||||
@ -754,6 +740,20 @@ 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()
|
||||||
|
|||||||
@ -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"` //nolint:gosec // request field, not a hardcoded credential
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginResponse struct {
|
type loginResponse struct {
|
||||||
@ -178,27 +178,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 +201,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))
|
||||||
_, _ = 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -602,7 +601,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
_, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input
|
_, err := os.Stat(logPath)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -726,7 +726,6 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"), 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)
|
// Also create a dir for a different deployment (should NOT be removed)
|
||||||
otherDir := filepath.Join(buildDir, "99-xyz789")
|
otherDir := filepath.Join(buildDir, "99-xyz789")
|
||||||
|
|||||||
@ -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(
|
||||||
_ context.Context,
|
ctx context.Context,
|
||||||
appName string,
|
appName string,
|
||||||
deploymentID int64,
|
deploymentID int64,
|
||||||
_ string,
|
imageID 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.
|
||||||
|
|||||||
@ -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) //nolint:gosec // URL constructed from trusted config, not user input
|
resp, err := svc.client.Do(request)
|
||||||
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) //nolint:gosec // URL from trusted webhook config
|
resp, err := svc.client.Do(request)
|
||||||
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
|
||||||
PublicKey string
|
PublicKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user