Client address is the right-most untrusted X-Forwarded-For entry (closes #144)
check / check (push) Waiting to run
check / check (push) Waiting to run
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. It now walks the entries from the right past trusted proxies and takes the first that is not one. An entry with a port is read as its address; with only trusted entries, or one that is not an address at all, it uses the peer. Every X-Forwarded-For header line is read, and X-Real-IP only when there is no X-Forwarded-For entry. Model: opus-5-5
This commit was merged in pull request #166.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -133,6 +134,17 @@ func ipFromHostPort(hostPort string) string {
|
||||
// X-Real-IP are honoured only when the direct peer is a
|
||||
// trusted proxy; otherwise the direct peer is returned so a
|
||||
// spoofed header cannot forge the logged address.
|
||||
//
|
||||
// Each proxy appends to X-Forwarded-For the address it took the
|
||||
// request from, so the entries on the right are written by the
|
||||
// trusted proxies and those further left by the client. The
|
||||
// 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
|
||||
// read as that address, both for the trust check and as the
|
||||
// result. When every entry is a trusted proxy, or the walk from
|
||||
// the right meets an entry that is not an address at all (such as
|
||||
// "unknown"), the peer is used. X-Real-IP is read only when there
|
||||
// is no X-Forwarded-For entry at all.
|
||||
func clientIP(
|
||||
remoteAddr string,
|
||||
header http.Header,
|
||||
@@ -144,28 +156,49 @@ func clientIP(
|
||||
return peer
|
||||
}
|
||||
|
||||
if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" {
|
||||
return xff
|
||||
forwarded := forwardedFor(header)
|
||||
|
||||
if len(forwarded) == 0 {
|
||||
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
|
||||
return xr
|
||||
}
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
|
||||
return xr
|
||||
for _, entry := range slices.Backward(forwarded) {
|
||||
if !validIP(entry) {
|
||||
return peer
|
||||
}
|
||||
|
||||
if !addrInAny(entry, trusted) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
// firstForwardedFor returns the left-most valid address in an
|
||||
// X-Forwarded-For list (the original client), or "" if none.
|
||||
func firstForwardedFor(value string) string {
|
||||
// forwardedFor returns the entries of X-Forwarded-For in order,
|
||||
// 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
|
||||
// sent, so every line is read, not only the first. Some proxies
|
||||
// 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 {
|
||||
value := strings.Join(header.Values("X-Forwarded-For"), ",")
|
||||
|
||||
var entries []string
|
||||
|
||||
for part := range strings.SplitSeq(value, ",") {
|
||||
candidate := strings.TrimSpace(part)
|
||||
if validIP(candidate) {
|
||||
return candidate
|
||||
entry := strings.TrimSpace(part)
|
||||
if entry != "" {
|
||||
entries = append(entries, ipFromHostPort(entry))
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return entries
|
||||
}
|
||||
|
||||
func validIP(s string) bool {
|
||||
|
||||
@@ -25,10 +25,16 @@ import (
|
||||
const (
|
||||
// loopbackPeer is a remote address inside the trusted-proxy allowlist.
|
||||
loopbackPeer = "127.0.0.1:5000"
|
||||
// loopbackIP is the address of loopbackPeer, which ClientIP returns
|
||||
// when it uses the peer.
|
||||
loopbackIP = "127.0.0.1"
|
||||
// forwardedIP is the client address presented via X-Forwarded-For.
|
||||
forwardedIP = "203.0.113.7"
|
||||
// realIP is the client address presented via X-Real-IP.
|
||||
realIP = "203.0.113.9"
|
||||
// appendedIP is the X-Forwarded-For entry a trusted proxy appended
|
||||
// after the entries the client wrote.
|
||||
appendedIP = "198.51.100.7"
|
||||
)
|
||||
|
||||
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
||||
@@ -85,10 +91,35 @@ func clientIPCases() []clientIPCase {
|
||||
want: forwardedIP,
|
||||
},
|
||||
{
|
||||
name: "trusted proxy uses left-most of chain",
|
||||
remoteAddr: "10.1.2.3:5000",
|
||||
xff: forwardedIP + ", 10.1.2.3",
|
||||
want: forwardedIP,
|
||||
name: "trusted proxy uses right-most of chain",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "203.0.113.9, " + appendedIP,
|
||||
want: appendedIP,
|
||||
},
|
||||
{
|
||||
name: "trusted proxy entries are skipped from the right",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "203.0.113.9, 10.0.0.5",
|
||||
want: "203.0.113.9",
|
||||
},
|
||||
{
|
||||
name: "chain of only trusted proxies uses peer",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "10.0.0.5, 10.1.2.3",
|
||||
want: loopbackIP,
|
||||
},
|
||||
{
|
||||
name: "entry that is not an address uses peer",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "203.0.113.9, unknown",
|
||||
want: loopbackIP,
|
||||
},
|
||||
{
|
||||
name: "x-real-ip ignored when forwarded-for has entries",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "10.0.0.5",
|
||||
xRealIP: realIP,
|
||||
want: loopbackIP,
|
||||
},
|
||||
{
|
||||
name: "trusted proxy falls back to x-real-ip",
|
||||
@@ -117,7 +148,7 @@ func clientIPCases() []clientIPCase {
|
||||
name: "trusted proxy with garbage header uses peer",
|
||||
remoteAddr: loopbackPeer,
|
||||
xff: "not-an-ip",
|
||||
want: "127.0.0.1",
|
||||
want: loopbackIP,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -148,6 +179,47 @@ func TestClientIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientIPReadsEveryForwardedForLine checks that an entry a proxy
|
||||
// adds as an X-Forwarded-For line of its own is read, and the line the
|
||||
// client sent before it is not taken.
|
||||
func TestClientIPReadsEveryForwardedForLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
header := http.Header{}
|
||||
header.Add("X-Forwarded-For", "203.0.113.9")
|
||||
header.Add("X-Forwarded-For", appendedIP)
|
||||
|
||||
got := middleware.ClientIP(loopbackPeer, header,
|
||||
mustPrefixes(t, "127.0.0.1/32"))
|
||||
if got != appendedIP {
|
||||
t.Errorf("ClientIP() = %q, want %q", got, appendedIP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientIPReadsAddressWithPort checks that an X-Forwarded-For entry
|
||||
// written as an address with a port is read as that address, both for
|
||||
// the trust check and as the result.
|
||||
func TestClientIPReadsAddressWithPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
trusted := mustPrefixes(t, "127.0.0.1/32", "10.0.0.0/8")
|
||||
|
||||
for xff, want := range map[string]string{
|
||||
"203.0.113.9, " + appendedIP + ":5000": appendedIP,
|
||||
"203.0.113.9, [2001:db8::1]:443": "2001:db8::1",
|
||||
"203.0.113.9, 10.0.0.5:5000": "203.0.113.9",
|
||||
} {
|
||||
header := http.Header{}
|
||||
header.Set("X-Forwarded-For", xff)
|
||||
|
||||
got := middleware.ClientIP(loopbackPeer, header, trusted)
|
||||
if got != want {
|
||||
t.Errorf("X-Forwarded-For %q: ClientIP() = %q, want %q",
|
||||
xff, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user