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

433 lines
24 KiB
Markdown

# 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](../../frontend/src/components/ProxyHostForm.tsx) | `handleSubmit()` L302-332 | Form submission, calls `onSubmit(payloadWithoutUptime)` |
| [frontend/src/hooks/useProxyHosts.ts](../../frontend/src/hooks/useProxyHosts.ts) | `updateMutation` L25-31, `updateHost()` L50 | React Query mutation for PUT requests |
| [frontend/src/api/proxyHosts.ts](../../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](../../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](../../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](../../backend/internal/services/proxyhost_service.go) | `Update()` L57-76 | Business logic - validates domain uniqueness, DB update |
| [backend/internal/models/proxy_host.go](../../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](../../backend/internal/caddy/manager.go) | `ApplyConfig()` L48-169 | Orchestrates config generation, validation, and application |
| [backend/internal/caddy/config.go](../../backend/internal/caddy/config.go) | `GenerateConfig()` L22-310 | Builds complete Caddy JSON config from DB |
| **[backend/internal/caddy/types.go](../../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)
```json
{
"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)
```json
{
"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**:
```json
{
"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
```go
// 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
### Option A: Remove Handler-Level trusted_proxies (Recommended)
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
```go
// 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:
```go
// 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:
```go
// 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:**
```bash
docker build --no-cache -t charon:local .
docker compose -f docker-compose.override.yml up -d
```
2. **Check logs for successful config application:**
```bash
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:**
```bash
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.