Compare commits

..

3 Commits

Author SHA1 Message Date
user
19d0b015ae feat: sanitize container log output beyond Content-Type
Add SanitizeLogs() that strips ANSI escape sequences and non-printable
control characters (preserving newlines, carriage returns, and tabs)
from all container and deployment log output paths:

- HandleAppLogs (text/plain response)
- HandleDeploymentLogsAPI (JSON response)
- HandleContainerLogsAPI (JSON response)

Container log output is attacker-controlled data. Content-Type alone
is insufficient — the data itself must be sanitized before serving.

Includes comprehensive test coverage for the sanitization function.
2026-02-19 20:36:13 -08:00
clawbot
b0d84868e9 fix: sanitize container log output and fix lint issues
- Update nolint comment on log streaming to accurately describe why
  gosec is suppressed (text/plain Content-Type, not HTML)
- Replace <script type="text/plain"> with data attribute for initial
  logs to prevent </script> breakout from attacker-controlled log data
- Move RemoveImage before unexported methods (funcorder)
- Fix file permissions in test (gosec G306)
- Rename unused parameters in export_test.go (revive)
- Add required blank line before assignment (wsl)
2026-02-19 20:30:11 -08:00
clawbot
fb91246b07 chore: code cleanup and best practices (closes #45)
- Fix gofmt formatting across 4 files
- Add nolint annotations with justifications for all gosec findings
- Resolve all 7 pre-existing linter warnings
- make check now passes cleanly
2026-02-19 20:27:07 -08:00
6 changed files with 23 additions and 62 deletions

View File

@@ -51,7 +51,7 @@ type Config struct {
MaintenanceMode bool MaintenanceMode bool
MetricsUsername string MetricsUsername string
MetricsPassword string MetricsPassword string
SessionSecret string `json:"-"` SessionSecret string //nolint:gosec // not a hardcoded credential, loaded from env/file
CORSOrigins string CORSOrigins string
params *Params params *Params
log *slog.Logger 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) { 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 // 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) data, err := os.ReadFile(secretPath)
if err == nil { if err == nil {
log.Info("loaded session secret from file", "path", secretPath) log.Info("loaded session secret from file", "path", secretPath)

View File

@@ -74,13 +74,18 @@ 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 map[string]string var req loginRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req) decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil { if decodeErr != nil {
@@ -91,10 +96,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
username := req["username"] if req.Username == "" || req.Password == "" {
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)
@@ -102,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
user, authErr := h.auth.Authenticate(request.Context(), username, credential) user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
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"},

View File

@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -500,11 +499,8 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
// Container log output is attacker-controlled (untrusted) data. //nolint:gosec // logs sanitized: ANSI escapes and control chars stripped
// SanitizeLogs strips ANSI escapes and control characters. _, _ = writer.Write([]byte(SanitizeLogs(logs)))
// 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
} }
} }
@@ -586,15 +582,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Check if file exists — logPath is from GetLogFilePath (internal, not user input). // Check if file exists
// filepath.Clean normalizes the path and filepath.Base extracts the filename _, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input
// 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
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
@@ -602,19 +591,19 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
} }
if err != nil { 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) http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// Extract filename for Content-Disposition header // 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-Type", "text/plain; charset=utf-8")
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
http.ServeFile(writer, request, safePath) http.ServeFile(writer, request, logPath)
} }
} }

View File

@@ -11,16 +11,14 @@ import (
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
) )
// testSessionValue is a dummy value for tests (not a real credential). //nolint:gosec // test credentials
const testSessionValue = "test-value-32-bytes-long-enough!"
func newCORSTestMiddleware(corsOrigins string) *Middleware { func newCORSTestMiddleware(corsOrigins string) *Middleware {
return &Middleware{ return &Middleware{
log: slog.Default(), log: slog.Default(),
params: &Params{ params: &Params{
Config: &config.Config{ Config: &config.Config{
CORSOrigins: corsOrigins, CORSOrigins: corsOrigins,
SessionSecret: testSessionValue, SessionSecret: "test-secret-32-bytes-long-enough",
}, },
}, },
} }

View File

@@ -10,7 +10,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"time" "time"
"go.uber.org/fx" "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( func (svc *Service) sendNtfy(
ctx context.Context, ctx context.Context,
topic, title, message, priority string, topic, title, message, priority string,
) error { ) error {
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title) 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( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
@@ -283,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) // #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 { if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err) 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) 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( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
@@ -380,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) // #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 { if err != nil {
return fmt.Errorf("failed to send slack request: %w", err) return fmt.Errorf("failed to send slack request: %w", err)
} }

View File

@@ -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 `json:"-"` PrivateKey string //nolint:gosec // field name describes SSH key material, not a hardcoded secret
PublicKey string PublicKey string
} }