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:
parent
7fc90524b5
commit
b587fb87a9
18 changed files with 987 additions and 87 deletions
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue