Files
Charon/docs/plans/current_spec.md
GitHub Actions ab4db87f59 fix: remove invalid trusted_proxies structure causing 500 error on proxy host save
Remove handler-level `trusted_proxies` configuration from ReverseProxyHandler that was
using an invalid object structure. Caddy's reverse_proxy handler expects trusted_proxies
to be an array of CIDR strings, not an object with {source, ranges}.

The server-level trusted_proxies configuration in config.go already provides equivalent
IP spoofing protection globally for all routes, making the handler-level setting redundant.

Changes:
- backend: Remove lines 184-189 from internal/caddy/types.go
- backend: Update 3 unit tests to remove handler-level trusted_proxies assertions
- docs: Document fix in CHANGELOG.md

Fixes: #[issue-number] (500 error when saving proxy hosts)

Tests: All 84 backend tests pass (84.6% coverage)
Security: Trivy + govulncheck clean, no vulnerabilities
2025-12-20 05:46:03 +00:00

24 KiB

Critical Bug Analysis: 500 Error on Proxy Host Save

Summary

Root Cause Identified: The 500 error is caused by an invalid Caddy configuration structure where trusted_proxies is set as an object at the handler level (within reverse_proxy), but Caddy's http.handlers.reverse_proxy expects it to be either:

  1. An array of strings at the handler level, OR
  2. An object only at the server level

The error from Caddy logs:

json: cannot unmarshal object into Go struct field Handler.trusted_proxies of type []string

1. Complete File List with Key Functions

Frontend Layer

File Key Functions/Lines Purpose
frontend/src/components/ProxyHostForm.tsx handleSubmit() L302-332 Form submission, calls onSubmit(payloadWithoutUptime)
frontend/src/hooks/useProxyHosts.ts updateMutation L25-31, updateHost() L50 React Query mutation for PUT requests
frontend/src/api/proxyHosts.ts updateProxyHost() L57-60 API client - PUT /proxy-hosts/{uuid}

Backend Layer

File Key Functions/Lines Purpose
backend/internal/api/routes/routes.go L341-342 Route registration: router.PUT("/proxy-hosts/:uuid", h.Update)
backend/internal/api/handlers/proxy_host_handler.go Update() L133-262 HTTP handler - parses payload, updates model, calls ApplyConfig()
backend/internal/services/proxyhost_service.go Update() L57-76 Business logic - validates domain uniqueness, DB update
backend/internal/models/proxy_host.go ProxyHost struct L10-61 Database model with all fields

Caddy Configuration Layer

File Key Functions/Lines Purpose
backend/internal/caddy/manager.go ApplyConfig() L48-169 Orchestrates config generation, validation, and application
backend/internal/caddy/config.go GenerateConfig() L22-310 Builds complete Caddy JSON config from DB
backend/internal/caddy/types.go ReverseProxyHandler() L130-201 🔴 BUG LOCATION - Creates invalid trusted_proxies structure

2. Data Flow Diagram

┌─────────────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND                                                                             │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                      │
│  ProxyHostForm.tsx                                                                   │
│  ┌─────────────────────────────────────┐                                             │
│  │ formData = {                        │                                             │
│  │   name: "Test",                     │                                             │
│  │   enable_standard_headers: true,    │  ← User enables this checkbox              │
│  │   websocket_support: true,          │                                             │
│  │   ...                               │                                             │
│  │ }                                   │                                             │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
│                    ▼ handleSubmit() L302-332                                         │
│  ┌─────────────────────────────────────┐                                             │
│  │ onSubmit(payloadWithoutUptime)      │                                             │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
│                    ▼ useProxyHosts.ts updateHost() L50                               │
│  ┌─────────────────────────────────────┐                                             │
│  │ updateMutation.mutateAsync({        │                                             │
│  │   uuid: "...",                      │                                             │
│  │   data: payload                     │                                             │
│  │ })                                  │                                             │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
│                    ▼ proxyHosts.ts updateProxyHost() L57-60                          │
│  ┌─────────────────────────────────────┐                                             │
│  │ client.put(`/proxy-hosts/${uuid}`,  │                                             │
│  │   host)                             │                                             │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
└────────────────────│────────────────────────────────────────────────────────────────┘
                     │
                     │ HTTP PUT /api/v1/proxy-hosts/{uuid}
                     │ JSON Body: { enable_standard_headers: true, ... }
                     ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ BACKEND                                                                              │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                      │
