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

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