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
}