│  routes.go L341-342                                                                  │
│  ┌─────────────────────────────────────┐                                             │
│  │ router.PUT("/proxy-hosts/:uuid",    │                                             │
│  │   h.Update)                         │                                             │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
│                    ▼ proxy_host_handler.go Update() L133-262                         │
│  ┌─────────────────────────────────────┐                                             │
│  │ 1. GetByUUID(uuidStr) → host        │                                             │
│  │ 2. c.ShouldBindJSON(&payload)       │                                             │
│  │ 3. Parse fields from payload        │                                             │
│  │    - enable_standard_headers        │ ← Correctly parsed at L182-189             │
│  │ 4. h.service.Update(host)           │                                             │
│  │ 5. h.caddyManager.ApplyConfig()     │ ← 🔴 FAILURE POINT                          │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
│                    ▼ proxyhost_service.go Update() L57-76                            │
│  ┌─────────────────────────────────────┐                                             │
│  │ 1. ValidateUniqueDomain()           │                                             │
│  │ 2. Normalize advanced_config        │                                             │
│  │ 3. db.Updates(host)                 │ ✅ Database update SUCCEEDS                 │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
│                    ▼ manager.go ApplyConfig() L48-169                                │
│  ┌─────────────────────────────────────┐                                             │
│  │ 1. db.Find(&hosts)                  │                                             │
│  │ 2. GenerateConfig(hosts, ...)       │                                             │
│  │ 3. ValidateConfig(config)           │                                             │
│  │ 4. m.client.Load(ctx, config)       │ ← 🔴 CADDY REJECTS CONFIG                   │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
│                    ▼ config.go GenerateConfig() L22-310                              │
│  ┌─────────────────────────────────────┐                                             │
│  │ For each enabled host:              │                                             │
│  │   ReverseProxyHandler(dial,         │                                             │
│  │     websocket, app,                 │                                             │
│  │     enableStandardHeaders)          │ ← 🔴 BUG TRIGGERED HERE                     │
│  └─────────────────────────────────────┘                                             │
│                    │                                                                 │
│                    ▼ types.go ReverseProxyHandler() L130-201                         │
│  ┌─────────────────────────────────────┐                                             │
│  │ 🔴 BUG: Creates invalid structure   │                                             │
│  │                                     │                                             │
│  │ h["trusted_proxies"] = map[string]  │                                             │
│  │   interface{}{                      │                                             │
│  │   "source": "static",               │ ← WRONG: Object at handler level            │
│  │   "ranges": []string{"private_..."},│                                             │
│  │ }                                   │                                             │
│  └─────────────────────────────────────┘                                             │
│                                                                                      │
└─────────────────────────────────────────────────────────────────────────────────────┘
                     │
                     │ POST /load to Caddy Admin API
                     │ (Invalid JSON structure)
                     ▼
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ CADDY                                                                                │
├─────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                      │
│  EXPECTED by http.handlers.reverse_proxy:                                            │
│  ┌─────────────────────────────────────┐                                             │
│  │ "trusted_proxies": ["192.168.0.0/16",│ ← Array of strings                         │
│  │   "10.0.0.0/8", ...]                │                                             │
│  └─────────────────────────────────────┘                                             │
│                                                                                      │
│  RECEIVED (invalid):                                                                 │
│  ┌─────────────────────────────────────┐                                             │
│  │ "trusted_proxies": {                │ ← Object (wrong type!)                      │
│  │   "source": "static",               │                                             │
│  │   "ranges": ["private_ranges"]      │                                             │
│  │ }                                   │                                             │
│  └─────────────────────────────────────┘                                             │
│                                                                                      │
│  🔴 ERROR:                                                                           │
│  "json: cannot unmarshal object into Go struct field                                 │
│   Handler.trusted_proxies of type []string"                                          │
│                                                                                      │
└─────────────────────────────────────────────────────────────────────────────────────┘

