package handlers import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" ) // SecurityHeadersHandler manages security header profiles type SecurityHeadersHandler struct { db *gorm.DB caddyManager *caddy.Manager service *services.SecurityHeadersService } // NewSecurityHeadersHandler creates a new handler func NewSecurityHeadersHandler(db *gorm.DB, caddyManager *caddy.Manager) *SecurityHeadersHandler { return &SecurityHeadersHandler{ db: db, caddyManager: caddyManager, service: services.NewSecurityHeadersService(db), } } // RegisterRoutes registers all security headers routes func (h *SecurityHeadersHandler) RegisterRoutes(router *gin.RouterGroup) { group := router.Group("/security/headers") group.GET("/profiles", h.ListProfiles) group.GET("/profiles/:id", h.GetProfile) group.POST("/profiles", h.CreateProfile) group.PUT("/profiles/:id", h.UpdateProfile) group.DELETE("/profiles/:id", h.DeleteProfile) group.GET("/presets", h.GetPresets) group.POST("/presets/apply", h.ApplyPreset) group.POST("/score", h.CalculateScore) group.POST("/csp/validate", h.ValidateCSP) group.POST("/csp/build", h.BuildCSP) } // ListProfiles returns all security header profiles // GET /api/v1/security/headers/profiles func (h *SecurityHeadersHandler) ListProfiles(c *gin.Context) { var profiles []models.SecurityHeaderProfile if err := h.db.Order("is_preset DESC, name ASC").Find(&profiles).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"profiles": profiles}) } // GetProfile returns a single profile by ID or UUID // GET /api/v1/security/headers/profiles/:id func (h *SecurityHeadersHandler) GetProfile(c *gin.Context) { idParam := c.Param("id") var profile models.SecurityHeaderProfile // Try to parse as uint ID first if id, err := strconv.ParseUint(idParam, 10, 32); err == nil { if err := h.db.First(&profile, uint(id)).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } else { // Try UUID if err := h.db.Where("uuid = ?", idParam).First(&profile).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } c.JSON(http.StatusOK, gin.H{"profile": profile}) } // CreateProfile creates a new security header profile // POST /api/v1/security/headers/profiles func (h *SecurityHeadersHandler) CreateProfile(c *gin.Context) { var req models.SecurityHeaderProfile if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate name is provided if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } // Generate UUID req.UUID = uuid.New().String() // Calculate security score scoreResult := services.CalculateSecurityScore(&req) req.SecurityScore = scoreResult.TotalScore // Create profile if err := h.db.Create(&req).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"profile": req}) } // UpdateProfile updates an existing profile // PUT /api/v1/security/headers/profiles/:id func (h *SecurityHeadersHandler) UpdateProfile(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } var existing models.SecurityHeaderProfile if err := h.db.First(&existing, uint(id)).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Cannot modify presets if existing.IsPreset { c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify system presets"}) return } var updates models.SecurityHeaderProfile if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Preserve ID and UUID updates.ID = existing.ID updates.UUID = existing.UUID // Recalculate security score scoreResult := services.CalculateSecurityScore(&updates) updates.SecurityScore = scoreResult.TotalScore if err := h.db.Save(&updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"profile": updates}) } // DeleteProfile deletes a profile (not presets) // DELETE /api/v1/security/headers/profiles/:id func (h *SecurityHeadersHandler) DeleteProfile(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) return } var profile models.SecurityHeaderProfile if err := h.db.First(&profile, uint(id)).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Cannot delete presets if profile.IsPreset { c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete system presets"}) return } // Check if profile is in use by any proxy hosts var count int64 if err := h.db.Model(&models.ProxyHost{}).Where("security_header_profile_id = ?", id).Count(&count).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if count > 0 { c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("profile is in use by %d proxy host(s)", count)}) return } if err := h.db.Delete(&profile).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"deleted": true}) } // GetPresets returns the list of built-in presets // GET /api/v1/security/headers/presets func (h *SecurityHeadersHandler) GetPresets(c *gin.Context) { presets := h.service.GetPresets() c.JSON(http.StatusOK, gin.H{"presets": presets}) } // ApplyPreset applies a preset to create/update a profile // POST /api/v1/security/headers/presets/apply func (h *SecurityHeadersHandler) ApplyPreset(c *gin.Context) { var req struct { PresetType string `json:"preset_type" binding:"required"` Name string `json:"name" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } profile, err := h.service.ApplyPreset(req.PresetType, req.Name) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"profile": profile}) } // CalculateScore calculates security score for given settings // POST /api/v1/security/headers/score func (h *SecurityHeadersHandler) CalculateScore(c *gin.Context) { var profile models.SecurityHeaderProfile if err := c.ShouldBindJSON(&profile); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } scoreResult := services.CalculateSecurityScore(&profile) c.JSON(http.StatusOK, scoreResult) } // ValidateCSP validates a CSP string // POST /api/v1/security/headers/csp/validate func (h *SecurityHeadersHandler) ValidateCSP(c *gin.Context) { var req struct { CSP string `json:"csp" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } errors := validateCSPString(req.CSP) c.JSON(http.StatusOK, gin.H{ "valid": len(errors) == 0, "errors": errors, }) } // BuildCSP builds a CSP string from directives // POST /api/v1/security/headers/csp/build func (h *SecurityHeadersHandler) BuildCSP(c *gin.Context) { var req struct { Directives []models.CSPDirective `json:"directives" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Convert directives to map for JSON storage directivesMap := make(map[string][]string) for _, dir := range req.Directives { directivesMap[dir.Directive] = dir.Values } cspJSON, err := json.Marshal(directivesMap) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build CSP"}) return } c.JSON(http.StatusOK, gin.H{"csp": string(cspJSON)}) } // validateCSPString performs basic validation on a CSP string func validateCSPString(csp string) []string { var errors []string if csp == "" { errors = append(errors, "CSP cannot be empty") return errors } // Try to parse as JSON var directivesMap map[string][]string if err := json.Unmarshal([]byte(csp), &directivesMap); err != nil { errors = append(errors, "CSP must be valid JSON") return errors } // Validate known directives validDirectives := map[string]bool{ "default-src": true, "script-src": true, "style-src": true, "img-src": true, "font-src": true, "connect-src": true, "frame-src": true, "object-src": true, "media-src": true, "worker-src": true, "manifest-src": true, "base-uri": true, "form-action": true, "frame-ancestors": true, "report-uri": true, "report-to": true, "upgrade-insecure-requests": true, "block-all-mixed-content": true, } for directive := range directivesMap { if !validDirectives[directive] { errors = append(errors, fmt.Sprintf("unknown CSP directive: %s", directive)) } } // Warn about unsafe directives for directive, values := range directivesMap { for _, value := range values { if strings.Contains(value, "unsafe-inline") || strings.Contains(value, "unsafe-eval") { errors = append(errors, fmt.Sprintf("'%s' contains unsafe directive in %s", value, directive)) } } } return errors }