From b587fb87a9fe3078f3789b13034729fbc39a0fcf Mon Sep 17 00:00:00 2001 From: backup Date: Thu, 3 Sep 2026 23:55:25 -0500 Subject: [PATCH] Security Audit Fixes: Session fixation prevention, SameSite cookies, audit log redaction, backup integrity, Docker hardening, error sanitization, rate limiting, trusted proxies, and config hardening --- .env.example | 33 + .gitignore | 30 +- README.md | 42 ++ backend/cmd/traefik-gui/main.go | 11 + backend/internal/api/handlers/auth.go | 53 +- backend/internal/api/handlers/config_file.go | 166 ++++- backend/internal/api/handlers/health.go | 4 +- backend/internal/api/handlers/traefik_api.go | 10 +- backend/internal/api/middleware/auth.go | 5 + backend/internal/api/server.go | 8 +- backend/internal/auth/ratelimit.go | 59 ++ backend/internal/config/file/service.go | 579 +++++++++++++++++- backend/internal/config/types.go | 7 +- .../internal/database/repositories/session.go | 5 + backend/internal/database/sqlite.go | 35 +- docker/Dockerfile | 9 +- docker/docker-compose.yml | 14 +- docker/traefik.dev.yml | 4 +- 18 files changed, 987 insertions(+), 87 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..68bf1cb --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Environment variables for Traefik GUI +# See README.md for detailed setup instructions + +# Required - Session signing secret (32+ characters, random) +# Generate with: openssl rand -hex 32 +GUI_SESSION_SECRET=CHANGE_THIS_TO_A_32_CHARACTER_RANDOM_SECRET + +# Required - Admin password (minimum 12 characters for production) +# This is hashed on startup; change to your preferred password +GUI_ADMIN_PASSWORD=changeme + +# Frontend CORS origin (must match the URL you'll use to access the GUI) +GUI_CORS_ORIGIN=http://localhost:5173 + +# Server address +GUI_ADDR=:8080 + +# Development mode: set to "true" for local dev (enables dev-mode cookie behavior), "false" for production +GUI_DEV_MODE=false + +# Traefik API URL (read-only; leave empty or set to http://localhost:8080/api for mock mode) +# Set to a real Traefik instance URL to enable read-only API features +TRAEFIK_API_URL=http://localhost:8080/api + +# SQLite database path (relative to binary location; defaults to ./data/traefik-gui.db) +GUI_DB_PATH=./data/traefik-gui.db + +# Config directory for Traefik dynamic config (must be under configs/) +GUI_CONFIG_DIR=./configs/dynamic + +# Trusted proxies for X-Forwarded-For header parsing (comma-separated IPs or CIDR ranges, e.g., "10.0.0.0/8,192.168.1.1") +# Set to "*" to trust all proxies (use only behind known reverse proxies) +GUI_TRUSTED_PROXIES= \ No newline at end of file diff --git a/.gitignore b/.gitignore index cbfec79..7c263a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,28 @@ -bin/ -node_modules/ -dist/ -data/ -*.db +# Environment variables .env + +# SQLite databases +*.db +*.sqlite + +# Binary artifacts +backend/bin/ +frontend/dist/ + +# Node modules +node_modules/ + +# IDE / editor artifacts .idea/ .vscode/ + +# Generated / runtime files +data/ +*.log + +# Traefik GUI specific +configs/dynamic/*.bak.* +configs/dynamic/*.tmp.* + +# OS specific +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index cf5e02f..642c81a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,48 @@ Web-based GUI for managing Traefik v3.7. See ARCHITECTURE.md for design. +--- + +## ⚠️ Security Notice + +**Never commit the SQLite database file** (`data/traefik-gui.db`). It contains session data, CSRF tokens, and potentially sensitive configuration snapshots. The `.gitignore` excludes `*.db` and `data/` by default. + +--- + +## Setup for Testers + +1. **Copy the environment example:** + ```bash + cp .env.example .env + ``` + Then edit `.env` and replace placeholder values with your own secrets: + - `GUI_SESSION_SECRET`: Must be at least 32 random characters + - `GUI_ADMIN_PASSWORD`: Minimum 12 characters for production + - Adjust `GUI_CORS_ORIGIN` if accessing from a different origin + +2. **Start the backend:** + ```bash + cd backend + go run ./cmd/traefik-gui --dev --session-secret $(grep GUI_SESSION_SECRET .env | cut -d= -f2-) --addr :8080 + ``` + Or via Docker Compose: + ```bash + docker compose up + ``` + +3. **Start the frontend (separate terminal):** + ```bash + cd frontend + npm install && npm run dev + ``` + +4. **Open http://localhost:5173 (Vite) → login with admin/changeme** (or the password you set via `GUI_ADMIN_PASSWORD`) + +5. **API health check:** + ``` + http://localhost:8080/api/health + ``` + ## Quick Start ```bash diff --git a/backend/cmd/traefik-gui/main.go b/backend/cmd/traefik-gui/main.go index 55ebe21..facccde 100644 --- a/backend/cmd/traefik-gui/main.go +++ b/backend/cmd/traefik-gui/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -28,6 +29,7 @@ func main() { configDir = flag.String("config-dir", "./configs/dynamic", "File provider config directory") devMode = flag.Bool("dev", false, "Development mode (serves frontend from Vite)") adminPassword = flag.String("admin-password", os.Getenv("GUI_ADMIN_PASSWORD"), "Admin password (env GUI_ADMIN_PASSWORD)") + trustedProxies = flag.String("trusted-proxies", os.Getenv("GUI_TRUSTED_PROXIES"), "Trusted proxy IPs for X-Forwarded-For (comma-separated)") showVersion = flag.Bool("version", false, "Show version and exit") ) @@ -53,6 +55,14 @@ func main() { log.Fatal("session-secret must be at least 32 characters") } + if *trustedProxies == "" { + *trustedProxies = os.Getenv("GUI_TRUSTED_PROXIES") + } + if *trustedProxies == "" { + *trustedProxies = "*" + } + trustedList := strings.Split(*trustedProxies, ",") + cfg := &config.Config{ Addr: *addr, DBPath: *dbPath, @@ -61,6 +71,7 @@ func main() { TraefikAPIURL: *traefikAPIURL, ConfigDir: *configDir, DevMode: *devMode, + TrustedProxies: trustedList, } db, err := database.New(cfg.DBPath) diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index 3aa37e7..486a677 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -64,6 +64,10 @@ func (h *AuthHandler) Login(c *gin.Context) { } auth.DefaultLoginLimiter.RecordSuccess(clientIP) + // INVALIDATE ANY EXISTING SESSION FOR THIS USER + // Delete any old sessions for this user before creating new one + h.sessionRepo.DeleteByUserID(user.ID) + sessionData, err := auth.NewSessionData(user.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"}) @@ -122,22 +126,12 @@ func (h *AuthHandler) Me(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } - // Include current CSRF token for bootstrap after page reload - var csrfToken string - if sVal, exists := c.Get("session"); exists { - if s, ok := sVal.(*models.Session); ok && s != nil { - csrfToken = s.CSRFToken - } - } resp := gin.H{ "id": user.ID, "username": user.Username, "email": user.Email, "role": user.Role, } - if csrfToken != "" { - resp["csrf_token"] = csrfToken - } c.JSON(http.StatusOK, resp) } @@ -151,25 +145,28 @@ func (h *AuthHandler) GetCSRF(c *gin.Context) { } func (h *AuthHandler) setSessionCookie(c *gin.Context, sessionID string, expiresAt time.Time) { - c.SetCookie( - SessionCookieName, - sessionID, - int(time.Until(expiresAt).Seconds()), - "/", - h.cookieDomain, - h.cookieSecure, - true, // HttpOnly - ) + http.SetCookie(c.Writer, &http.Cookie{ + Name: SessionCookieName, + Value: sessionID, + MaxAge: int(time.Until(expiresAt).Seconds()), + Path: "/", + Domain: h.cookieDomain, + Secure: h.cookieSecure, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Expires: expiresAt, + }) } func (h *AuthHandler) clearSessionCookie(c *gin.Context) { - c.SetCookie( - SessionCookieName, - "", - -1, - "/", - h.cookieDomain, - h.cookieSecure, - true, - ) + http.SetCookie(c.Writer, &http.Cookie{ + Name: SessionCookieName, + Value: "", + MaxAge: -1, + Path: "/", + Domain: h.cookieDomain, + Secure: h.cookieSecure, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) } \ No newline at end of file diff --git a/backend/internal/api/handlers/config_file.go b/backend/internal/api/handlers/config_file.go index 9fc15d6..56f3507 100644 --- a/backend/internal/api/handlers/config_file.go +++ b/backend/internal/api/handlers/config_file.go @@ -2,24 +2,32 @@ package handlers import ( "net/http" + "strings" + "strconv" + + "fmt" "github.com/gin-gonic/gin" "github.com/traefik/traefik-gui/backend/internal/api/middleware" + "github.com/traefik/traefik-gui/backend/internal/auth" "github.com/traefik/traefik-gui/backend/internal/config/file" + "github.com/traefik/traefik-gui/backend/internal/database/repositories" ) type FileConfigHandler struct { - svc *file.Service + svc *file.Service + auditRepo *repositories.AuditRepository + userRepo *repositories.UserRepository } -func NewFileConfigHandler(svc *file.Service) *FileConfigHandler { - return &FileConfigHandler{svc: svc} +func NewFileConfigHandler(svc *file.Service, auditRepo *repositories.AuditRepository, userRepo *repositories.UserRepository) *FileConfigHandler { + return &FileConfigHandler{svc: svc, auditRepo: auditRepo, userRepo: userRepo} } func (h *FileConfigHandler) ListFiles(c *gin.Context) { files, err := h.svc.ListFilesWithMeta() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } if files == nil { @@ -32,7 +40,7 @@ func (h *FileConfigHandler) GetFile(c *gin.Context) { name := c.Param("name") content, err := h.svc.ReadFile(name) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } c.JSON(http.StatusOK, gin.H{"filename": name, "content": content}) @@ -67,9 +75,10 @@ func (h *FileConfigHandler) Preview(c *gin.Context) { } type ApplyRequest struct { - Filename string `json:"filename" binding:"required"` - Content string `json:"content" binding:"required"` - Confirm bool `json:"confirm"` + Filename string `json:"filename" binding:"required"` + Content string `json:"content" binding:"required"` + Confirm bool `json:"confirm"` + Revision string `json:"revision"` // optional revision token for stale-form protection } func (h *FileConfigHandler) Apply(c *gin.Context) { @@ -82,6 +91,13 @@ func (h *FileConfigHandler) Apply(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"}) return } + // Rate limit config write operations + clientIP := c.ClientIP() + if !auth.DefaultConfigLimiter.Allow(clientIP) { + auth.DefaultConfigLimiter.RecordFailure(clientIP) + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many configuration changes, try again later"}) + return + } var req ApplyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "filename, content, and confirm required"}) @@ -91,9 +107,44 @@ func (h *FileConfigHandler) Apply(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "confirmation required: set confirm:true"}) return } + + // Validate revision token for stale-form protection + if req.Revision != "" { + currentRevision, err := h.svc.FileRevision(req.Filename) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file revision"}) + return + } + if currentRevision != req.Revision { + c.JSON(http.StatusConflict, gin.H{ + "error": "conflict: the configuration has been modified by another user", + }) + return + } + } + result, err := h.svc.Apply(req.Filename, req.Content, user.ID, true) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // Distinguish between different error types + switch { + case strings.Contains(err.Error(), "confirmation required"): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case strings.Contains(err.Error(), "apply: file written but verification failed; automatically rolled back"): + // Apply wrote the file but verification failed and rollback succeeded + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + "rollback_succeeded": true, + }) + case strings.Contains(err.Error(), "apply: file written but verification failed; could not roll back"): + // Apply wrote the file but verification failed and rollback also failed + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + "rollback_succeeded": false, + }) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "configuration apply failed"}) + } + } return } if !result.Valid { @@ -101,6 +152,7 @@ func (h *FileConfigHandler) Apply(c *gin.Context) { return } c.JSON(http.StatusOK, gin.H{"message": "applied", "diff": result.Diff}) + auth.DefaultConfigLimiter.RecordSuccess(clientIP) } type RollbackRequest struct { @@ -118,6 +170,13 @@ func (h *FileConfigHandler) Rollback(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "admin required for rollback"}) return } + // Rate limit config write operations + clientIP := c.ClientIP() + if !auth.DefaultConfigLimiter.Allow(clientIP) { + auth.DefaultConfigLimiter.RecordFailure(clientIP) + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many configuration changes, try again later"}) + return + } var req RollbackRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "filename required"}) @@ -125,17 +184,18 @@ func (h *FileConfigHandler) Rollback(c *gin.Context) { } result, err := h.svc.Rollback(req.Filename, req.BackupID, user.ID) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadRequest, gin.H{"error": "rollback failed"}) return } c.JSON(http.StatusOK, gin.H{"message": "rolled back", "diff": result.Diff}) + auth.DefaultConfigLimiter.RecordSuccess(clientIP) } func (h *FileConfigHandler) History(c *gin.Context) { filename := c.Query("filename") history, err := h.svc.History(filename) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve history"}) return } if history == nil { @@ -144,6 +204,90 @@ func (h *FileConfigHandler) History(c *gin.Context) { c.JSON(http.StatusOK, history) } +// Refresh re-reads the config directory from disk and updates internal state. +// This helps reconcile any drift between the GUI state and the actual filesystem. +// Admins and operators can use this after editing files outside the GUI. +func (h *FileConfigHandler) Refresh(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if user.Role != "admin" && user.Role != "operator" { + c.JSON(http.StatusForbidden, gin.H{"error": "operator or admin required"}) + return + } + // Simply re-list the files; any changes on disk will now be visible + files, err := h.svc.ListFilesWithMeta() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to refresh config state"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "config state refreshed", "files": files}) +} + +// ListAuditEvents lists audit events with pagination and filtering (admin only) +type ListAuditEventsQuery struct { + Username string `form:"username"` + Action string `form:"action"` + Result string `form:"result"` + ResourceType string `form:"resource_type"` + StartDate string `form:"start_date"` + EndDate string `form:"end_date"` + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=50"` +} + +func (h *FileConfigHandler) ListAuditEvents(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if user.Role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "admin required"}) + return + } + + // Parse query options + page := 1 + pageSize := 50 + if c.Query("page") != "" { + if p, err := strconv.Atoi(c.Query("page")); err == nil && p > 0 { + page = p + } + } + if c.Query("page_size") != "" { + if p, err := strconv.Atoi(c.Query("page_size")); err == nil && p > 0 { + pageSize = p + } + } + + opts := repositories.ListAuditOptions{ + Username: c.Query("username"), + Action: c.Query("action"), + Result: c.Query("result"), + ResourceType: c.Query("resource_type"), + StartDate: c.Query("start_date"), + EndDate: c.Query("end_date"), + Limit: pageSize, + Offset: (page - 1) * pageSize, + } + +entries, total, err := h.auditRepo.List(opts) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve audit events"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "entries": entries, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + func (h *FileConfigHandler) Validate(c *gin.Context) { user := middleware.GetUser(c) if user == nil { diff --git a/backend/internal/api/handlers/health.go b/backend/internal/api/handlers/health.go index 8c25c2c..36b663b 100644 --- a/backend/internal/api/handlers/health.go +++ b/backend/internal/api/handlers/health.go @@ -34,9 +34,9 @@ func (h *HealthHandler) Ready(c *gin.Context) { configStatus := "ok" if h.configDir != "" { if _, err := os.Stat(h.configDir); err != nil { - configStatus = "error: " + err.Error() + configStatus = "error" } else if f, err := os.CreateTemp(h.configDir, ".writetest"); err != nil { - configStatus = "not writable: " + err.Error() + configStatus = "not writable" } else { f.Close() os.Remove(f.Name()) diff --git a/backend/internal/api/handlers/traefik_api.go b/backend/internal/api/handlers/traefik_api.go index 31795ef..8f6c4de 100644 --- a/backend/internal/api/handlers/traefik_api.go +++ b/backend/internal/api/handlers/traefik_api.go @@ -72,24 +72,22 @@ func (h *TraefikAPIHandler) cached(c *gin.Context, path string, fn func() (inter data, err := fn() if err != nil { if traefik.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "not found", "detail": err.Error()}) + c.JSON(http.StatusNotFound, gin.H{"error": "resource not found"}) return } if traefik.IsUnauthorized(err) { - // 401 from Traefik is upstream problem, not caller's auth failure -> 502 - c.JSON(http.StatusBadGateway, gin.H{"error": "traefik API unauthorized - check if Traefik API is enabled or on a private entrypoint", "detail": err.Error()}) + c.JSON(http.StatusBadGateway, gin.H{"error": "traefik API unauthorized - check if Traefik API is enabled or on a private entrypoint"}) return } if apiErr, ok := err.(*traefik.APIError); ok { - // Preserve upstream status for 5xx, else 502 status := apiErr.StatusCode if status < 400 || status >= 600 { status = http.StatusBadGateway } - c.JSON(status, gin.H{"error": apiErr.Message, "detail": apiErr.Body}) + c.JSON(status, gin.H{"error": apiErr.Message}) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "upstream API error"}) return } diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 9438028..4c35581 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -195,6 +195,11 @@ func SecurityHeadersMiddleware() gin.HandlerFunc { c.Header("X-Frame-Options", "DENY") c.Header("X-XSS-Protection", "1; mode=block") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';") + c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + c.Header("Pragma", "no-cache") c.Next() } } diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 48a3a7b..4c87df6 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -31,6 +31,9 @@ func NewServer(cfg *config.Config, db *database.DB, traefikAPI traefik.TraefikAP } engine := gin.New() + if len(cfg.TrustedProxies) > 0 { + engine.SetTrustedProxies(cfg.TrustedProxies) + } userRepo := repositories.NewUserRepository(db.DB) sessionRepo := repositories.NewSessionRepository(db.DB) @@ -51,11 +54,12 @@ func NewServer(cfg *config.Config, db *database.DB, traefikAPI traefik.TraefikAP traefikHandler := handlers.NewTraefikAPIHandler(traefikAPI) // File-provider service (Phase 2) - fileSvc, err := file.NewService(cfg.ConfigDir, db.DB) + auditRepo := repositories.NewAuditRepository(db.DB) + fileSvc, err := file.NewService(cfg.ConfigDir, db.DB, auditRepo) if err != nil { log.Fatal().Err(err).Str("configDir", cfg.ConfigDir).Msg("Failed to init file service") } - fileHandler := handlers.NewFileConfigHandler(fileSvc) + fileHandler := handlers.NewFileConfigHandler(fileSvc, auditRepo, userRepo) // Enhance health ready to check config dir writable healthHandler.SetConfigDir(cfg.ConfigDir) diff --git a/backend/internal/auth/ratelimit.go b/backend/internal/auth/ratelimit.go index 16f4ccc..fb4ffed 100644 --- a/backend/internal/auth/ratelimit.go +++ b/backend/internal/auth/ratelimit.go @@ -68,3 +68,62 @@ func (r *LoginRateLimiter) RecordSuccess(key string) { // DefaultLoginLimiter is a global limiter: 5 failures per minute, block for 30s after var DefaultLoginLimiter = NewLoginRateLimiter(5, time.Minute, 30*time.Second) + +// ConfigOperationLimiter tracks config write operations per IP to prevent abuse. +type ConfigOperationLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + maxAttempts int + window time.Duration + blockDuration time.Duration +} + +func NewConfigOperationLimiter(maxAttempts int, window time.Duration, blockDuration time.Duration) *ConfigOperationLimiter { + return &ConfigOperationLimiter{ + attempts: make(map[string][]time.Time), + maxAttempts: maxAttempts, + window: window, + blockDuration: blockDuration, + } +} + +// Allow returns true if the key is allowed to perform a config operation now. +func (r *ConfigOperationLimiter) Allow(key string) bool { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + times := r.attempts[key] + var filtered []time.Time + for _, t := range times { + if now.Sub(t) < r.window { + filtered = append(filtered, t) + } + } + r.attempts[key] = filtered + if len(filtered) >= r.maxAttempts { + last := filtered[len(filtered)-1] + if now.Sub(last) < r.blockDuration { + return false + } + r.attempts[key] = nil + return true + } + return true +} + +// RecordFailure records a failed config operation attempt. +func (r *ConfigOperationLimiter) RecordFailure(key string) { + r.mu.Lock() + defer r.mu.Unlock() + r.attempts[key] = append(r.attempts[key], time.Now()) +} + +// RecordSuccess clears failures for key. +func (r *ConfigOperationLimiter) RecordSuccess(key string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.attempts, key) +} + +// DefaultConfigLimiter is a global limiter: 10 config operations per minute, block for 1 minute after +var DefaultConfigLimiter = NewConfigOperationLimiter(10, time.Minute, time.Minute) diff --git a/backend/internal/config/file/service.go b/backend/internal/config/file/service.go index 7a8a523..6927321 100644 --- a/backend/internal/config/file/service.go +++ b/backend/internal/config/file/service.go @@ -2,36 +2,40 @@ package file import ( "crypto/rand" + "crypto/sha256" "database/sql" "encoding/hex" "fmt" "os" "path/filepath" + "regexp" "strings" "time" "github.com/google/uuid" + "github.com/traefik/traefik-gui/backend/internal/database/repositories" ) type Service struct { configDir string db *sql.DB + auditRepo *repositories.AuditRepository locks *FileLocks } -func NewService(configDir string, db *sql.DB) (*Service, error) { +func NewService(configDir string, db *sql.DB, auditRepo *repositories.AuditRepository) (*Service, error) { abs, err := filepath.Abs(configDir) if err != nil { return nil, fmt.Errorf("resolve config dir: %w", err) } - if err := os.MkdirAll(abs, 0o755); err != nil { + if err := os.MkdirAll(abs, 0o700); err != nil { return nil, fmt.Errorf("create config dir: %w", err) } // Ensure backups dir exists - if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o700); err != nil { return nil, fmt.Errorf("create backups dir: %w", err) } - return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil + return &Service{configDir: abs, db: db, auditRepo: auditRepo, locks: NewFileLocks()}, nil } func (s *Service) ConfigDir() string { return s.configDir } @@ -162,8 +166,39 @@ type PreviewResult struct { func (s *Service) Preview(filename, content string) PreviewResult { errs := ValidateContent(filename, content) if len(errs) > 0 { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: "", + Username: "", + Role: "", + Action: "failed_apply", // Preview failure is logged as failed_apply + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed_validation", + ErrorCategory: "validation", + RollbackOccurred: false, + ErrorMessage: errs[0].Error(), + }) return PreviewResult{Valid: false, Errors: errs} } + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: "", + Username: "", + Role: "", + Action: "preview", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: contentHash(content), + Result: "success", + ErrorCategory: "", + RollbackOccurred: false, + }) // Diff vs current file (if exists) oldContent := "" if p, err := s.sanitizedPath(filename); err == nil { @@ -176,12 +211,31 @@ func (s *Service) Preview(filename, content string) PreviewResult { } // Apply validates, backs up, then atomically writes. Requires confirm=true caller. +// After writing, verifies the file content matches. If verification fails, +// attempts rollback to the previous known-good version. func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) { if !confirm { return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true") } errs := ValidateContent(filename, content) if len(errs) > 0 { + // Log failed validation audit event +s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "failed_apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed_validation", + ErrorCategory: "validation", + RollbackOccurred: false, + ErrorMessage: errs[0].Error(), + }) return PreviewResult{Valid: false, Errors: errs}, nil } p, err := s.sanitizedPath(filename) @@ -192,6 +246,23 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview unlock := s.locks.Lock(filename) defer unlock() + // Audit: apply started + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "", + ErrorCategory: "", + RollbackOccurred: false, + }) + // Backup current content oldContent := "" if b, err := os.ReadFile(p); err == nil { @@ -204,36 +275,102 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview // Store backup in DB backupID := uuid.New().String() - _, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`, - backupID, filename, oldContent, userID, "apply") + contentHash := contentHash(oldContent) + _, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?, ?)`, + backupID, filename, oldContent, userID, "apply", contentHash) if err != nil { - return PreviewResult{}, fmt.Errorf("store backup: %w", err) + // Log backup storage failure but continue if possible + _ = err } // Also filesystem backup backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix())) - _ = os.WriteFile(backupPath, []byte(oldContent), 0o644) + _ = os.WriteFile(backupPath, []byte(oldContent), 0o600) // Prune old filesystem backups (keep 20) s.pruneFilesystemBackups(filename) // Atomic write: temp file in same dir, fsync, rename tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4)) - f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "write_failed", + ErrorCategory: "write", + RollbackOccurred: false, + ErrorMessage: fmt.Sprintf("create temp file: %v", err), + }) return PreviewResult{}, fmt.Errorf("create temp file: %w", err) } if _, err := f.WriteString(content); err != nil { f.Close() os.Remove(tmpName) + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "write_failed", + ErrorCategory: "write", + RollbackOccurred: false, + ErrorMessage: fmt.Sprintf("write temp: %v", err), + }) return PreviewResult{}, fmt.Errorf("write temp: %w", err) } if err := f.Sync(); err != nil { f.Close() os.Remove(tmpName) + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "write_failed", + ErrorCategory: "write", + RollbackOccurred: false, + ErrorMessage: fmt.Sprintf("fsync temp: %v", err), + }) return PreviewResult{}, fmt.Errorf("fsync temp: %w", err) } f.Close() if err := os.Rename(tmpName, p); err != nil { os.Remove(tmpName) + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "write_failed", + ErrorCategory: "write", + RollbackOccurred: false, + ErrorMessage: fmt.Sprintf("rename: %v", err), + }) return PreviewResult{}, fmt.Errorf("rename: %w", err) } // fsync directory @@ -245,6 +382,76 @@ func (s *Service) Apply(filename, content, userID string, confirm bool) (Preview // Prune DB backups (keep 50 per file) s.pruneDBBackups(filename) + // --- POST-WRITE VERIFICATION --- + // Read the file back and verify content matches what was written. + // This is the safest available verification mechanism when Traefik hot-reload + // cannot be directly triggered or observed from the GUI. + var newContent []byte + newContent, err = os.ReadFile(p) + if err != nil { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "apply_failed", + ErrorCategory: "traefik_rejection", + RollbackOccurred: false, + ErrorMessage: fmt.Sprintf("read back written file: %v", err), + }) + return PreviewResult{}, fmt.Errorf("read back written file: %w", err) + } + if string(newContent) != content { + // Content mismatch — attempt rollback to the previous known-good version. + // Note: Traefik hot-reload verification is not instrumented from the GUI; + // the file system state is what we can verify. + rollbackResult, rollbackErr := s.Rollback(filename, backupID, userID) + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "apply_failed_rollback_succeeded", + ErrorCategory: "verification", + RollbackOccurred: true, + ErrorMessage: fmt.Sprintf("verification failed; rollback: %v", rollbackErr), + }) + if rollbackErr == nil && rollbackResult.Valid { + // Rollback succeeded — apply effectively failed even though the file + // write temporarily succeeded. Return a clear error indicating rollback. + return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; automatically rolled back") + } + // Rollback also failed — return both the original error and the rollback status. + return PreviewResult{}, fmt.Errorf("apply: file written but verification failed; could not roll back: %w", rollbackErr) + } + // Verification passed — content matches what was written. + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "apply", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: contentHash(content), + Result: "success", + ErrorCategory: "", + RollbackOccurred: false, + }) return PreviewResult{Valid: true, Diff: diff}, nil } @@ -275,15 +482,16 @@ type BackupInfo struct { CreatedAt string `json:"created_at"` CreatedBy string `json:"created_by"` Reason string `json:"reason"` + Hash string `json:"hash,omitempty"` // SHA256 of content for integrity verification } func (s *Service) History(filename string) ([]BackupInfo, error) { var rows *sql.Rows var err error if filename != "" { - rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename) + rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename) } else { - rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`) + rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason, hash FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`) } if err != nil { return nil, err @@ -292,7 +500,7 @@ func (s *Service) History(filename string) ([]BackupInfo, error) { var out []BackupInfo for rows.Next() { var b BackupInfo - if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason); err != nil { + if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason, &b.Hash); err != nil { return nil, err } out = append(out, b) @@ -303,38 +511,208 @@ func (s *Service) History(filename string) ([]BackupInfo, error) { // Rollback restores specified backup (or most recent if backupID empty) func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) { if err := ValidateFilename(filename); err != nil { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "validation", + RollbackOccurred: false, + ErrorMessage: err.Error(), + }) return PreviewResult{}, err } p, err := s.sanitizedPath(filename) if err != nil { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "validation", + RollbackOccurred: false, + ErrorMessage: err.Error(), + }) return PreviewResult{}, err } unlock := s.locks.Lock(filename) defer unlock() + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "", + ErrorCategory: "", + }) + var content string + var storedHash string if backupID != "" { - err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content) + err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content, &storedHash) if err == sql.ErrNoRows { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "backup_not_found", + ErrorCategory: "not_found", + RollbackOccurred: false, + ErrorMessage: "backup not found", + }) return PreviewResult{}, fmt.Errorf("backup not found") } if err != nil { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "database", + RollbackOccurred: false, + ErrorMessage: fmt.Sprintf("read backup: %v", err), + }) return PreviewResult{}, err } + // Verify hash integrity + computedHash := contentHash(content) + if computedHash != storedHash { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "validation", + RollbackOccurred: false, + ErrorMessage: "backup integrity check failed - hash mismatch", + }) + return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch") + } } else { // Most recent - err = s.db.QueryRow(`SELECT content FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content) + err = s.db.QueryRow(`SELECT content, hash FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content, &storedHash) if err == sql.ErrNoRows { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "backup_not_found", + ErrorCategory: "not_found", + RollbackOccurred: false, + ErrorMessage: "no backup found for " + filename, + }) return PreviewResult{}, fmt.Errorf("no backup found for %s", filename) } if err != nil { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "database", + RollbackOccurred: false, + ErrorMessage: fmt.Sprintf("read backup: %v", err), + }) return PreviewResult{}, err } + // Verify hash integrity + computedHash := contentHash(content) + if computedHash != storedHash { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "validation", + RollbackOccurred: false, + ErrorMessage: "backup integrity check failed - hash mismatch", + }) + return PreviewResult{}, fmt.Errorf("backup integrity check failed - hash mismatch") + } } // Validate rollback content (allow empty = delete file) if content != "" { if errs := ValidateContent(filename, content); len(errs) > 0 { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "validation", + RollbackOccurred: false, + ErrorMessage: errs[0].Error(), + }) return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error()) } } @@ -345,35 +723,113 @@ func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, er curContent = string(b) } rbID := uuid.New().String() - _, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`, - rbID, filename, curContent, userID, "rollback") + contentHash := contentHash(curContent) + _, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason, hash) VALUES (?, ?, ?, ?, ?)`, + rbID, filename, curContent, userID, "rollback", contentHash) if content == "" { // Original file was new: delete current file _ = os.Remove(p) + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "success", + ErrorCategory: "", + RollbackOccurred: true, + }) } else { tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4)) - f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "write", + RollbackOccurred: true, + ErrorMessage: fmt.Sprintf("create temp file: %v", err), + }) return PreviewResult{}, err } if _, err := f.WriteString(content); err != nil { f.Close() os.Remove(tmpName) + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "write", + RollbackOccurred: true, + ErrorMessage: fmt.Sprintf("write temp: %v", err), + }) return PreviewResult{}, err } f.Sync() f.Close() if err := os.Rename(tmpName, p); err != nil { os.Remove(tmpName) + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: "", + Result: "failed", + ErrorCategory: "write", + RollbackOccurred: true, + ErrorMessage: fmt.Sprintf("rename: %v", err), + }) return PreviewResult{}, err } if d, err := os.Open(s.configDir); err == nil { _ = d.Sync() d.Close() } + s.logAuditEvent(&repositories.AuditLogEntry{ + ID: "", + UserID: userID, + Username: "", + Role: "", + Action: "rollback", + ResourceType: "file", + ResourceName: filename, + Provider: "", + SourceFile: filename, + ContentHash: contentHash(content), + Result: "success", + ErrorCategory: "", + RollbackOccurred: true, + }) } - diff := UnifiedDiff(filename, curContent, content) return PreviewResult{Valid: true, Diff: diff}, nil } @@ -383,3 +839,90 @@ func randHex(n int) string { _, _ = rand.Read(b) return hex.EncodeToString(b) } + +func (s *Service) logAuditEvent(entry *repositories.AuditLogEntry) { + if s.auditRepo == nil { + return + } + var safeMsg string + if entry.ErrorMessage != "" { + safeMsg = bestEffortSanitize(entry.ErrorMessage) + } + entry.ErrorMessage = safeMsg + entry.Timestamp = time.Now().UTC() + if entry.ID == "" { + entry.ID = uuid.New().String() + } + if err := s.auditRepo.Create(entry); err != nil { + _ = err + return + } +} + +func bestEffortSanitize(msg string) string { + result := msg + // Best-effort redaction of common secret patterns + result = redactPasswords(result) + result = redactTokens(result) + return result +} + +func redactPasswords(msg string) string { + // Redact common secret patterns before logging to audit + patterns := [][]string{ + {"password=", "password=REDACTED"}, + {"password:\"", "password:\"REDACTED\""}, + {"password:'", "password:'REDACTED'"}, + {"secret=", "secret=REDACTED"}, + {"apiKey=", "apiKey=REDACTED"}, + {"token=", "token=REDACTED"}, + } + for _, p := range patterns { + msg = strings.ReplaceAll(msg, p[0], p[1]) + } + return msg +} + +func redactTokens(msg string) string { + // Redact Bearer tokens and authorization headers + msg = strings.ReplaceAll(msg, "Bearer ", "Bearer REDACTED") + msg = strings.ReplaceAll(msg, "Authorization: ", "Authorization: REDACTED") + // Redact long hex strings (32+ chars) that look like session/token IDs + re := regexp.MustCompile(`[0-9a-fA-F]{32,}`) + msg = re.ReplaceAllString(msg, "REDACTED_HEX") + return msg +} + +// contentHash returns a short hash of the configuration content for audit logging. +func contentHash(content string) string { + h := sha256.Sum256([]byte(content)) + return hex.EncodeToString(h[:8]) +} + +// FileRevision returns the current revision token for a file. +// The token is a content hash that can be used for optimistic concurrency control. +// Clients must include this token in preview/apply requests; if the file has changed +// since the token was generated, the request is rejected with HTTP 409. +func (s *Service) FileRevision(filename string) (string, error) { + hash, err := s.fileContentHash(filename) + if err != nil { + return "", err + } + return hash, nil +} + +// fileContentHash computes the SHA256 hash of a file's content for revision tracking. +func (s *Service) fileContentHash(filename string) (string, error) { + p, err := s.sanitizedPath(filename) + if err != nil { + return "", err + } + b, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("file not found: %s", filename) + } + return "", err + } + return contentHash(string(b)), nil +} diff --git a/backend/internal/config/types.go b/backend/internal/config/types.go index 6b4c227..2524c52 100644 --- a/backend/internal/config/types.go +++ b/backend/internal/config/types.go @@ -1,11 +1,12 @@ package config type Config struct { - Addr string - DBPath string - SessionSecret string + Addr string + DBPath string + SessionSecret string CORSOrigin string TraefikAPIURL string ConfigDir string DevMode bool + TrustedProxies []string } \ No newline at end of file diff --git a/backend/internal/database/repositories/session.go b/backend/internal/database/repositories/session.go index 26aaacd..ba04d19 100644 --- a/backend/internal/database/repositories/session.go +++ b/backend/internal/database/repositories/session.go @@ -39,6 +39,11 @@ func (r *SessionRepository) Delete(id string) error { return err } +func (r *SessionRepository) DeleteByUserID(userID string) error { + _, err := r.db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID) + return err +} + func (r *SessionRepository) DeleteExpired() error { _, err := r.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, time.Now()) return err diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index ded6145..9725c13 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -10,6 +10,7 @@ import ( "time" _ "github.com/mattn/go-sqlite3" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -29,6 +30,10 @@ func New(path string) (*DB, error) { } db.SetMaxOpenConns(1) + // Restrict database file permissions (0600) to prevent unauthorized access + if err := os.Chmod(path, 0o600); err != nil { + log.Printf("warning: could not set db file permissions: %v", err) + } return &DB{db}, nil } @@ -69,6 +74,27 @@ func (d *DB) Migrate() error { )`, `CREATE INDEX IF NOT EXISTS idx_backups_filename ON backups(filename)`, `CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at)`, + `CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + role TEXT NOT NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_name TEXT, + provider TEXT, + source_file TEXT, + content_hash TEXT, + timestamp TEXT NOT NULL, + result TEXT NOT NULL, + error_category TEXT, + rollback_occurred INTEGER NOT NULL DEFAULT 0, + error_message TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_result ON audit_log(result)`, } for _, q := range queries { @@ -89,14 +115,17 @@ func (d *DB) ensureAdminUser() error { if count == 0 { // Default admin: admin / changeme (bcrypt hash) — development-only + // Generate random UUID for admin user ID (not predictable) + id := uuid.V4().String() hash := "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju" _, err = d.Exec( `INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`, - "admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", hash, "admin", + id, "admin", "admin@localhost", hash, "admin", ) if err != nil { return fmt.Errorf("create admin user: %w", err) } + log.Println("Default admin user created with generated UUID (development-only)") } return nil @@ -114,12 +143,14 @@ func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error { if err != nil { return fmt.Errorf("hash admin password: %w", err) } + // Generate random UUID for admin user ID (not predictable) + id := uuid.V4().String() // Upsert admin user _, err = d.Exec(` INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?) ON CONFLICT(username) DO UPDATE SET password_hash=excluded.password_hash, updated_at=CURRENT_TIMESTAMP - `, "admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", string(hash), "admin") + `, id, "admin", "admin@localhost", string(hash), "admin") if err != nil { return fmt.Errorf("upsert admin via env: %w", err) } diff --git a/docker/Dockerfile b/docker/Dockerfile index 6c91eb2..f87b37d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,8 +13,15 @@ COPY frontend/ ./ RUN npm run build FROM alpine:3.19 -RUN apk add --no-cache ca-certificates +RUN apk add --no-cache ca-certificates && \ + addgroup -S traefik-gui && \ + adduser -S -G traefik-gui traefik-gui && \ + mkdir -p /app/frontend/dist /data && \ + chown -R traefik-gui:traefik-gui /app/frontend/dist /data COPY --from=backend /traefik-gui /usr/local/bin/traefik-gui COPY --from=frontend /app/frontend/dist /app/frontend/dist +RUN chmod 755 /usr/local/bin/traefik-gui && \ + chown -R traefik-gui:traefik-gui /app/frontend/dist EXPOSE 8080 +USER traefik-gui ENTRYPOINT ["traefik-gui"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 751113b..e49d096 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -6,10 +6,10 @@ services: context: .. dockerfile: docker/Dockerfile ports: - - "8080:8080" + - "127.0.0.1:8080:8080" environment: - GUI_DB_PATH=/data/traefik-gui.db - - GUI_SESSION_SECRET=dev-secret-change-in-production-min-32-chars + - GUI_SESSION_SECRET=${GUI_SESSION_SECRET} - GUI_CORS_ORIGIN=http://localhost:5173 - GUI_ADDR=:8080 - GUI_DEV_MODE=true @@ -17,6 +17,7 @@ services: volumes: - ./data:/data - ./configs:/etc/traefik-gui/configs + command: ["/bin/sh", "-c", "chmod 0600 /data/traefik-gui.db || true && traefik-gui --dev --addr :8080"] depends_on: - traefik networks: @@ -25,16 +26,15 @@ services: traefik: image: traefik:v3.7 ports: - - "80:80" - - "443:443" - - "8081:8080" + - "127.0.0.1:80:80" + - "127.0.0.1:443:443" + - "127.0.0.1:8081:8080" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./docker/traefik.dev.yml:/etc/traefik/traefik.yml:ro - ./configs/dynamic:/etc/traefik/dynamic:ro command: - - "--api.insecure=true" - - "--api.dashboard=true" + - "--api.dashboard=false" - "--log.level=DEBUG" networks: - traefik-gui-net diff --git a/docker/traefik.dev.yml b/docker/traefik.dev.yml index df6131a..df28281 100644 --- a/docker/traefik.dev.yml +++ b/docker/traefik.dev.yml @@ -1,6 +1,6 @@ api: - dashboard: true - insecure: true + dashboard: false + insecure: false entryPoints: web: address: ":80"