Firewall: Real Client IP Behind a Reverse Proxy

When the server runs behind a reverse proxy such as Caddy, NGINX or HAProxy, every connection reaches the server from the proxy address. Blacklists, bans, GeoIP filtering, custom rules and audit entries would all see the proxy instead of the end client. The ForwardedHeaders property tells TsgcWebSocketFirewall to take the real client address from the X-Forwarded-For and X-Real-IP request headers, so every later decision is made against the client that actually sent the request.

The feature is disabled by default. Nothing changes when you upgrade until you set ForwardedHeaders.Enabled to True.

Properties

PropertyDefaultDescription
ForwardedHeaders.EnabledFalseEnables or disables reverse-proxy address resolution. While False the socket address is always used and no header is read.
ForwardedHeaders.TrustedProxies(empty)TStringList with the addresses of the proxies that are allowed to supply forwarded headers. Accepts plain IP addresses and CIDR ranges, the same syntax used by Whitelist.IPs and Blacklist.IPs. While the list is empty the headers are ignored.
ForwardedHeaders.ModefwmBothWhich headers are read: fwmXForwardedFor (only X-Forwarded-For), fwmXRealIP (only X-Real-IP), fwmBoth (X-Real-IP first, and the X-Forwarded-For chain when X-Real-IP is absent or empty).
ForwardedHeaders.TrustedHops1Number of proxies in front of the server. Each one appends an entry, so the last TrustedHops entries of the X-Forwarded-For chain come from the proxies and the client address is the first of them. A shorter chain is not trusted and the socket address is kept.

The Trust Gate

Forwarded headers travel inside the request, so anyone who can reach the server can write any value into them. The firewall therefore honours them only when the immediate peer, the address of the socket that opened the connection, matches an entry in TrustedProxies. A request that arrives directly from the internet keeps its socket address, because the internet peer is not in the list.

With an empty TrustedProxies list the headers are ignored entirely. This is deliberate and it is the anti-spoofing gate of the whole feature. Without a trust anchor any client could claim any address, write false entries into your audit trail, and step around a blacklist or an active ban simply by rotating a header value.

List every address the proxy may connect from. When the proxy runs on the same machine that is usually 127.0.0.1, and a container or load-balancer subnet can be given in CIDR form, for example 10.0.0.0/8.

Reading the X-Forwarded-For Chain

X-Forwarded-For is a comma separated list that grows from left to right as the request passes through each hop. The leftmost entry is the one the client itself supplied, so it is attacker controlled and must never be taken at face value. Each proxy appends the address of the peer it received the request from, so the last TrustedHops entries of the chain are the ones the trusted proxies wrote, and the firewall takes the first of them as the client address. Everything to the left of that entry came from the client, so it is never used. With a single appending proxy and TrustedHops set to 1, a chain of 1.2.3.4, 203.0.113.7 resolves to 203.0.113.7, and the 1.2.3.4 the client wrote itself is ignored. When the chain holds fewer entries than TrustedHops it does not match the topology you configured, nothing in it can be trusted and the socket address is kept. A TrustedHops of 0 means no trusted hop appended anything, so the rightmost entry is used.

Set TrustedHops to the number of proxies in front of the server. The default value of 1 covers the common case of a single reverse proxy. Increase it when a CDN or an outer load balancer forwards to your own proxy.

The safest arrangement is the one shown in the examples below, where the proxy sets X-Forwarded-For to the address it received the request from instead of appending to whatever the client sent. The chain then holds a single trustworthy entry and there is no client-supplied prefix at all.

How It Works

1. The connection is accepted and the connect-time check runs against the socket peer, which is the proxy. This happens before the TLS handshake, so the proxy address itself must be accepted by Whitelist and Blacklist.

2. A request arrives. If ForwardedHeaders.Enabled is False the socket address is used and nothing else happens.

3. The peer address is compared against TrustedProxies. If it does not match, or the list is empty, the headers are ignored and the socket address is kept.

4. A candidate address is taken from the headers selected by Mode. Brackets, an IPv6 zone identifier and a trailing port are stripped, and the value is normalized.

5. The candidate is parsed as IPv4 or IPv6. Anything that does not parse is discarded and the peer address is kept, because an unparseable value would not match any rule.

6. The connection IP property is overwritten with the resolved address. Every later firewall check, every event and your own handler code now see the end client. The original socket address stays available in the connection PeerIP property.

7. The resolved address is passed to IsForwardedIPAllowed, which applies the address-identity checks (whitelist, blacklist, bans, GeoIP, custom rules). A rejected request is answered with 403 Forbidden by the HTTP servers, and the connection is dropped for WebSocket and the other protocol handlers.

Resolution runs per request and not per connection, because a reverse proxy reuses a single upstream connection for requests coming from different end clients. Each request resolves against the frozen socket address, never against the address the previous request left behind.

