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)
19 KiB
Bug Investigation: Security Header Profile Not Persisting
Created: 2025-12-18 Status: Investigation Complete - Root Cause Identified
Bug Report
Security header profile changes are not persisting to the database when editing proxy hosts.
Observed Behavior:
- User assigns "Strict" profile to a proxy host → Saves successfully ✓
- User edits the same host, changes to "Basic" profile → Appears to save ✓
- User reopens the host edit form → Shows "Strict" (not "Basic") ❌
The profile change is NOT persisting to the database.
Root Cause Analysis
Investigation Summary
I examined the complete data flow from frontend form submission to backend database save. The code analysis reveals that the implementation SHOULD work correctly, but there are potential issues with value handling and silent error conditions.
Frontend Code Analysis
File: frontend/src/components/ProxyHostForm.tsx
Lines 656-661: Security header profile dropdown and onChange handler
<select
value={formData.security_header_profile_id || 0}
onChange={e => {
const value = parseInt(e.target.value) || null
setFormData({ ...formData, security_header_profile_id: value })
}}
>
Issue #1: Falsy Coercion Bug
The expression parseInt(e.target.value) || null has a problematic behavior:
- When user selects "None" (value="0"):
parseInt("0")= 0, then0 || null=null✓ (Correct - we want null for "None") - When user selects profile ID 2:
parseInt("2")= 2, then2 || null= 2 ✓ (Works) - BUT: If
parseInt()fails or returnsNaN, the expression evaluates tonullinstead of preserving the current value
Risk: Edge cases where parseInt fails silently convert valid profile selections to null.
Lines 308-311: Form submission payload preparation
const payload = { ...formData }
const { addUptime: _addUptime, uptimeInterval: _uptimeInterval, uptimeMaxRetries: _uptimeMaxRetries, ...payloadWithoutUptime } = payload as ProxyHostFormState
Analysis:
- The
security_header_profile_idfield is included in the spread operation - If
formData.security_header_profile_idisundefined, it won't be in the payload keys - If it's
nullor a number, it WILL be included
Backend Code Analysis
File: backend/internal/api/handlers/proxy_host_handler.go
Lines 231-248: Security Header Profile update handling
// Security Header Profile: update only if provided
if v, ok := payload["security_header_profile_id"]; ok {
if v == nil {
host.SecurityHeaderProfileID = nil
} else {
switch t := v.(type) {
case float64:
if id, ok := safeFloat64ToUint(t); ok {
host.SecurityHeaderProfileID = &id
}
// ❌ NO ELSE CLAUSE - silently fails if conversion fails
case int:
if id, ok := safeIntToUint(t); ok {
host.SecurityHeaderProfileID = &id
}
// ❌ NO ELSE CLAUSE
case string:
if n, err := strconv.ParseUint(t, 10, 32); err == nil {
id := uint(n)
host.SecurityHeaderProfileID = &id
}
// ❌ NO ELSE CLAUSE
}
// ❌ NO DEFAULT CASE - fails silently if type doesn't match
}
}
Issue #2: Silent Failure in Type Conversion
If ANY of the following occur, the field is NOT updated:
safeFloat64ToUint()returnsok = falsesafeIntToUint()returnsok = falsestrconv.ParseUint()returns an error- The value type doesn't match
float64,int, orstring
Critical: No error is logged, no status code returned - the old value remains in memory and gets saved to the database.
Lines 29-34: The safeFloat64ToUint conversion function
func safeFloat64ToUint(f float64) (uint, bool) {
if f < 0 || f != float64(uint(f)) {
return 0, false
}
return uint(f), true
}
Analysis:
- For negative numbers: Returns
false✓ - For integers (0, 1, 2, etc.): Returns
true✓ - For floats with decimals (2.5): Returns
false(correct - can't convert to uint)
This function should work fine for valid profile IDs.
Lines 256-258: Calling the service to save
if err := h.service.Update(host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
This calls the service Update() method which uses GORM's Save().
Service Layer Analysis
File: backend/internal/services/proxyhost_service.go
Line 92: The actual database save operation
return s.db.Save(host).Error
GORM's Save() method:
- Updates ALL fields in the struct, including zero values
- Handles nullable pointers correctly (
*uint) - Should persist changes to
SecurityHeaderProfileID
No issues found here.
Model Analysis
File: backend/internal/models/proxy_host.go
Lines 40-42: Security Header Profile field definition
SecurityHeaderProfileID *uint `json:"security_header_profile_id"`
SecurityHeaderProfile *SecurityHeaderProfile `json:"security_header_profile" gorm:"foreignKey:SecurityHeaderProfileID"`
Analysis:
- Field is nullable pointer
*uint✓ - JSON tag is snake_case ✓
- GORM relationship configured ✓
No issues found here.
Identified Root Causes
After comprehensive code review, I've identified TWO potential root causes:
Root Cause #1: Frontend - Potential NaN Edge Case ⚠️
Location: frontend/src/components/ProxyHostForm.tsx:658-661
Problem:
const value = parseInt(e.target.value) || null
While this works for most cases, it has edge case vulnerabilities:
- If
e.target.valueis""(empty string):parseInt("")=NaN, thenNaN || null=null - If
parseInt()somehow returns0:0 || null=null(converts valid 0 to null)
Impact: Low likelihood but would cause profile selection to silently become null.
Root Cause #2: Backend - Silent Failure with No Logging ⚠️⚠️⚠️
Location: backend/internal/api/handlers/proxy_host_handler.go:231-248
Problem: No error handling or logging when type conversion fails:
case float64:
if id, ok := safeFloat64ToUint(t); ok {
host.SecurityHeaderProfileID = &id
}
// If ok==false, nothing happens! Old value remains!
Impact: HIGH - If the payload value can't be converted (for any reason), the update is silently skipped.
Why This Is The Likely Culprit:
For the reported bug (changing from Strict to Basic), the frontend should send:
{"security_header_profile_id": 2}
JSON numbers unmarshal as float64 in Go. So v would be float64(2.0).
The safeFloat64ToUint(2.0) call should return (2, true) and set the field correctly.
UNLESS:
- The JSON payload is malformed
- The value comes as a string
"2"instead of number2 - There's middleware modifying the payload
- The
okcheck is somehow failing despite valid input
The lack of logging makes this impossible to debug!
Why the Bug Occurs (Hypothesis)
Based on the code analysis, here's my hypothesis:
Most Likely Scenario:
- Frontend sends
{"security_header_profile_id": 2}as JSON number ✓ - Backend receives it as
float64(2.0)✓ safeFloat64ToUint(2.0)returns(2, true)✓- Sets
host.SecurityHeaderProfileID = &2✓ - Calls
h.service.Update(host)which runsdb.Save(host)✓ - BUT: GORM's
Save()might not be updating nullable pointer fields properly? ❌
Alternative Scenario (Less Likely):
The JSON payload is somehow getting stringified or modified before reaching the handler, causing the type assertion to fail.
Evidence Needed:
Without logs, we can't know which scenario is happening. The fix MUST include logging.
Proposed Fix Plan
Fix 1: Frontend - Explicit Value Handling (Safety Improvement)
File: frontend/src/components/ProxyHostForm.tsx
Lines: 658-661
Change:
// BEFORE (risky):
onChange={e => {
const value = parseInt(e.target.value) || null
setFormData({ ...formData, security_header_profile_id: value })
}}
// AFTER (safe):
onChange={e => {
const rawValue = e.target.value
let value: number | null = null
if (rawValue !== "0" && rawValue !== "") {
const parsed = parseInt(rawValue, 10)
value = isNaN(parsed) ? null : parsed
}
setFormData({ ...formData, security_header_profile_id: value })
}}
Why:
- Explicitly handles "0" case (None/null)
- Explicitly handles empty string
- Checks for NaN before assigning
- No reliance on falsy coercion
Fix 2: Backend - Add Logging and Error Handling (CRITICAL)
File: backend/internal/api/handlers/proxy_host_handler.go
Lines: 231-248
Change:
// Security Header Profile: update only if provided
if v, ok := payload["security_header_profile_id"]; ok {
logger := middleware.GetRequestLogger(c)
logger.WithField("security_header_profile_id_raw", v).Debug("Received security header profile ID")
if v == nil {
host.SecurityHeaderProfileID = nil
logger.Debug("Cleared security header profile ID (set to nil)")
} else {
updated := false
var finalID uint
switch t := v.(type) {
case float64:
if id, ok := safeFloat64ToUint(t); ok {
finalID = id
updated = true
} else {
logger.WithField("value", t).Warn("Failed to convert float64 to uint (out of range or negative)")
}
case int:
if id, ok := safeIntToUint(t); ok {
finalID = id
updated = true
} else {
logger.WithField("value", t).Warn("Failed to convert int to uint (negative)")
}
case string:
if n, err := strconv.ParseUint(t, 10, 32); err == nil {
finalID = uint(n)
updated = true
} else {
logger.WithField("value", t).WithError(err).Warn("Failed to parse string to uint")
}
default:
logger.WithField("type", fmt.Sprintf("%T", v)).Error("Unexpected type for security_header_profile_id")
}
if !updated {
logger.Error("Failed to update security_header_profile_id - conversion failed")
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("invalid security_header_profile_id: cannot convert %v (%T) to uint", v, v),
})
return
}
host.SecurityHeaderProfileID = &finalID
logger.WithField("security_header_profile_id", finalID).Debug("Set security header profile ID")
}
}
Why:
- Logs incoming raw value - We can see exactly what the frontend sent
- Logs conversion attempts - We can see if type assertions match
- Logs success/failure - We know if the field was updated
- Returns explicit error - No more silent failures
- Includes type information - Helps diagnose payload issues
Fix 3: Backend - Verify GORM Update Behavior (Investigation)
File: backend/internal/services/proxyhost_service.go
Line: 92
Current:
return s.db.Save(host).Error
Investigation needed:
- Does
Save()properly update nullable pointer fields? - Should we use
Updates()instead? - Should we use
Select()to explicitly update specific fields?
Possible alternative:
// Option A: Use Updates with Select (explicit fields)
return s.db.Model(host).Select("SecurityHeaderProfileID").Updates(host).Error
// Option B: Use Updates (auto-detects changed fields)
return s.db.Model(host).Updates(host).Error
Note: Updates() skips zero values but handles nil pointers correctly. Need to test.
Testing Plan
Phase 1: Add Logging (Diagnostic)
-
Implement Fix 2 (backend logging)
-
Deploy to test environment
-
Reproduce the bug:
- Create host with Strict profile
- Edit host, change to Basic profile
- Check logs to see:
- What value was received
- What type it was
- If conversion succeeded
- What value was set
-
Check database directly:
SELECT id, name, security_header_profile_id FROM proxy_hosts WHERE name = 'Test Host';
Phase 2: Fix Issues (Implementation)
Based on log findings:
- If conversion is failing → Fix conversion logic
- If GORM isn't saving → Change to
Updates()orSelect() - If payload is wrong type → Investigate middleware/JSON unmarshaling
Phase 3: Frontend Safety (Prevention)
- Implement Fix 1 (explicit value handling)
- Test all scenarios:
- Select "None" → Should send
null - Select "Basic" → Should send profile ID
- Select invalid option → Should handle gracefully
- Select "None" → Should send
Phase 4: Verification (End-to-End)
- Create proxy host
- Assign Strict profile (ID 2)
- Save → Verify in DB:
security_header_profile_id = 2 - Edit host
- Change to Basic profile (ID 1)
- Save → Verify in DB:
security_header_profile_id = 1✓ - Edit host
- Change to None
- Save → Verify in DB:
security_header_profile_id = NULL✓
Edge Cases to Test
- Changing between non-zero profiles: Strict (2) → Basic (1)
- Setting to None: Basic (1) → None (null)
- Setting from None: None (null) → Strict (2)
- Rapid changes: Strict → Basic → Paranoid → None
- Invalid profile ID: Send ID 999 (non-existent)
- Zero profile ID: Send ID 0 (should become null)
- Negative ID: Send ID -1 (should reject)
- String ID: Send "2" as string (should convert)
- Float ID: Send 2.5 (should reject - not a valid uint)
Success Criteria
✅ Bug is fixed when:
- User can change security header profile from one to another, and it persists after save
- User can set profile to "None" (null), and it persists
- User can set profile from "None" to any preset, and it persists
- Backend logs show clear diagnostic information when profile changes
- Invalid profile IDs return explicit errors (not silent failures)
- All edge cases pass testing
- No regressions in other proxy host fields
Files to Modify
High Priority (Fixes)
-
backend/internal/api/handlers/proxy_host_handler.go (Lines 231-248)
- Add logging
- Add error handling
- Remove silent failures
-
frontend/src/components/ProxyHostForm.tsx (Lines 658-661)
- Explicit value handling
- Remove falsy coercion
Medium Priority (Investigation)
- backend/internal/services/proxyhost_service.go (Line 92)
- Verify GORM Save() vs Updates()
- May need to change update method
Low Priority (Testing)
- backend/internal/api/handlers/proxy_host_handler_test.go
- Add test for security header profile updates
- Test edge cases
Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| GORM doesn't save nullable pointers | Medium | High | Test with Updates() method |
| Frontend sends wrong type | Low | High | Add explicit type checking |
| Middleware modifies payload | Low | High | Add early logging to check |
| Conversion logic has bugs | Low | Medium | Add comprehensive unit tests |
| Breaking other fields | Very Low | High | Test full proxy host CRUD |
Implementation Priority
- CRITICAL: Add backend logging (Fix 2) - Enables diagnosis
- HIGH: Add error handling (Fix 2) - Prevents silent failures
- MEDIUM: Frontend safety (Fix 1) - Prevents edge case bugs
- LOW: GORM investigation (Fix 3) - Only if Save() proves problematic
Next Steps
- Implement Fix 2 (backend logging) - ~15 minutes
- Deploy to test environment - ~5 minutes
- Reproduce bug with logging enabled - ~10 minutes
- Analyze logs - ~10 minutes
- Implement remaining fixes based on findings - ~20 minutes
- Test all edge cases - ~30 minutes
- Document findings - ~10 minutes
Total estimated time: ~1.5 hours
Conclusion
The root cause of the security header profile persistence bug is likely a silent failure in the backend handler's type conversion logic. The lack of logging makes it impossible to diagnose the exact failure point.
The immediate fix is to:
- Add comprehensive logging to track the value through its lifecycle
- Add explicit error handling to prevent silent failures
- Improve frontend value handling to prevent edge cases
Once logging is in place, we can identify the exact failure point and implement a targeted fix.
Investigation Date: 2025-12-18 Status: Root cause hypothesized, fix plan documented Next Action: Implement backend logging to confirm hypothesis
Appendix: Expected vs. Actual Data Flow
Expected Data Flow (Should Work)
Frontend
User changes dropdown to "Basic" (ID 1)
↓
onChange fires: parseInt("1") = 1
↓
setFormData({ security_header_profile_id: 1 })
↓
User clicks Save
↓
handleSubmit sends: {"security_header_profile_id": 1}
↓
Backend
Gin unmarshals JSON: payload["security_header_profile_id"] = float64(1.0)
↓
Handler checks: if v, ok := payload["security_header_profile_id"]; ok { ✓
↓
v == nil? No ✓
↓
switch t := v.(type) { case float64: ✓
↓
safeFloat64ToUint(1.0) returns (1, true) ✓
↓
host.SecurityHeaderProfileID = &1 ✓
↓
h.service.Update(host) ✓
↓
s.db.Save(host) ✓
↓
Database
UPDATE proxy_hosts SET security_header_profile_id = 1 WHERE id = X ✓
This flow SHOULD work! So why doesn't it?
Actual Data Flow (Hypothesis - Needs Logging to Confirm)
Scenario A: Payload Type Mismatch
Frontend sends: {"security_header_profile_id": "1"} ← String instead of number!
Backend receives: v = "1" (string)
Type switch enters: case string:
strconv.ParseUint("1", 10, 32) succeeds
Sets: host.SecurityHeaderProfileID = &1
BUT: Something downstream fails or overwrites it
Scenario B: GORM Save Issue
Everything up to Save() works correctly
host.SecurityHeaderProfileID = &1 ✓
db.Save(host) is called
BUT: GORM doesn't detect the field as "changed"
OR: GORM skips nullable pointer updates
Result: Old value remains in database
Scenario C: Concurrent Request
Request A: Sets profile to Basic (ID 1)
Request B: Reloads host data (has Strict, ID 2)
Request A saves: profile_id = 1
Request B saves: profile_id = 2 ← Overwrites A's change
Result: Profile reverts to Strict
Only logging will tell us which scenario is happening!
End of Investigation Report