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)
10 KiB
Troubleshooting Standard Proxy Headers
This guide helps resolve issues with Charon's standard proxy headers feature.
Understanding Standard Proxy Headers
When enabled, Charon adds these headers to requests sent to your backend:
- X-Real-IP: The actual client IP address (not Charon's)
- X-Forwarded-Proto: Original protocol (http or https)
- X-Forwarded-Host: Original hostname from the client
- X-Forwarded-Port: Original port number
- X-Forwarded-For: Chain of proxy IPs (managed by Caddy)
Problem: Backend Still Sees Charon's IP
Symptoms
Your application logs show Charon's internal IP (e.g., 172.17.0.1) instead of the real client IP.
Solutions
1. Verify the feature is enabled
- Go to Proxy Hosts → Edit the affected host
- Scroll to Standard Proxy Headers section
- Ensure the checkbox is checked
- Click Save
2. Configure backend to trust proxy headers
Your backend application must be configured to read these headers. Here's how:
Express.js/Node.js:
// Enable trust proxy
app.set('trust proxy', true);
// Now req.ip will use X-Real-IP or X-Forwarded-For
app.get('/', (req, res) => {
console.log('Client IP:', req.ip);
});
Django:
# settings.py
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# For real IP detection, install django-ipware:
# pip install django-ipware
from ipware import get_client_ip
client_ip, is_routable = get_client_ip(request)
Flask:
from werkzeug.middleware.proxy_fix import ProxyFix
# Apply proxy fix middleware
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=1, # Trust 1 proxy for X-Forwarded-For
x_proto=1, # Trust X-Forwarded-Proto
x_host=1, # Trust X-Forwarded-Host
x_port=1 # Trust X-Forwarded-Port
)
# Now request.remote_addr will show the real client IP
@app.route('/')
def index():
client_ip = request.remote_addr
return f'Your IP: {client_ip}'
Go (net/http):
func handler(w http.ResponseWriter, r *http.Request) {
// Read X-Real-IP header
clientIP := r.Header.Get("X-Real-IP")
if clientIP == "" {
// Fallback to X-Forwarded-For
clientIP = strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]
}
if clientIP == "" {
// Last resort: connection IP
clientIP = r.RemoteAddr
}
log.Printf("Client IP: %s", strings.TrimSpace(clientIP))
}
NGINX (as backend):
# In your server block
real_ip_header X-Real-IP;
set_real_ip_from 172.16.0.0/12; # Charon's Docker network
real_ip_recursive on;
Apache (as backend):
# Enable mod_remoteip
<IfModule mod_remoteip.c>
RemoteIPHeader X-Real-IP
RemoteIPInternalProxy 172.16.0.0/12
</IfModule>
3. Verify headers are present
Use curl to check if headers are actually being sent:
# Replace with your backend's URL
curl -H "Host: yourdomain.com" http://your-backend:8080 -v 2>&1 | grep -i "x-"
Look for:
> X-Real-IP: 203.0.113.42
> X-Forwarded-Proto: https
> X-Forwarded-Host: yourdomain.com
> X-Forwarded-Port: 443
Problem: HTTPS Redirect Loop
Symptoms
Visiting https://yourdomain.com redirects endlessly, browser shows "too many redirects" error.
Cause
Your backend application is checking the connection protocol instead of the X-Forwarded-Proto header.
Solutions
Update your redirect logic:
Express.js:
// BAD: Checks the direct connection (always http from Charon)
if (req.protocol !== 'https') {
return res.redirect('https://' + req.get('host') + req.originalUrl);
}
// GOOD: Checks X-Forwarded-Proto header
if (req.get('x-forwarded-proto') !== 'https') {
return res.redirect('https://' + req.get('host') + req.originalUrl);
}
// BEST: Use trust proxy (it checks X-Forwarded-Proto automatically)
app.set('trust proxy', true);
if (req.protocol !== 'https') {
return res.redirect('https://' + req.get('host') + req.originalUrl);
}
Django:
# settings.py
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Now request.is_secure() will check X-Forwarded-Proto
if not request.is_secure():
return redirect('https://' + request.get_host() + request.get_full_path())
Laravel:
// app/Http/Middleware/TrustProxies.php
protected $proxies = '*'; // Trust all proxies (or specify Charon's IP)
protected $headers = Request::HEADER_X_FORWARDED_ALL;
Problem: Application Breaks After Enabling Headers
Symptoms
- 500 Internal Server Error
- Application behaves unexpectedly
- Features stop working
Possible Causes
- Strict header validation: Your app rejects unexpected headers
- Conflicting logic: App has custom IP detection that conflicts with proxy headers
- Security middleware: App blocks requests with proxy headers (anti-spoofing)
Solutions
1. Check application logs
Look for errors mentioning:
- X-Real-IP
- X-Forwarded-*
- Proxy headers
- IP validation
2. Temporarily disable the feature
- Edit the proxy host
- Uncheck "Enable Standard Proxy Headers"
- Save and test if the app works again
3. Configure security middleware
Some security frameworks block proxy headers by default:
Helmet.js (Express):
// Allow proxy headers
app.use(helmet({
frameguard: false // If you need to allow iframes
}));
app.set('trust proxy', true);
Django Security Middleware:
# settings.py
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
4. Whitelist Charon's IP
If your app has IP filtering:
// Express example
const trustedProxies = ['172.17.0.1', '172.18.0.1'];
app.set('trust proxy', trustedProxies);
Problem: Wrong IP in Rate Limiting
Symptoms
All users share the same rate limit, or all requests appear to come from one IP.
Cause
Rate limiting middleware is checking the connection IP instead of proxy headers.
Solutions
Express-rate-limit:
import rateLimit from 'express-rate-limit';
// Trust proxy first
app.set('trust proxy', true);
// Then apply rate limit
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
// Rate limit will use req.ip (which uses X-Real-IP when trust proxy is set)
});
app.use(limiter);
Custom middleware:
function getRealIP(req) {
return req.headers['x-real-ip'] ||
req.headers['x-forwarded-for']?.split(',')[0] ||
req.ip;
}
Problem: GeoIP Location is Wrong
Symptoms
Users from the US are detected as being from your server's location.
Cause
GeoIP lookup is using Charon's IP instead of the client IP.
Solutions
Ensure proxy headers are enabled in Charon, then:
MaxMind GeoIP2 (Node.js):
import maxmind from 'maxmind';
const lookup = await maxmind.open('/path/to/GeoLite2-City.mmdb');
function getLocation(req) {
const clientIP = req.headers['x-real-ip'] || req.ip;
return lookup.get(clientIP);
}
Python geoip2:
import geoip2.database
reader = geoip2.database.Reader('/path/to/GeoLite2-City.mmdb')
def get_location(request):
client_ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR')
return reader.city(client_ip)
Problem: Logs Show Multiple IPs in X-Forwarded-For
Symptoms
X-Forwarded-For shows: 203.0.113.42, 172.17.0.1
Explanation
This is correct behavior. X-Forwarded-For is a comma-separated list:
- First IP = Real client (
203.0.113.42) - Second IP = Charon's IP (
172.17.0.1)
Get the Real Client IP
Always read the FIRST IP in X-Forwarded-For:
const forwardedFor = req.headers['x-forwarded-for'];
const clientIP = forwardedFor ? forwardedFor.split(',')[0].trim() : req.ip;
Or use X-Real-IP (simpler):
const clientIP = req.headers['x-real-ip'] || req.ip;
Testing Headers Locally
1. Check if headers reach your backend:
Add temporary logging:
// Express
app.use((req, res, next) => {
console.log('Headers:', req.headers);
next();
});
# Django middleware
def log_headers(get_response):
def middleware(request):
print('Headers:', request.META)
return get_response(request)
return middleware
2. Simulate client request with curl:
# Test from outside Charon
curl -H "Host: yourdomain.com" https://yourdomain.com/test
# Check your backend logs for:
# X-Real-IP: <your actual IP>
# X-Forwarded-Proto: https
# X-Forwarded-Host: yourdomain.com
Security Considerations
IP Spoofing Prevention
Charon configures Caddy with trusted_proxies to prevent clients from spoofing headers.
What this means:
- Clients CANNOT inject fake X-Real-IP headers
- Caddy overwrites any client-provided proxy headers
- Only Charon's headers are trusted
Backend security: Your backend should still:
- Only trust proxy headers from Charon's IP
- Validate IP addresses before using them for access control
- Use a proper IP parsing library (not regex)
Example: Trust Proxy Configuration
// Express: Only trust specific IPs
app.set('trust proxy', ['127.0.0.1', '172.17.0.0/16']);
// Nginx: Specify allowed proxy IPs
real_ip_header X-Real-IP;
set_real_ip_from 172.17.0.0/16; # Docker network
set_real_ip_from 10.0.0.0/8; # Internal network
When to Contact Support
If you've tried the above solutions and:
- ✅ Standard headers are enabled in Charon
- ✅ Backend is configured to trust proxies
- ✅ Headers are visible in logs but still not working
- ✅ No redirect loops or errors
Open an issue with:
- Backend framework and version (e.g., Express 4.18.2)
- Charon version (from Dashboard)
- Proxy host configuration (screenshot or JSON)
- Sample backend logs showing the headers
- Expected vs actual behavior