3. JSON Payload Comparison

What Frontend Sends (Correct)

{
  "name": "Test Service",
  "domain_names": "test.example.com",
  "forward_scheme": "http",
  "forward_host": "192.168.1.100",
  "forward_port": 8080,
  "ssl_forced": true,
  "http2_support": true,
  "hsts_enabled": true,
  "hsts_subdomains": true,
  "block_exploits": true,
  "websocket_support": true,
  "enable_standard_headers": true,
  "application": "none",
  "enabled": true,
  "certificate_id": null,
  "access_list_id": null,
  "security_header_profile_id": null
}

What Backend Generates for Caddy (INVALID)

{
  "handler": "reverse_proxy",
  "upstreams": [{ "dial": "192.168.1.100:8080" }],
  "flush_interval": -1,
  "headers": {
    "request": {
      "set": {
        "X-Real-IP": ["{http.request.remote.host}"],
        "X-Forwarded-Proto": ["{http.request.scheme}"],
        "X-Forwarded-Host": ["{http.request.host}"],
        "X-Forwarded-Port": ["{http.request.port}"]
      }
    }
  },
  "trusted_proxies": {
    "source": "static",
    "ranges": ["private_ranges"]
  }
}

What Caddy EXPECTS (Correct Structure)

According to Caddy's documentation, at the handler level, trusted_proxies must be an array of CIDR strings:

{
  "handler": "reverse_proxy",
  "upstreams": [{ "dial": "192.168.1.100:8080" }],
  "flush_interval": -1,
  "headers": {
    "request": {
      "set": {
        "X-Real-IP": ["{http.request.remote.host}"],
        "X-Forwarded-Proto": ["{http.request.scheme}"],
        "X-Forwarded-Host": ["{http.request.host}"],
        "X-Forwarded-Port": ["{http.request.port}"]
      }
    }
  },
  "trusted_proxies": [
    "192.168.0.0/16",
    "10.0.0.0/8",
    "172.16.0.0/12",
    "127.0.0.1/32",
    "::1/128"
  ]
}

Note: The object structure with source and ranges is valid at the server level (in Server.TrustedProxies), not at the handler level.


4. Root Cause Analysis

The Bug Location

File: backend/internal/caddy/types.go Function: ReverseProxyHandler() Lines: 186-189

// STEP 4: Always configure trusted_proxies for security when headers are set
// This prevents IP spoofing attacks by only trusting headers from known proxy sources
if len(setHeaders) > 0 {
    h["trusted_proxies"] = map[string]interface{}{      // 🔴 BUG: Object structure
        "source": "static",
        "ranges": []string{"private_ranges"},           // 🔴 "private_ranges" is also invalid
    }
}

Problems

  1. Wrong Type: trusted_proxies at handler level must be []string, not map[string]interface{}
  2. Invalid Value: "private_ranges" is not a valid CIDR - Caddy doesn't expand this magic string at the handler level
  3. Server vs Handler Confusion: The object structure {source, ranges} is only valid in apps.http.servers.*.trusted_proxies, not in route handlers

Why This Wasn't Caught Before

  1. Unit tests pass because they only check the Go structure, not Caddy's actual validation
  2. Integration tests may have been run with a different Caddy version or config
  3. Recent addition: The trusted_proxies at handler level was added as part of the standard headers feature

5. Proposed Fix

The server-level trusted_proxies in config.go is already correctly configured and applies to ALL routes. The handler-level setting is redundant and incorrect.

File: backend/internal/caddy/types.go Change: Remove lines 184-189