Caddy Example

A Caddyfile that terminates TLS on the public interface and forwards to the server listening on port 8080:

example.com {
    reverse_proxy http://127.0.0.1:8080 {
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Host {host}
        header_up X-Forwarded-Proto {scheme}
        header_up X-Real-IP {remote_host}
    }
}

The matching Delphi configuration. Caddy connects from the loopback address, so 127.0.0.1 is both the trusted proxy and a whitelisted address:


// Resolve the real client address from the proxy headers
sgcWebSocketFirewall1.ForwardedHeaders.Enabled := True;
sgcWebSocketFirewall1.ForwardedHeaders.TrustedProxies.Add('127.0.0.1');
sgcWebSocketFirewall1.ForwardedHeaders.Mode := fwmBoth;
sgcWebSocketFirewall1.ForwardedHeaders.TrustedHops := 1;

// The connect-time check sees the proxy, so the proxy must be allowed
sgcWebSocketFirewall1.Whitelist.Enabled := True;
sgcWebSocketFirewall1.Whitelist.IPs.Add('127.0.0.1');

sgcWebSocketFirewall1.Enabled := True;
sgcWebSocketHTTPServer1.Firewall := sgcWebSocketFirewall1;

The whitelist is optional. If you do not enable it, make sure instead that the proxy address is neither blacklisted nor banned. What must never happen is the proxy address being rejected by the connect-time check.

NGINX Example

The equivalent NGINX location block, including the two headers a WebSocket upgrade needs:

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Host $host;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

The Delphi side is identical to the Caddy example. Only the trusted proxy address changes when NGINX runs on another host.

Resolved Address and Socket Address

PropertyValue
Connection.IPThe real client address once resolution succeeds. This is what every firewall check, every event and your own code read. Without resolution it stays the socket address.
Connection.PeerIPAlways the original socket address, which is the proxy. It is captured the first time the address is replaced and never changes afterwards.


procedure TForm1.OnMessage(Connection: TsgcWSConnection; const Text: string);
begin
  // Connection.IP is the end client, Connection.PeerIP is the proxy
  Memo1.Lines.Add(Connection.IP + ' via ' + Connection.PeerIP + ': ' + Text);
end;

Rate Limiting Stays Connection Scoped

RateLimit.MaxConnectionsPerIP and the internal connection counters track sockets, and a reverse proxy opens its own sockets. They keep counting the proxy connections even with ForwardedHeaders enabled, and they are not re-evaluated per request. IsForwardedIPAllowed is the per-request verdict and it deliberately leaves the rate limit out, so a proxy that multiplexes many end clients over a few upstream connections is never rejected for exceeding a per-IP connection cap.

Set MaxConnectionsPerIP high enough for the number of upstream connections your proxy opens, or whitelist the proxy address so the limit is bypassed altogether. To limit individual clients behind the proxy, use FloodProtection, which is evaluated per message against the resolved address, or a custom rule.

Troubleshooting

Everything is blocked once the proxy is in front of the server

This is by far the most common misconfiguration. The connect-time check runs on the socket peer before TLS and before any header is read, so it sees the proxy. If the proxy address is not accepted at that point, the proxy itself is refused and nothing else in the chain ever runs. Add the proxy address to Whitelist.IPs, or make sure it is neither blacklisted nor banned. Whitelist.Enabled with a list that does not contain the proxy rejects everything.

The headers are ignored and every client still shows the proxy address

Check TrustedProxies. An empty list disables header reading entirely, and an entry that does not match the address the proxy actually connects from has the same effect. Compare Connection.PeerIP against what you configured. A proxy on the same machine may connect from 127.0.0.1 or from ::1, and both may need to be listed. Also confirm that ForwardedHeaders.Enabled is True and that the proxy really sends the header, since Mode fwmXRealIP ignores X-Forwarded-For and fwmXForwardedFor ignores X-Real-IP.

The resolved address is another proxy instead of the client

TrustedHops is too low for the number of hops in front of the server. The entry that gets taken sits too far to the right in the chain, so the address one of the inner proxies appended for its own peer is resolved instead of the client. Increase TrustedHops by one for each extra proxy, or configure the outermost proxy under your control to set X-Forwarded-For rather than append to it.

The address falls back to the proxy even though TrustedProxies matches

TrustedHops is higher than the number of entries the chain actually carries. The chain does not match the configured topology, so resolution fails closed and the socket address stays in place, which is why the audit shows the proxy. Seeing the proxy address here is the signal that the setting is wrong, not that the headers are missing. Count the hops that really append an entry, remembering that a proxy configured to set X-Forwarded-For instead of appending leaves a chain of one, and lower TrustedHops to match.

Notes