Fixes the defect the operator spotted in #370 immediately after it shipped: auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a proxy on a public address — a separate host, or a CDN, i.e. anyone running this publicly, since public means TLS means a proxy — recorded the PROXY for every session. created_ip and last_ip were then always equal and the "Address changed" signal could never fire. The feature looked like it worked and reported nothing. Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx). XFF grows left-to-right as each proxy appends the peer it received from, so for client -> CDN -> own-proxy -> app the app sees [client, CDN] with RemoteAddr = own-proxy, and the client sits at XFF[len - hops]: 0 RemoteAddr, XFF ignored — no proxy 1 the address your own proxy observed 2 through a CDN in front of your proxy Default 1, per the operator: publicly reachable means a TLS terminator in front. The cost is real and stated rather than hidden. hops >= 1 DECLARES that a proxy exists; set it with no proxy, or deeper than the actual chain, and the index reaches attacker-supplied entries, letting a visitor choose which address their own session shows — defeating exactly the detection #370 is for. That's inherent to the model, which is why 0 is a first-class value and the admin card says "count your proxies, don't guess high" instead of just exposing a number. Both mis-set shapes are pinned by tests so they stay known consequences rather than surprises. Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an optimisation: ClientIP runs in RequireUser for every authenticated request, so a per-request query would put the database on the critical path of the whole API. New() always returns a usable service so a boot-time DB hiccup degrades to the default instead of breaking that path (rule #131), and Hops() is nil-safe because test routers construct middleware without it. RequireUser now takes a func() int rather than an int — the value is operator-editable at runtime while the middleware is built once at boot, and reading it per request is what makes a save take effect with no restart (rule #25). The admin card is verifiable, not just configurable: it reports the address the CURRENT setting resolves THIS request to, the raw forwarded chain, and the socket peer — so you set the number, save, and confirm the address matches the machine you're on. It also counts the arriving chain and says how many proxies that implies. GET/PUT both return that payload, PUT recomputed under the new value, so the effect is visible without a reload. Also fixes styling in the #370 card that CI could not catch: text-destructive and bg-destructive don't exist in this Tailwind config — the palette is colors.action.destructive — so the "Address changed" warning and the sign-out-others button were rendering unstyled. Both now use text-action-destructive / bg-action-destructive / text-action-fg. Not done here: requestlog.go still logs raw RemoteAddr and will disagree with the sessions UI about who connected. Left for its own change.
171 lines
4.8 KiB
Go
171 lines
4.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// The hop arithmetic is the whole feature, so the table is written as
|
|
// deployment topologies rather than abstract inputs.
|
|
func TestClientIP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hops int
|
|
remoteAddr string
|
|
forwarded string
|
|
realIP string
|
|
want string
|
|
}{
|
|
{
|
|
name: "no proxy configured, socket peer wins",
|
|
hops: 0,
|
|
remoteAddr: "203.0.113.5:51234",
|
|
want: "203.0.113.5",
|
|
},
|
|
{
|
|
// hops 0 is the setting for a directly-exposed instance, and it
|
|
// must make forged headers inert.
|
|
name: "hops 0 ignores a forged forwarded header",
|
|
hops: 0,
|
|
remoteAddr: "203.0.113.5:51234",
|
|
forwarded: "198.51.100.99",
|
|
want: "203.0.113.5",
|
|
},
|
|
{
|
|
// The common case: one TLS-terminating proxy. Note RemoteAddr is
|
|
// PUBLIC here — a proxy on its own host — which the previous
|
|
// private-range heuristic got wrong.
|
|
name: "one proxy on a public address yields the client",
|
|
hops: 1,
|
|
remoteAddr: "203.0.113.200:40000",
|
|
forwarded: "198.51.100.7",
|
|
want: "198.51.100.7",
|
|
},
|
|
{
|
|
name: "one proxy on a private address yields the client",
|
|
hops: 1,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: "198.51.100.7",
|
|
want: "198.51.100.7",
|
|
},
|
|
{
|
|
// client -> Cloudflare -> own proxy -> app.
|
|
// Trusting only our own proxy, the honest answer is Cloudflare:
|
|
// that's the address our proxy actually observed.
|
|
name: "cdn chain with hops 1 stops at the cdn",
|
|
hops: 1,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: "198.51.100.7, 203.0.113.50",
|
|
want: "203.0.113.50",
|
|
},
|
|
{
|
|
// Same chain, both hops trusted — now we reach the real client.
|
|
name: "cdn chain with hops 2 reaches the client",
|
|
hops: 2,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: "198.51.100.7, 203.0.113.50",
|
|
want: "198.51.100.7",
|
|
},
|
|
{
|
|
// A client prepending a lie is only reachable if the operator
|
|
// over-counts their proxies; at the correct depth it's skipped.
|
|
name: "forged prefix is not reached at the correct depth",
|
|
hops: 1,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: "1.2.3.4, 198.51.100.7",
|
|
want: "198.51.100.7",
|
|
},
|
|
{
|
|
// The documented mis-set failure, pinned so it stays a KNOWN
|
|
// consequence rather than a surprise: depth deeper than the real
|
|
// chain reads attacker-supplied input.
|
|
name: "hops set deeper than the chain clamps to the leftmost entry",
|
|
hops: 5,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: "1.2.3.4, 198.51.100.7",
|
|
want: "1.2.3.4",
|
|
},
|
|
{
|
|
name: "no forwarding header falls back to the socket peer",
|
|
hops: 1,
|
|
remoteAddr: "203.0.113.5:51234",
|
|
want: "203.0.113.5",
|
|
},
|
|
{
|
|
name: "x-real-ip used when forwarded-for is absent",
|
|
hops: 1,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
realIP: "198.51.100.7",
|
|
want: "198.51.100.7",
|
|
},
|
|
{
|
|
name: "forwarded-for wins over x-real-ip when both present",
|
|
hops: 1,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: "198.51.100.7",
|
|
realIP: "1.2.3.4",
|
|
want: "198.51.100.7",
|
|
},
|
|
{
|
|
// Positions are preserved, so a garbage hop can be selected —
|
|
// in which case we fall back rather than return nonsense.
|
|
name: "unparseable selected entry falls back to the socket peer",
|
|
hops: 1,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: "198.51.100.7, not-an-ip",
|
|
want: "172.18.0.1",
|
|
},
|
|
{
|
|
name: "ipv6 client through one proxy",
|
|
hops: 1,
|
|
remoteAddr: "[fd00::1]:40000",
|
|
forwarded: "2001:db8::5",
|
|
want: "2001:db8::5",
|
|
},
|
|
{
|
|
name: "ipv6 socket peer without proxy",
|
|
hops: 0,
|
|
remoteAddr: "[2001:db8::1]:51234",
|
|
want: "2001:db8::1",
|
|
},
|
|
{
|
|
name: "remote addr without a port is tolerated",
|
|
hops: 0,
|
|
remoteAddr: "203.0.113.5",
|
|
want: "203.0.113.5",
|
|
},
|
|
{
|
|
name: "empty remote addr yields empty",
|
|
hops: 1,
|
|
remoteAddr: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "whitespace-only forwarded header is treated as absent",
|
|
hops: 1,
|
|
remoteAddr: "172.18.0.1:40000",
|
|
forwarded: " ",
|
|
want: "172.18.0.1",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r, err := http.NewRequest(http.MethodGet, "/api/me/sessions", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest: %v", err)
|
|
}
|
|
r.RemoteAddr = tc.remoteAddr
|
|
if tc.forwarded != "" {
|
|
r.Header.Set("X-Forwarded-For", tc.forwarded)
|
|
}
|
|
if tc.realIP != "" {
|
|
r.Header.Set("X-Real-IP", tc.realIP)
|
|
}
|
|
if got := ClientIP(r, tc.hops); got != tc.want {
|
|
t.Errorf("ClientIP(hops=%d) = %q, want %q", tc.hops, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|