Add handlers for enable_standard_headers, forward_auth_enabled, and waf_disabled fields in the proxy host Update function. These fields were defined in the model but were not being processed during updates, causing: - 500 errors when saving proxy host configurations - Auth pass-through failures for apps like Seerr/Overseerr due to missing X-Forwarded-* headers Changes: - backend: Add field handlers for 3 missing fields in proxy_host_handler.go - backend: Add 5 comprehensive unit tests for field handling - frontend: Update TypeScript ProxyHost interface with missing fields - docs: Document fixes in CHANGELOG.md Tests: All 1147 tests pass (backend 85.6%, frontend 87.7% coverage) Security: No vulnerabilities (Trivy + govulncheck clean) Fixes #16 (auth pass-through) Fixes #17 (500 error on save)
22 KiB
SecurityHeaders Page Rendering Issue - Root Cause Analysis
Date: December 18, 2025 Issue: SecurityHeaders page is not rendering Analysis Type: Complete Workflow Trace (NO CODE CHANGES)
Executive Summary
ROOT CAUSE IDENTIFIED: Backend-Frontend API Response Format Mismatch
The backend returns security header profiles wrapped in an object with a profiles key:
{ "profiles": [...] }
But the frontend expects a raw array:
Promise<SecurityHeaderProfile[]>
This causes the frontend React Query hook to fail silently, preventing the page from rendering the data correctly.
Complete File Workflow Map
1. Frontend Component Layer
File: frontend/src/pages/SecurityHeaders.tsx
Role: Main page component that displays security header profiles
Key Operations:
- Uses
useSecurityHeaderProfiles()hook to fetch profiles - Expects
data: SecurityHeaderProfile[] | undefined - Filters profiles into
customProfilesandpresetProfiles - Renders cards for each profile
Critical Line:
const { data: profiles, isLoading } = useSecurityHeaderProfiles();
// Expects profiles to be SecurityHeaderProfile[] or undefined
2. Frontend Hooks Layer
File: frontend/src/hooks/useSecurityHeaders.ts
Role: React Query wrapper for security headers API
Key Operations:
export function useSecurityHeaderProfiles() {
return useQuery({
queryKey: ['securityHeaderProfiles'],
queryFn: securityHeadersApi.listProfiles, // ← Expects array return
});
}
Expected Return Type: Promise<SecurityHeaderProfile[]>
3. Frontend API Layer
File: frontend/src/api/securityHeaders.ts
Role: Type-safe API client for security headers endpoints
Key Operations:
async listProfiles(): Promise<SecurityHeaderProfile[]> {
const response = await client.get<SecurityHeaderProfile[]>('/security/headers/profiles');
return response.data; // ← Expects response.data to be an array
}
API Endpoint: GET /api/v1/security/headers/profiles
Expected Response Format: Direct array SecurityHeaderProfile[]
4. Frontend HTTP Client
File: frontend/src/api/client.ts
Role: Axios instance with base configuration
Configuration:
- Base URL:
/api/v1 - With credentials:
true(for cookies) - Timeout: 30 seconds
- 401 interceptor for auth errors
No issues here - properly configured.
5. Backend Route Registration
File: backend/internal/api/routes/routes.go
Role: Registers all API routes including security headers
Lines 421-423:
// Security Headers
securityHeadersHandler := handlers.NewSecurityHeadersHandler(db, caddyManager)
securityHeadersHandler.RegisterRoutes(protected)
Routes Registered:
GET /api/v1/security/headers/profiles→ListProfilesGET /api/v1/security/headers/profiles/:id→GetProfilePOST /api/v1/security/headers/profiles→CreateProfilePUT /api/v1/security/headers/profiles/:id→UpdateProfileDELETE /api/v1/security/headers/profiles/:id→DeleteProfileGET /api/v1/security/headers/presets→GetPresetsPOST /api/v1/security/headers/presets/apply→ApplyPresetPOST /api/v1/security/headers/score→CalculateScorePOST /api/v1/security/headers/csp/validate→ValidateCSPPOST /api/v1/security/headers/csp/build→BuildCSP
Migration: Line 48 includes &models.SecurityHeaderProfile{} in AutoMigrate list ✓
6. Backend Handler
File: backend/internal/api/handlers/security_headers_handler.go
Role: HTTP handlers for security headers endpoints
ListProfiles Handler (Lines 54-62):
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}) // ← WRAPPED IN OBJECT!
}
ACTUAL Response Format:
{
"profiles": [
{ "id": 1, "name": "...", ... },
{ "id": 2, "name": "...", ... }
]
}
GetPresets Handler (Lines 233-236):
func (h *SecurityHeadersHandler) GetPresets(c *gin.Context) {
presets := h.service.GetPresets()
c.JSON(http.StatusOK, gin.H{"presets": presets}) // ← ALSO WRAPPED!
}
Other Handlers:
GetProfilereturns:gin.H{"profile": profile}(wrapped)CreateProfilereturns:gin.H{"profile": req}(wrapped)UpdateProfilereturns:gin.H{"profile": updates}(wrapped)ApplyPresetreturns:gin.H{"profile": profile}(wrapped)
ALL ENDPOINTS WRAP RESPONSES IN OBJECTS!
7. Backend Model
File: backend/internal/models/security_header_profile.go
Role: GORM database model for security header profiles
Struct Definition:
type SecurityHeaderProfile struct {
ID uint `json:"id" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
Name string `json:"name" gorm:"index;not null"`
// HSTS Configuration
HSTSEnabled bool `json:"hsts_enabled" gorm:"default:true"`
HSTSMaxAge int `json:"hsts_max_age" gorm:"default:31536000"`
HSTSIncludeSubdomains bool `json:"hsts_include_subdomains" gorm:"default:true"`
HSTSPreload bool `json:"hsts_preload" gorm:"default:false"`
// ... (25+ more fields)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
JSON Tags: ✓ All fields have proper json:"snake_case" tags
GORM Tags: ✓ All fields have proper GORM configuration
No issues in the model layer
Data Flow Diagram (Text-Based)
┌─────────────────────────────────────────────────────────────────┐
│ USER OPENS /security/headers │
└─────────────────┬───────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SecurityHeaders.tsx Component │
│ - Calls: useSecurityHeaderProfiles() │
│ - Expects: SecurityHeaderProfile[] | undefined │
└─────────────────┬───────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ useSecurityHeaders Hook │
│ - React Query: queryFn: securityHeadersApi.listProfiles │
│ - Returns: Promise<SecurityHeaderProfile[]> │
└─────────────────┬───────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ securityHeaders.ts API Layer │
│ - client.get<SecurityHeaderProfile[]>('/security/headers/...') │
│ - Returns: response.data ← Expects array! │
└─────────────────┬───────────────────────────────────────────────┘
│
▼ HTTP GET /api/v1/security/headers/profiles
┌─────────────────────────────────────────────────────────────────┐
│ client.ts (Axios) │
│ - Base URL: /api/v1 │
│ - Makes request with auth cookies │
└─────────────────┬───────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend: routes.go │
│ - Route: GET /security/headers/profiles │
│ - Handler: securityHeadersHandler.ListProfiles │
└─────────────────┬───────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ security_headers_handler.go │
│ - Query: db.Order(...).Find(&profiles) │
│ - Response: gin.H{"profiles": profiles} ← OBJECT WRAPPER! │
└─────────────────┬───────────────────────────────────────────────┘
│
▼ Returns: {"profiles": [...]}
┌─────────────────────────────────────────────────────────────────┐
│ Frontend receives: │
│ { │
│ "profiles": [ │
│ { id: 1, name: "...", ... } │
│ ] │
│ } │
│ │
│ BUT TypeScript expects: │
│ [ │
│ { id: 1, name: "...", ... } │
│ ] │
│ │
│ TYPE MISMATCH → React Query fails to parse → Page shows empty │
└─────────────────────────────────────────────────────────────────┘
Identified Logic Gaps
GAP #1: Response Format Mismatch ⚠️ CRITICAL
Location: Backend handlers vs Frontend API layer
Backend Returns:
{
"profiles": [...]
}
Frontend Expects:
[...]
Impact:
- React Query receives an object when it expects an array
response.datainsecurityHeadersApi.listProfiles()contains{ profiles: [...] }, not[...]- This causes the component to receive
undefinedinstead of an array - Page cannot render profile data
Affected Endpoints:
GET /security/headers/profiles→ Returnsgin.H{"profiles": profiles}GET /security/headers/presets→ Returnsgin.H{"presets": presets}GET /security/headers/profiles/:id→ Returnsgin.H{"profile": profile}POST /security/headers/profiles→ Returnsgin.H{"profile": req}PUT /security/headers/profiles/:id→ Returnsgin.H{"profile": updates}POST /security/headers/presets/apply→ Returnsgin.H{"profile": profile}
GAP #2: Inconsistent API Pattern
Issue: This endpoint doesn't follow the project's established pattern
Evidence from other handlers:
Looking at the test file security_headers_handler_test.go line 58-62:
var response map[string][]models.SecurityHeaderProfile
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Len(t, response["profiles"], 2) // ← Test expects wrapped format
The backend tests are written for the wrapped format, which means this was intentional. However, this creates an inconsistency with the frontend expectations.
Comparison with other endpoints:
Most endpoints in Charon follow this pattern in handlers, but the frontend API layer typically unwraps them. For example, if we look at other API calls, they might do:
const response = await client.get<{profiles: SecurityHeaderProfile[]}>('/endpoint');
return response.data.profiles; // ← Unwrap the data
But securityHeaders.ts doesn't unwrap:
const response = await client.get<SecurityHeaderProfile[]>('/security/headers/profiles');
return response.data; // ← Missing unwrap!
GAP #3: Missing Error Visibility
Issue: Silent failure in React Query
Current Behavior:
- When the type mismatch occurs, React Query doesn't throw a visible error
- The component receives
data: undefined isLoadingbecomesfalse- Page shows "No custom profiles yet" empty state even if profiles exist
Expected Behavior:
- Should show an error message
- Should log the type mismatch to console
- Should prevent silent failures
Field Name Mapping Analysis
Frontend Type Definition (securityHeaders.ts)
export interface SecurityHeaderProfile {
id: number;
uuid: string;
name: string;
hsts_enabled: boolean;
hsts_max_age: number;
// ... (all snake_case)
}
Backend Model (security_header_profile.go)
type SecurityHeaderProfile struct {
ID uint `json:"id" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
Name string `json:"name" gorm:"index;not null"`
HSTSEnabled bool `json:"hsts_enabled" gorm:"default:true"`
HSTSMaxAge int `json:"hsts_max_age" gorm:"default:31536000"`
// ... (all have json:"snake_case" tags)
}
✓ Field names match perfectly - All Go struct fields have proper json tags in snake_case
✓ No camelCase vs snake_case issues
✓ Type compatibility is correct (uint→number, string→string, bool→boolean)
Root Cause Hypothesis
Primary Cause: API Response Wrapper Inconsistency
The SecurityHeaders feature fails to render because:
-
Backend Design Decision: All handlers return responses wrapped in objects with descriptive keys:
c.JSON(http.StatusOK, gin.H{"profiles": profiles}) -
Frontend Assumption: The API layer assumes direct array responses:
async listProfiles(): Promise<SecurityHeaderProfile[]> { const response = await client.get<SecurityHeaderProfile[]>('/security/headers/profiles'); return response.data; // data is {profiles: [...]}, not [...] } -
Type System Failure: TypeScript's type assertion doesn't prevent the runtime mismatch:
- TypeScript says: "This is
SecurityHeaderProfile[]" - Runtime reality: "This is
{profiles: SecurityHeaderProfile[]}" - React Query receives wrong type → component gets
undefined
- TypeScript says: "This is
-
Silent Failure: No console errors because:
- HTTP request succeeds (200 OK)
- JSON parsing succeeds
- React Query doesn't validate response shape
- Component defensively handles
undefinedwith empty state
Secondary Contributing Factors
- No runtime type validation: No schema validation (e.g., Zod) to catch mismatches
- Inconsistent patterns: Other parts of the codebase may handle this differently
- Test coverage gap: Frontend tests likely mock the API, missing the real mismatch
Verification Steps Performed
✓ Checked Component Implementation
- SecurityHeaders.tsx exists and imports correct hooks
- Component structure is sound
- No syntax errors
✓ Checked Hooks Layer
- useSecurityHeaders.ts properly uses React Query
- Query keys are correct
- Mutation handlers are properly structured
✓ Checked API Layer
- securityHeaders.ts exists with all required functions
- Endpoints match backend routes
- ISSUE FOUND: Response type expectations don't match backend
✓ Checked Backend Handlers
- security_headers_handler.go exists with all required handlers
- Routes are properly registered in routes.go
- ISSUE FOUND: All responses are wrapped in objects
✓ Checked Database Models
- SecurityHeaderProfile model is complete
- All fields have proper JSON tags
- GORM configuration is correct
- Model is included in AutoMigrate
✓ Checked Route Registration
- Handler is instantiated in routes.go
- RegisterRoutes is called on the protected route group
- All security header routes are mounted correctly
Fix Strategy (NOT IMPLEMENTED - ANALYSIS ONLY)
Option 1: Update Frontend API Layer (RECOMMENDED)
Change: Unwrap responses in frontend/src/api/securityHeaders.ts
Before:
async listProfiles(): Promise<SecurityHeaderProfile[]> {
const response = await client.get<SecurityHeaderProfile[]>('/security/headers/profiles');
return response.data;
}
After:
async listProfiles(): Promise<SecurityHeaderProfile[]> {
const response = await client.get<{profiles: SecurityHeaderProfile[]}>('/security/headers/profiles');
return response.data.profiles; // ← Unwrap
}
Pros:
- Minimal changes (only update API layer)
- Maintains backend consistency
- No breaking changes for other consumers
- Tests remain valid
Cons:
- Frontend needs to know about backend wrapper keys
Option 2: Update Backend Handlers (NOT RECOMMENDED)
Change: Return direct arrays from handlers
Before:
c.JSON(http.StatusOK, gin.H{"profiles": profiles})
After:
c.JSON(http.StatusOK, profiles)
Pros:
- Simpler response format
- Matches frontend expectations
Cons:
- Breaking change for any existing API consumers
- Breaks all existing backend tests
- Inconsistent with other endpoints
- May violate REST best practices (responses should be objects)
Option 3: Add Runtime Type Validation (FUTURE IMPROVEMENT)
Add: Schema validation library (e.g., Zod)
import { z } from 'zod';
const SecurityHeaderProfileSchema = z.object({
id: z.number(),
uuid: z.string(),
name: z.string(),
// ... rest of fields
});
const ListProfilesResponseSchema = z.object({
profiles: z.array(SecurityHeaderProfileSchema),
});
async listProfiles(): Promise<SecurityHeaderProfile[]> {
const response = await client.get('/security/headers/profiles');
const validated = ListProfilesResponseSchema.parse(response.data);
return validated.profiles;
}
Pros:
- Catches mismatches at runtime
- Provides clear error messages
- Documents expected shapes
- Prevents silent failures
Cons:
- Adds dependency
- Increases bundle size
- Requires maintenance of schemas
Summary
What Prevents the Page from Loading?
The SecurityHeaders page does load, but it shows no data because:
- The component calls
useSecurityHeaderProfiles() - The hook calls
securityHeadersApi.listProfiles() - The API makes a successful HTTP request to
/api/v1/security/headers/profiles - Backend returns:
{"profiles": [...]} - Frontend expects:
[...] - Type mismatch causes React Query to provide
undefinedto the component - Component renders empty state: "No custom profiles yet"
The Fix (One-Line Change)
In frontend/src/api/securityHeaders.ts, change line 86-87:
// Change from:
const response = await client.get<SecurityHeaderProfile[]>('/security/headers/profiles');
return response.data;
// To:
const response = await client.get<{profiles: SecurityHeaderProfile[]}>('/security/headers/profiles');
return response.data.profiles;
Apply similar changes to all other methods in the file that fetch wrapped responses.
Files Requiring Updates (NOT IMPLEMENTED)
-
frontend/src/api/securityHeaders.ts- Unwrap all responseslistProfiles()- unwrap.profilesgetProfile()- unwrap.profilecreateProfile()- unwrap.profileupdateProfile()- unwrap.profilegetPresets()- unwrap.presetsapplyPreset()- unwrap.profile
-
Frontend tests - Update mocked responses to match wrapped format
frontend/src/hooks/__tests__/useSecurityHeaders.test.tsxfrontend/src/api/__tests__/securityHeaders.test.ts(if exists)
Conclusion
The root cause is a Backend-Frontend API contract mismatch. The backend wraps all responses in objects with descriptive keys (following a common REST pattern), but the frontend API layer assumes direct array/object responses. This is easily fixed by updating the frontend to unwrap the responses at the API layer boundary.
The bug is not a logic error but an integration contract violation - both sides work correctly in isolation, but they don't agree on the data format at the boundary.
Recommended Fix: Option 1 - Update frontend API layer (5-10 lines changed) Estimated Time: 5 minutes + testing Risk Level: Low (isolated to API layer)
Analysis completed: December 18, 2025 Analyst: GitHub Copilot Next Steps: Present findings to user for approval before implementing fix