Repository navigation
edge: carry the real client address, and make X-Forwarded-For worth trusting - #1
Merged
Merged
Conversation
Every public visitor has been recorded as 127.0.0.1 for as long as the SNI split has existed, and the cause was not the forwarder. HAProxy splits :443 in TCP mode and hands Caddy a loopback connection, so Caddy honestly wrote `X-Forwarded-For: 127.0.0.1` and the forwarder — which only filled the header when it was empty — passed that on. Downstream, geography is empty and rate limiting keys on a constant. A TCP proxy cannot set a header, so the address travels ahead of the TLS handshake instead: haproxy sends PROXY v2 to Caddy, and Caddy's proxy_protocol listener wrapper turns it back into the connection's peer. `allow 127.0.0.1/32` is the trust boundary — only HAProxy on this box may assert an address — and the `tls` wrapper stays last because wrappers run in order. The :80 frontend is already in HTTP mode, so it just gains `option forwardfor`. The forwarder then stops passing on what arrived. Caddy APPENDS to whatever the client sent, so a non-empty header is `<the caller's invention>, <what Caddy saw>`: a downstream reader taking the first hop — the conventional choice, and what kernel-hive did — was reading the caller. realIP now takes the last entry and only when the peer is loopback, and setForwardingHeaders OVERWRITES rather than filling, so downstream gets one value written by a hop that could see the connection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQDnpb2NzHhjAXT8MMAuVY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every public visitor has been recorded as
127.0.0.1for as long as the SNI split has existed. Measured downstream in kernel-hive on 2026-09-01 and again on 2026-09-21: the gallery's client log holds 36,746 rows with exactly two distinct values, both private.The cause is not in the Go code.
haproxy.cfgsplits:443in TCP mode and hands Caddy a loopback connection, so Caddy honestly recordedX-Forwarded-For: 127.0.0.1, and the forwarder — which filled the header only when empty — passed that on. Downstream, geography resolves nothing and rate limiting keys on a constant.What changes
A TCP proxy cannot set a header, so the address travels ahead of the TLS handshake instead:
haproxy.cfg—send-proxy-v2on thecaddy_httpsbackend;option forwardforon the:80frontend, which is already in HTTP mode.Caddyfile/install.sh— aproxy_protocollistener wrapper, rendered only when HAProxy is in front.allow 127.0.0.1/32is the trust boundary: only HAProxy on this box may assert an address. Thetlswrapper stays last, because wrappers run in order and the PROXY header precedes the handshake.proxy.go— the forwarder stops passing on what arrived.The trust half
Caddy appends to whatever the client sent, so a non-empty header is
<the caller's invention>, <what Caddy saw>. A downstream reader taking the first hop — the conventional choice, and what kernel-hive did — was reading the caller.realIPnow takes the last entry, and only when the peer is loopback; a non-loopback peer means the request did not come through Caddy, so the header carries no weight at all.setForwardingHeadersoverwrites rather than filling, so downstream receives a single value written by a hop that could see the connection.internal/server/realip_test.gopins that boundary, including the spoof-through-Caddy and direct-caller cases.Paired change
kernel-hive enforces the same boundary on the reading side (
scripts/serve/clientip.py): believe the header only from a loopback peer, take the last entry. Landing that first is harmless — it is strictly more conservative than today's behaviour.Note
Opened as a PR rather than pushed to main because a push to main auto-deploys to the edge, and there is no Go toolchain on either box here — CI is the first place this compiles. Merge once
buildis green.