Files
Charon/docs/reports/security_headers_trace.md
GitHub Actions f936c93896 fix: add missing field handlers in proxy host Update endpoint
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)
2025-12-20 01:55:52 +00:00

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 customProfiles and presetProfiles
  • 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/profilesListProfiles
  • GET /api/v1/security/headers/profiles/:idGetProfile
  • POST /api/v1/security/headers/profilesCreateProfile
  • PUT /api/v1/security/headers/profiles/:idUpdateProfile
  • DELETE /api/v1/security/headers/profiles/:idDeleteProfile
  • GET /api/v1/security/headers/presetsGetPresets
  • POST /api/v1/security/headers/presets/applyApplyPreset
  • POST /api/v1/security/headers/scoreCalculateScore
  • POST /api/v1/security/headers/csp/validateValidateCSP
  • POST /api/v1/security/headers/csp/buildBuildCSP

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:

  • GetProfile returns: gin.H{"profile": profile} (wrapped)
  • CreateProfile returns: gin.H{"profile": req} (wrapped)
  • UpdateProfile returns: gin.H{"profile": updates} (wrapped)
  • ApplyPreset returns: 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.data in securityHeadersApi.listProfiles() contains { profiles: [...] }, not [...]
  • This causes the component to receive undefined instead of an array
  • Page cannot render profile data

Affected Endpoints:

  1. GET /security/headers/profiles → Returns gin.H{"profiles": profiles}
  2. GET /security/headers/presets → Returns gin.H{"presets": presets}
  3. GET /security/headers/profiles/:id → Returns gin.H{"profile": profile}
  4. POST /security/headers/profiles → Returns gin.H{"profile": req}
  5. PUT /security/headers/profiles/:id → Returns gin.H{"profile": updates}
  6. POST /security/headers/presets/apply → Returns gin.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
  • isLoading becomes false
  • 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:

  1. Backend Design Decision: All handlers return responses wrapped in objects with descriptive keys:

    c.JSON(http.StatusOK, gin.H{"profiles": profiles})
    
  2. 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 [...]
    }
    
  3. 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
  4. Silent Failure: No console errors because:

    • HTTP request succeeds (200 OK)
    • JSON parsing succeeds
    • React Query doesn't validate response shape
    • Component defensively handles undefined with 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)

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

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:

  1. The component calls useSecurityHeaderProfiles()
  2. The hook calls securityHeadersApi.listProfiles()
  3. The API makes a successful HTTP request to /api/v1/security/headers/profiles
  4. Backend returns: {"profiles": [...]}
  5. Frontend expects: [...]
  6. Type mismatch causes React Query to provide undefined to the component
  7. 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)

  1. frontend/src/api/securityHeaders.ts - Unwrap all responses

    • listProfiles() - unwrap .profiles
    • getProfile() - unwrap .profile
    • createProfile() - unwrap .profile
    • updateProfile() - unwrap .profile
    • getPresets() - unwrap .presets
    • applyPreset() - unwrap .profile
  2. Frontend tests - Update mocked responses to match wrapped format

    • frontend/src/hooks/__tests__/useSecurityHeaders.test.tsx
    • frontend/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