Security Audit Fixes: Session fixation prevention, SameSite cookies, audit log redaction, backup integrity, Docker hardening, error sanitization, rate limiting, trusted proxies, and config hardening

This commit is contained in:
backup 2026-09-03 23:55:25 -05:00
commit b587fb87a9
18 changed files with 987 additions and 87 deletions

View file

@ -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,
})
}

View file

@ -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 {

View file

@ -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())

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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)
}