// REMOVE THIS ENTIRE BLOCK (lines 184-189):
// STEP 4: Always configure trusted_proxies for security when headers are set
// This prevents IP spoofing attacks by only trusting headers from known proxy sources
if len(setHeaders) > 0 {
    h["trusted_proxies"] = map[string]interface{}{
        "source": "static",
        "ranges": []string{"private_ranges"},
    }
}

Rationale: The server already has trusted_proxies configured at config.go L295-306:

// Configure trusted proxies for proper client IP detection from X-Forwarded-For headers
trustedProxies := &TrustedProxies{
    Source: "static",
    Ranges: []string{
        "127.0.0.1/32",   // Localhost
        "::1/128",        // IPv6 localhost
        "172.16.0.0/12",  // Docker bridge networks
        "10.0.0.0/8",     // Private network
        "192.168.0.0/16", // Private network
    },
}

config.Apps.HTTP.Servers["charon_server"] = &Server{
    ...
    TrustedProxies: trustedProxies,  // ← This is correct and sufficient
    ...
}

Option B: Fix Handler-Level Structure (If Per-Route Control Needed)

If per-route trusted_proxies control is needed in the future, fix the structure:

// STEP 4: Always configure trusted_proxies for security when headers are set
if len(setHeaders) > 0 {
    h["trusted_proxies"] = []string{   // ← ARRAY of CIDRs
        "192.168.0.0/16",
        "10.0.0.0/8",
        "172.16.0.0/12",
        "127.0.0.1/32",
        "::1/128",
    }
}

Tests to Update

File: backend/internal/caddy/types_extra_test.go

Update tests that expect the object structure:

  • L87-93: TestReverseProxyHandler_StandardHeadersEnabled
  • L133-139: TestReverseProxyHandler_WebSocketWithApplication
  • L256-279: TestReverseProxyHandler_TrustedProxiesConfiguration

6. Verification Steps

After applying the fix:

  1. Rebuild container:

    docker build --no-cache -t charon:local .
    docker compose -f docker-compose.override.yml up -d
    
  2. Check logs for successful config application:

    docker logs charon 2>&1 | grep -i "caddy config"
    # Should see: "Successfully applied initial Caddy config"
    
  3. Test proxy host save:

    • Edit any proxy host in UI
    • Toggle "Enable Standard Proxy Headers"
    • Click Save
    • Should succeed (200 response, no 500 error)
  4. Verify Caddy config:

    curl -s http://localhost:2019/config/ | jq '.apps.http.servers.charon_server.trusted_proxies'
    # Should show server-level trusted_proxies (not in individual routes)
    

7. Timeline of Events

  1. Standard Proxy Headers Feature Added - Added enable_standard_headers field
  2. trusted_proxies Added to Handler - Someone added trusted_proxies at the handler level for security
  3. Wrong Structure Used - Used the server-level object format instead of handler-level array format
  4. Tests Pass - Go unit tests check structure, not Caddy validation
  5. Container Deployed - Bug deployed to production
  6. User Saves Proxy Host - Triggers config regeneration with invalid structure
  7. Caddy Rejects Config - Returns 400 error
  8. Handler Returns 500 - ApplyConfig failure triggers 500 response to user

8. Files Changed Summary

File Line(s) Change Required
backend/internal/caddy/types.go 184-189 DELETE the handler-level trusted_proxies block
backend/internal/caddy/types_extra_test.go 87-93, 133-139, 256-279 UPDATE tests to not expect handler-level trusted_proxies

9. Risk Assessment

Risk Level Mitigation
Breaking existing configs Low Server-level trusted_proxies already provides same protection
Security regression None Server-level config is equivalent protection
Test failures Medium Need to update 3 test functions
Rollback needed Low Simple code deletion, easy to revert

Conclusion

The 500 error is caused by incorrect JSON structure for trusted_proxies at the reverse_proxy handler level. The fix is to remove the handler-level trusted_proxies since the server-level configuration already provides the same security protection.

Recommended Action: Delete lines 184-189 in types.go and update the corresponding tests.