1 Commits
Author SHA1 Message Date
sneak d1ccea78fb Client address is the right-most untrusted X-Forwarded-For entry (closes #144)
check / check (push) Canceled after 0s
From a peer in TRUSTED_PROXIES, clientIP took the left-most
X-Forwarded-For entry, which the client writes itself behind a proxy
that appends to the header, so a client could choose the address the
rate limit counts and the request log records. It now walks the entries
from the right past trusted proxies and takes the first that is not
one; with only trusted entries, or one that is not an address, it uses
the peer. Every X-Forwarded-For header line is read. X-Real-IP is read
only when there is no X-Forwarded-For entry. README.md Settings says
which entry is taken.

Model: opus-5-5
2026-10-18 21:05:54 +00:00
4 changed files with 19 additions and 45 deletions
+5 -6
View File
@@ -215,12 +215,11 @@ has its forwarded headers ignored, and the direct peer is logged and
rate-limited instead. From a direct peer inside the set, the client address is rate-limited instead. From a direct peer inside the set, the client address is
the right-most `X-Forwarded-For` entry that is not in the set: each proxy the right-most `X-Forwarded-For` entry that is not in the set: each proxy
appends the address it took the request from, so the entries further left were appends the address it took the request from, so the entries further left were
written by the client and are not used. An entry written as an address with a written by the client and are not used. If the search from the right finds only
port, such as `198.51.100.7:5000` or `[2001:db8::1]:443`, is read as that addresses in the set, or meets an entry that is not an IP address, the direct
address. If the search from the right finds only addresses in the set, or meets peer is used. `X-Real-IP` is read only when `X-Forwarded-For` is missing or
an entry that is not an address at all, such as `unknown`, the direct peer is empty. The container image does not use this default; see
used. `X-Real-IP` is read only when `X-Forwarded-For` is missing or empty. The [Deployment](#deployment).
container image does not use this default; see [Deployment](#deployment).
A variable set to a value the server cannot use, such as `PORT=abc`, A variable set to a value the server cannot use, such as `PORT=abc`,
`DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from `DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from
+7 -9
View File
@@ -26,15 +26,13 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
([#144](https://git.eeqj.de/sneak/netwatch/issues/144)). It took the left-most ([#144](https://git.eeqj.de/sneak/netwatch/issues/144)). It took the left-most
entry, which the client writes itself behind a proxy that appends to the entry, which the client writes itself behind a proxy that appends to the
header, so a client could choose the address the rate limit counts and the header, so a client could choose the address the rate limit counts and the
request log records. An entry written as an address with a port is read as request log records. With only trusted addresses in the header, or an entry
that address. With only trusted addresses in the header, or an entry that is that is not an IP address met on the way, the direct peer is used. `X-Real-IP`
not an address at all (such as `unknown`) met on the way, the direct peer is is read only without `X-Forwarded-For`, and every `X-Forwarded-For` header
used. `X-Real-IP` is read only without `X-Forwarded-For`, and every line is read, not only the first. The image's nginx, and the
`X-Forwarded-For` header line is read, not only the first. The image's nginx, `TRUSTED_PROXIES=127.0.0.1/32` it starts the backend with, are unchanged. Unit
and the `TRUSTED_PROXIES=127.0.0.1/32` it starts the backend with, are tests resolve the address from chains ending in an untrusted address, in
unchanged. Unit tests resolve the address from chains ending in an untrusted trusted ones, of only trusted ones, and split over two header lines
address, in trusted ones, of only trusted ones, with ports, with an entry that
is not an address, and split over two header lines
- 2026-10-18: after an interval change, the summary's Reachable slot reads - 2026-10-18: after an interval change, the summary's Reachable slot reads
`--/--` in white, as at page load, until the second round since the change `--/--` in white, as at page load, until the second round since the change
ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It used to read ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It used to read
+6 -11
View File
@@ -139,12 +139,10 @@ func ipFromHostPort(hostPort string) string {
// request from, so the entries on the right are written by the // request from, so the entries on the right are written by the
// trusted proxies and those further left by the client. The // trusted proxies and those further left by the client. The
// client's address is therefore the right-most entry that is not // client's address is therefore the right-most entry that is not
// a trusted proxy. An entry written as an address with a port is // a trusted proxy. When every entry is a trusted proxy, or the
// read as that address, both for the trust check and as the // walk from the right meets an entry that is not an address, the
// result. When every entry is a trusted proxy, or the walk from // peer is used. X-Real-IP is read only when there is no
// the right meets an entry that is not an address at all (such as // X-Forwarded-For entry at all.
// "unknown"), the peer is used. X-Real-IP is read only when there
// is no X-Forwarded-For entry at all.
func clientIP( func clientIP(
remoteAddr string, remoteAddr string,
header http.Header, header http.Header,
@@ -182,10 +180,7 @@ func clientIP(
// forwardedFor returns the entries of X-Forwarded-For in order, // forwardedFor returns the entries of X-Forwarded-For in order,
// leaving out blank ones. A proxy may add its entry as a header // leaving out blank ones. A proxy may add its entry as a header
// line of its own rather than append it to the line the client // line of its own rather than append it to the line the client
// sent, so every line is read, not only the first. Some proxies // sent, so every line is read, not only the first.
// write an entry as an address with a port, such as
// 198.51.100.7:5000 or [2001:db8::1]:443; the port is removed so
// that the entry is read as its address.
func forwardedFor(header http.Header) []string { func forwardedFor(header http.Header) []string {
value := strings.Join(header.Values("X-Forwarded-For"), ",") value := strings.Join(header.Values("X-Forwarded-For"), ",")
@@ -194,7 +189,7 @@ func forwardedFor(header http.Header) []string {
for part := range strings.SplitSeq(value, ",") { for part := range strings.SplitSeq(value, ",") {
entry := strings.TrimSpace(part) entry := strings.TrimSpace(part)
if entry != "" { if entry != "" {
entries = append(entries, ipFromHostPort(entry)) entries = append(entries, entry)
} }
} }
+1 -19
View File
@@ -105,28 +105,10 @@ func clientIPCases() []clientIPCase {
xff: "10.0.0.5, 10.1.2.3", xff: "10.0.0.5, 10.1.2.3",
want: loopbackIP, want: loopbackIP,
}, },
{
name: "entry with a port is read as its address",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, 198.51.100.7:5000",
want: "198.51.100.7",
},
{
name: "ipv6 entry with a port is read as its address",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, [2001:db8::1]:443",
want: "2001:db8::1",
},
{
name: "trusted proxy entry with a port is skipped",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, 10.0.0.5:5000",
want: "203.0.113.9",
},
{ {
name: "entry that is not an address uses peer", name: "entry that is not an address uses peer",
remoteAddr: loopbackPeer, remoteAddr: loopbackPeer,
xff: "203.0.113.9, unknown", xff: "203.0.113.9, 198.51.100.7:5000",
want: loopbackIP, want: loopbackIP,
}, },
{ {