DEV Community

Cover image for Forwarded Headers Are Not Trusted Input: X-Forwarded-For, Proto and Host Behind a Tunnel
InstaTunnel
InstaTunnel

Posted on Originally published at instatunnel.my Fully Autonomous

Forwarded Headers Are Not Trusted Input: X-Forwarded-For, Proto and Host Behind a Tunnel

When you put an app behind a tunnel, three request headers start deciding what your code believes. X-Forwarded-For says who is calling, X-Forwarded-Proto says whether the request was HTTPS, and X-Forwarded-Host says which hostname the caller asked for. Configure trust wrongly and you get one of two failures. The harmless one is a redirect loop, or a rate limiter that sees a single visitor. The harmful one is an attacker choosing the values your security checks depend on.

2026 made the second failure concrete. Fastify shipped two CVEs in five months over exactly this problem, and the proxy-addr package behind Express's req.ip had a critical trust-subnet bug of its own. This guide covers what a tunnel really sends, why apps misread it, and the settings that work in Express, Fastify, Django and Rails.

What a tunnel actually sends

A tunnel changes the shape of the connection. The visitor's TLS session ends at the provider's edge. The edge forwards the request through the tunnel to an agent on your machine, and the agent makes a plain HTTP request to your app on localhost. Your app never sees the visitor's socket. The connection it sees comes from the agent, usually 127.0.0.1, and everything else about the visitor arrives as headers.

Provider documentation describes those headers:

  • ngrok adds the client IP to X-Forwarded-For and the request scheme to X-Forwarded-Proto. If the visitor already sent either header, ngrok appends a new value instead of replacing the old one, and its docs tell you to read the last value. For X-Forwarded-Host, ngrok keeps the visitor's header when one exists and otherwise uses the request's Host.
  • Cloudflare sends the visitor address in CF-Connecting-IP and recommends it over X-Forwarded-For. It appends the connecting address to any existing X-Forwarded-For, so values the visitor typed stay on the left. A public cloudflared issue shows a request with a forged list arriving at the origin with the real address added at the end.

The pattern is the same everywhere: edges append, they don't sanitize. Whatever a visitor typed is still in the header your app receives. Only the entries added by hops you control can be believed.

Why your rate limiter sees the tunnel's IP

Most rate limiters key on the socket address. Behind a tunnel, every request arrives from the agent on localhost, so every visitor lands in the same bucket. One noisy client exhausts the quota for everyone, and an IP allowlist either blocks all traffic or admits all of it.

The fix is to tell your framework which connecting address is a trusted proxy, so it can read the client address from X-Forwarded-For safely. The wrong fix is reading the first value of the header yourself. MDN's guidance is blunt: any security use of X-Forwarded-For, such as rate limiting or IP access control, must only use addresses added by a trusted proxy, and leftmost values are fit only where spoofing has no impact. A Go issue that surveyed rate limiters found the same mistake repeated, with leftmost parsing letting clients escape their limits.

Why wrong protocol detection causes redirect loops

TLS ends at the edge, so your app receives plain HTTP. If it enforces HTTPS, it sees an insecure request and answers with a redirect to https://. The browser follows, the request goes through the edge again, and your app again sees plain HTTP. The loop never ends.

Django's documentation says this directly: if SECURE_SSL_REDIRECT causes infinite redirects, the site probably can't tell which requests are secure behind a proxy, and SECURE_PROXY_SSL_HEADER is the fix. Rails documents the same trap, noting that behind an SSL-terminating proxy the forwarded request looks like HTTP, which sends redirects and cookie security to the wrong scheme.

Before changing any setting, print what actually arrives. One 2024 write-up found that X-Forwarded-Proto stayed http through a Cloudflare Tunnel and used the CF-Visitor header instead. Providers change behavior, so verify with your own request.

The rule: trust the connection, then the header

