Skip to content

edge: carry the real client address, and make X-Forwarded-For worth trusting - #1

Merged
Wnt merged 1 commit into
mainfrom
xff-real-client
Sep 21, 2026
Merged

Wnt merged 1 commit into
mainfrom
xff-real-client

Conversation

@Wnt

@Wnt Wnt commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Why

Every public visitor has been recorded as 127.0.0.1 for 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.cfg splits :443 in TCP mode and hands Caddy a loopback connection, so Caddy honestly recorded X-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-v2 on the caddy_https backend; option forwardfor on the :80 frontend, which is already in HTTP mode.
  • Caddyfile / install.sh — a proxy_protocol listener wrapper, rendered only when HAProxy is in front. allow 127.0.0.1/32 is the trust boundary: only HAProxy on this box may assert an address. The tls wrapper 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.

realIP now 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. setForwardingHeaders overwrites rather than filling, so downstream receives a single value written by a hop that could see the connection.

internal/server/realip_test.go pins 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 build is green.

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
@Wnt
Wnt merged commit 2f6092e into main Sep 21, 2026
2 checks passed
@Wnt
Wnt deleted the xff-real-client branch September 21, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant