# Security Headers "Apply Preset" Workflow Analysis **Date**: December 18, 2025 **Issue**: User confusion after applying security header preset - no feedback, unclear activation status --- ## Executive Summary The user applied a security header preset (e.g., "Basic Security") and experienced confusion because: 1. **No toast appeared** (actually it does, but message is ambiguous) 2. **No loading indicator** (button state doesn't show progress) 3. **Profile appeared in "Custom Profiles"** (unclear naming) 4. **Uncertainty about activation** (doesn't know if headers are live) 5. **Suggested renaming** section if headers are already active **KEY FINDING**: Headers are **NOT ACTIVE** after applying preset. The preset creates a **new custom profile** that must be **manually assigned to each proxy host** to take effect. **Root Cause**: UX does not communicate the multi-step workflow clearly. --- ## ๐ Complete Workflow Trace ### Step 1: User Action **Location**: [SecurityHeaders.tsx](../../frontend/src/pages/SecurityHeaders.tsx) **Trigger**: User clicks "Apply" button on a preset card (Basic/Strict/Paranoid) ```tsx ``` ### Step 2: Frontend Handler **Function**: `handleApplyPreset(presetType: string)` ```tsx const handleApplyPreset = (presetType: string) => { const name = `${presetType.charAt(0).toUpperCase() + presetType.slice(1)} Security Profile`; applyPresetMutation.mutate({ preset_type: presetType, name }); }; ``` **What happens**: - Constructs name: "Basic Security Profile", "Strict Security Profile", etc. - Calls mutation from React Query hook ### Step 3: React Query Hook **Location**: [useSecurityHeaders.ts](../../frontend/src/hooks/useSecurityHeaders.ts#L63-L74) ```typescript export function useApplySecurityHeaderPreset() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: ApplyPresetRequest) => securityHeadersApi.applyPreset(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] }); toast.success('Preset applied successfully'); }, onError: (error: Error) => { toast.error(`Failed to apply preset: ${error.message}`); }, }); } ``` **What happens**: - โ **DOES** show toast: `'Preset applied successfully'` - โ **DOES** invalidate queries (triggers refetch of profile list) - โ **DOES NOT** show loading indicator during mutation ### Step 4: Backend Handler **Location**: [security_headers_handler.go](../../backend/internal/api/handlers/security_headers_handler.go#L223-L240) ```go 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}) } ``` **What happens**: - Receives preset type and name - Delegates to service layer - Returns created profile - โ **DOES NOT** trigger `ApplyConfig()` (no Caddy reload) ### Step 5: Service Layer **Location**: [security_headers_service.go](../../backend/internal/services/security_headers_service.go#L95-L120) ```go func (s *SecurityHeadersService) ApplyPreset(presetType, name string) (*models.SecurityHeaderProfile, error) { presets := s.GetPresets() var selectedPreset *models.SecurityHeaderProfile for i := range presets { if presets[i].PresetType == presetType { selectedPreset = &presets[i] break } } if selectedPreset == nil { return nil, fmt.Errorf("preset type %s not found", presetType) } // Create a copy with custom name and UUID newProfile := *selectedPreset newProfile.ID = 0 // Clear ID so GORM creates a new record newProfile.UUID = uuid.New().String() newProfile.Name = name newProfile.IsPreset = false // User-created profiles are not presets newProfile.PresetType = "" // Clear preset type for custom profiles if err := s.db.Create(&newProfile).Error; err != nil { return nil, fmt.Errorf("failed to create profile from preset: %w", err) } return &newProfile, nil } ``` **What happens**: - Finds the requested preset (basic/strict/paranoid) - Creates a **COPY** of the preset as a new custom profile - Saves to database - Returns the new profile - โ **Profile is NOT assigned to any hosts** - โ **Headers are NOT active yet** ### Step 6: Profile Appears in UI **Location**: "Custom Profiles" section **What user sees**: - New card appears in "Custom Profiles" grid - Shows profile name, security score, timestamp - User can Edit/Clone/Delete - โ ๏ธ **No indication that profile needs to be assigned to hosts** --- ## ๐ Critical Understanding: Per-Host Assignment ### How Security Headers Work Security headers in Charon are **PER-HOST**, not global: ```go // ProxyHost model type ProxyHost struct { // ... SecurityHeaderProfileID *uint `json:"security_header_profile_id"` SecurityHeaderProfile *SecurityHeaderProfile `json:"security_header_profile" gorm:"foreignKey:SecurityHeaderProfileID"` SecurityHeadersEnabled bool `json:"security_headers_enabled" gorm:"default:true"` SecurityHeadersCustom string `json:"security_headers_custom" gorm:"type:text"` // ... } ``` **Key facts**: 1. Each proxy host can reference ONE profile via `SecurityHeaderProfileID` 2. If no profile is assigned, host uses inline settings or defaults 3. Creating a profile **DOES NOT** automatically assign it to any hosts 4. Headers are applied when Caddy config is generated from ProxyHost data ### When Headers Become Active **Location**: [config.go](../../backend/internal/caddy/config.go#L1143-L1160) ```go func buildSecurityHeadersHandler(host *models.ProxyHost) (Handler, error) { if host == nil { return nil, nil } // Use profile if configured var cfg *models.SecurityHeaderProfile if host.SecurityHeaderProfile != nil { cfg = host.SecurityHeaderProfile // โ Profile assigned to host } else if !host.SecurityHeadersEnabled { // No profile and headers disabled - skip return nil, nil } else { // Use default secure headers cfg = getDefaultSecurityHeaderProfile() // โ ๏ธ Fallback defaults } // ... builds headers from cfg ... } ``` **Activation requires**: 1. User creates/edits a proxy host 2. User selects the security header profile in the host form 3. User saves the host 4. `ProxyHostHandler.UpdateProxyHost()` calls `caddyManager.ApplyConfig()` 5. Caddy reloads with new headers applied --- ## โ Current Behavior vs โ Expected Behavior | Aspect | Current | Expected | Severity | |--------|---------|----------|----------| | **Toast notification** | โ Shows "Preset applied successfully" | โ Same (but could be clearer) | Low | | **Loading indicator** | โ None during mutation | โ Should show loading state on button | Medium | | **Profile location** | โ Appears in "Custom Profiles" | โ ๏ธ Should clarify activation needed | High | | **User confusion** | โ "Is it active?" "What's next?" | โ Clear next steps | **Critical** | | **Caddy reload** | โ Not triggered | โ Correct - only reload when assigned to host | Low | | **Section naming** | "Custom Profiles" | โ ๏ธ Misleading - implies active | Medium | --- ## ๐ Root Cause of User Confusion ### Problem 1: Ambiguous Toast Message **Current**: `"Preset applied successfully"` **User thinks**: "Applied to what? Is it protecting my sites now?" ### Problem 2: No Loading Indicator Button shows no feedback during the async operation. User doesn't know: - When request starts - When request completes - If anything happened at all ### Problem 3: "Custom Profiles" Name is Misleading This section name implies: - These are "your active profiles" - Headers are protecting something **Reality**: These are **AVAILABLE** profiles, not **ACTIVE** profiles ### Problem 4: No Next Steps Guidance After applying preset, UI doesn't tell user: - โ Profile created - โ ๏ธ **Next**: Assign this profile to proxy hosts - ๐ **Where**: Edit any Proxy Host โ Security Headers dropdown --- ## ๐ฏ Recommended Fixes ### Fix 1: Improve Toast Messages โญ HIGH PRIORITY **Change in**: [useSecurityHeaders.ts](../../frontend/src/hooks/useSecurityHeaders.ts) ```typescript // CURRENT toast.success('Preset applied successfully'); // RECOMMENDED toast.success('Profile created! Assign it to proxy hosts to activate headers.'); ``` **Better yet**, use a rich toast with action: ```typescript onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] }); toast.success(
Assign it to proxy hosts to activate security headers.
Next Step: Assign to Proxy Hosts
Go to Proxy Hosts, edit a host, and select this profile under "Security Headers" to activate protection.