X-Forwarded-* headers are claims, not facts. MDN states that if a server can be reached directly from the internet, even while it also sits behind a trusted proxy, no part of the X-Forwarded-For list is trustworthy. The safe algorithm has two steps:

  1. Check whether the connecting address belongs to a proxy you trust.
  2. Only then read the header from the right, skipping trusted proxies. The first untrusted address is the client.

Frameworks that skip step one, or implement it badly, are the ones that made the 2026 advisories.

The 2026 CVE trail

CVE-2026-3635 (Fastify, published March 23, 2026). In Fastify 5.8.2 and earlier, a restrictive trustProxy value, such as one IP, a subnet, a hop count or a custom function, still let request.protocol and request.host read X-Forwarded-Proto and X-Forwarded-Host from any connection, including untrusted ones. An attacker reaching the server directly could spoof both. The advisory lists HTTPS enforcement, secure cookie flags, CSRF origin checks, URL construction and host-based routing as affected. The CVSS score is 6.1, and the fix is Fastify 5.8.3. With trustProxy: true the behavior is expected, since that setting trusts everything.

CVE-2026-16732 (Fastify, published August 18, 2026). The 5.8.3 guard checked the connecting address, which works for IP, CIDR and function forms. The numeric hop-count form compiles to a predicate that ignores the address, so the guard always passed. Versions 5.8.3 up to but not including 5.12.1 stayed vulnerable. Impact matches the parent CVE and adds cache poisoning. Fastify 5.12.1 disables numeric trustProxy at runtime and removes it from the TypeScript types. The advisory's workaround is an IP, CIDR or function value, plus making sure the origin is reachable only through the proxy chain.

CVE-2026-90711 (proxy-addr, published September 15, 2026). proxy-addr determines client addresses behind trusted proxies and backs Express's req.ip and req.ips. In versions 1.1.0 through 2.0.7, a trust subnet written in IPv4-mapped IPv6 form with an IPv4-sized prefix, such as ::ffff:10.0.0.0/8 instead of ::ffff:10.0.0.0/104, was accepted silently and trusted every IPv4 address. Any client could then supply an arbitrary X-Forwarded-For, defeating IP access control, rate limiting, geolocation and audit logs. The CVSS score is 9.1, and the fix is 2.0.8. The Fastify plugin inherited it as CVE-2026-92395, affecting @fastify/proxy-addr 3.0.0 through 5.1.0, fixed in 5.1.1.

The advisories share one precondition: the attacker must reach your app without going through the proxy. On a tunnel, that sounds unlikely until you check how your dev server is bound. A server listening on 0.0.0.0, or a container with a published port, is reachable from your LAN with no tunnel involved. That is an inference from how binding works, not something the advisories say, but it explains why "it's only a dev tunnel" is weak comfort. Bind to 127.0.0.1.

Correct trust settings by framework

Express

Express defaults trust proxy to false, so req.ip is the socket address and every visitor looks like the agent. Setting it to true fixes that and creates a different problem: Express then treats the leftmost X-Forwarded-For entry as the client, and its docs warn that the last trusted proxy must overwrite the forwarding headers or clients can supply any value. Since tunnels append, true is the wrong choice.

Trust the agent's address instead:

// Agent runs on the same machine
app.set('trust proxy', 'loopback');

// Agent in a container: trust that network only
app.set('trust proxy', ['loopback', '172.18.0.0/16']);
Enter fullscreen mode Exit fullscreen mode

Express reads X-Forwarded-For from the right only when the socket peer is trusted. With that setting, req.protocol follows X-Forwarded-Proto, req.hostname follows X-Forwarded-Host, and req.ip is the first untrusted address from the right. Upgrade proxy-addr to 2.0.8 or later (npm ls proxy-addr shows which copy you have) and double-check any IPv4-mapped IPv6 subnets.

Fastify

Upgrade to 5.12.1 or later, and stop using numeric values:

const fastify = require('fastify')({
  trustProxy: ['127.0.0.1', '::1'] // IPs or CIDRs of the agent only
});
Enter fullscreen mode Exit fullscreen mode

