TsDProxy X-Forwarded-For Header Injection Allows IP Spoofing
tsdproxy's HTTP reverse proxy forwards attacker-supplied X-Forwarded-For and X-Real-IP headers verbatim to backend services, letting any authenticated Tailscale user spoof their apparent source IP…
The problem
The Rewrite closure in internal/proxymanager/port.go correctly strips tsdproxy identity headers before forwarding requests, but never deletes X-Forwarded-For or X-Real-IP from the inbound request.
When r.SetXForwarded() is called, Go's httputil.ProxyRequest appends the real Tailscale client IP to whatever X-Forwarded-For value the attacker already injected. The backend receives the attacker-controlled value at the head of the list. X-Real-IP is passed through entirely unchanged.
Backend services that trust the first element of X-Forwarded-For, or that trust X-Real-IP without overriding it, will operate on the attacker-supplied IP. This breaks IP-based admin restrictions, rate limiting, audit logging, and geo-blocking. The impact is heightened because tsdproxy is typically the sole access path to otherwise network-isolated backends.
Proof of concept
A working proof-of-concept for this issue in github.com/almeidapaulopt/tsdproxy, with the exact payload below.
# Vector 1: X-Forwarded-For injection
# Backend receives: X-Forwarded-For: 127.0.0.1, <real-tailscale-ip>
curl -H "X-Forwarded-For: 127.0.0.1" \
https://<proxy-hostname>.ts.net/admin
# Vector 2: X-Real-IP injection (forwarded verbatim, no stripping)
curl -H "X-Real-IP: 127.0.0.1" \
https://<proxy-hostname>.ts.net/admin
# Combined PoC
curl -v \
-H "X-Forwarded-For: 127.0.0.1" \
-H "X-Real-IP: 127.0.0.1" \
"https://<proxy-hostname>.ts.net/"Root cause is missing header sanitization before r.SetXForwarded() in the Rewrite closure. Go's httputil.ProxyRequest.SetXForwarded() appends the connecting client IP to any pre-existing X-Forwarded-For value rather than replacing it, so an attacker who sets X-Forwarded-For: 127.0.0.1 causes the backend to receive 127.0.0.1, <tailscale-ip>.
Backends reading the leftmost entry treat 127.0.0.1 as the originating client.
The patch commit e8200b7947719e5e7fbbbdb9c34f459a4c285e77 adds two explicit deletes in the Rewrite closure before the r.SetXForwarded() call: r.Out.Header.Del("X-Forwarded-For") and r.Out.Header.Del("X-Real-IP"). This ensures SetXForwarded() starts from a clean slate and writes only the verified Tailscale client IP.
CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component (Injection).
The fix
Upgrade to tsdproxy v3.0.0-alpha.3 or later. The fix is in commit e8200b7947719e5e7fbbbdb9c34f459a4c285e77: add r.Out.Header.Del("X-Forwarded-For") and r.Out.Header.Del("X-Real-IP") inside the Rewrite closure in internal/proxymanager/port.go, immediately before the call to r.SetXForwarded().
No configuration change is required once upgraded.
Reported by Vishal Shukla (@shukla304).
Related research
- critical · 9TSDProxy: Internal Auth Token Leaked to Backend Services Enables Management API Takeover
- critical · 9.9CVE-2026-54680CVE-2026-54680: kube-logging/logging-operator Fluentd Configuration Injection RCE
- high · 7.7CVE-2026-54910CVE-2026-54910: FileBrowser Quantum Path Traversal in Subtitle Handler
- critical · 9.6CVE-2026-54725CVE-2026-54725: vault-secrets-webhook Annotation SSRF and ServiceAccount Token Theft