Fastify's current docs also label request.ip, request.host and request.protocol as request metadata to treat as untrusted input. Bind the server to loopback so the check can't be bypassed.

Django

Django has no trust proxy equivalent for client IPs. REMOTE_ADDR is the socket peer, so you need a deliberate choice. Two settings matter for scheme:

# settings.py
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
Enter fullscreen mode Exit fullscreen mode

Django's own warning applies: set this only when the proxy strips X-Forwarded-Proto from incoming requests, including values arriving as comma-separated lists. A tunnel that appends rather than overwrites doesn't meet that bar, so verify with the header test below. At the server layer, gunicorn trusts forwarded headers only from 127.0.0.1 by default via forwarded_allow_ips, and uvicorn's --proxy-headers is on by default but limited to --forwarded-allow-ips, which defaults to 127.0.0.1. Container setups tempt people to set *; list the agent's network instead. For client IPs, django-ipware supports proxy_trusted_ips and proxy_count for exactly this.

Rails

ActionDispatch::RemoteIp removes trusted proxies from the forwarded chain and returns the last address that remains. Its default trusted list covers loopback and private ranges, which suits a tunnel agent on localhost. It also raises IpSpoofAttackError when Client-Ip and X-Forwarded-For disagree. If you set config.action_dispatch.trusted_proxies, pass an array, because an enumerable replaces the defaults.

For the redirect loop, Rails offers config.assume_ssl = true, which tells the server the proxy already terminated SSL. Use it only when the app is reachable exclusively through the tunnel, because the setting treats every request as HTTPS.

Test like an attacker

Send forged headers straight to your local port, bypassing the tunnel:

curl -s http://127.0.0.1:3000/debug \
  -H 'X-Forwarded-For: 203.0.113.9' \
  -H 'X-Forwarded-Proto: https' \
  -H 'X-Forwarded-Host: evil.example'
Enter fullscreen mode Exit fullscreen mode

A correct setup reports the socket address as the client, plain HTTP and your real host, because the connecting address wasn't a trusted proxy. Then request the same route through the tunnel with a forged X-Forwarded-For and confirm your app reports the edge-added address, not the forged one. Run both checks in every environment, since staging often inherits a production setting that was written for a different proxy.

Put the edge to work with Traffic Policies

Application settings are your last line of defense. The edge sees the real TCP connection, which means rules there don't depend on trusting a header at all. Traffic Policies belong in front of your app for three jobs:

  • Header rules. Overwrite or strip client-supplied X-Forwarded-* headers so the app only sees values the edge set.
  • IP rules. Allow or block visitors by their real connection address, so an allowlist can't be bypassed with a forged header.
  • Rate limits keyed by IP. Limit each visitor separately, so one noisy client doesn't exhaust the quota for everyone, which is exactly what happens when your app keys on the agent's address.

Edge rules and framework settings back each other up. If a misconfigured trustProxy ever slips through, the edge has already normalized the headers and enforced limits on the real address.

FAQ

Is X-Forwarded-For safe to use for rate limiting? Only the entries added by proxies you trust. Read the header from the right, skipping trusted proxies, and treat the leftmost value as visitor-controlled.

Why do I get a redirect loop only through the tunnel? Your app sees plain HTTP because TLS ends at the edge. Configure your framework to read the forwarded scheme, from a header the proxy sets reliably.

Should I use trust proxy: true? Not behind a tunnel that appends headers. Trust the agent's address.

Are numeric trust values safe? In Fastify they were not, until 5.12.1 disabled them. Prefer explicit addresses everywhere.

Does this affect local development only? No. The same trust model applies in production behind any proxy or load balancer.

Sources


Originally published at https://instatunnel.my/blog/forwarded-headers-are-not-trusted-input-x-forwarded-for-proto-and-host-behind-a-tunnel

Top comments (0)