0b96c114b6
The per-IP rate limiter keys on the transport RemoteAddr. Behind the same-origin Caddy proxy that fronts the control plane, every request arrives with the proxy's single IP, which collapses the limiter into one bucket shared by the whole world — a flood from one client throttles all of them. Add an opt-in `--trusted-proxies` flag (comma-separated IPs/CIDRs). When the immediate peer is one of the named proxies, clientIP now believes its X-Forwarded-For (read right-to-left, skipping trusted hops) or X-Real-IP and keys on the real client. A direct, non-trusted peer's forwarding headers are ignored entirely, so this opens no quota-fanning hole: an attacker connecting straight to the public :8470 cannot spoof a key. The zero value (no flag) preserves the prior RemoteAddr-only behavior exactly. Covered by ratelimit_proxy_test.go: trusted vs untrusted peers, XFF right-to-left precedence, client-prepended forgery, X-Real-IP fallback, and rejection of malformed proxy entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
114 lines
3.1 KiB
Go
114 lines
3.1 KiB
Go
package membership
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// TestClientIPTrustedProxy covers the rate-limit key extraction behind a reverse
|
|
// proxy: forwarding headers are believed ONLY when the immediate peer is a
|
|
// configured trusted proxy, and never otherwise. This is what keeps the per-IP
|
|
// rate limit per-client once the control plane runs behind the same-origin Caddy
|
|
// proxy, without opening a quota-fanning hole for a direct attacker.
|
|
func TestClientIPTrustedProxy(t *testing.T) {
|
|
const caddy = "135.125.201.30"
|
|
|
|
cases := []struct {
|
|
name string
|
|
proxies []string
|
|
remote string
|
|
xff string
|
|
xRealIP string
|
|
want string
|
|
}{
|
|
{
|
|
name: "no trusted proxies ignores XFF",
|
|
remote: "203.0.113.7:5000",
|
|
xff: "1.2.3.4",
|
|
want: "203.0.113.7",
|
|
},
|
|
{
|
|
name: "trusted proxy honors XFF client",
|
|
proxies: []string{caddy},
|
|
remote: caddy + ":4451",
|
|
xff: "198.51.100.23",
|
|
want: "198.51.100.23",
|
|
},
|
|
{
|
|
name: "loopback proxy honors XFF (magnus-local hop)",
|
|
proxies: []string{"127.0.0.1/32", "::1/128"},
|
|
remote: "127.0.0.1:33344",
|
|
xff: "198.51.100.99",
|
|
want: "198.51.100.99",
|
|
},
|
|
{
|
|
name: "untrusted peer cannot spoof XFF",
|
|
proxies: []string{caddy},
|
|
remote: "203.0.113.7:5000",
|
|
xff: "10.0.0.1",
|
|
want: "203.0.113.7",
|
|
},
|
|
{
|
|
name: "XFF read right-to-left, trusted hops skipped",
|
|
proxies: []string{caddy},
|
|
remote: caddy + ":4451",
|
|
xff: "198.51.100.23, " + caddy,
|
|
want: "198.51.100.23",
|
|
},
|
|
{
|
|
name: "client-prepended forgery is skipped, real appended wins",
|
|
proxies: []string{caddy},
|
|
remote: caddy + ":4451",
|
|
xff: "9.9.9.9, 198.51.100.23",
|
|
want: "198.51.100.23",
|
|
},
|
|
{
|
|
name: "X-Real-IP fallback when no XFF",
|
|
proxies: []string{caddy},
|
|
remote: caddy + ":4451",
|
|
xRealIP: "198.51.100.77",
|
|
want: "198.51.100.77",
|
|
},
|
|
{
|
|
name: "trusted peer but no forwarding header falls back to peer",
|
|
proxies: []string{caddy},
|
|
remote: caddy + ":4451",
|
|
want: caddy,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := &Server{}
|
|
if len(tc.proxies) > 0 {
|
|
if err := s.SetTrustedProxies(tc.proxies); err != nil {
|
|
t.Fatalf("SetTrustedProxies(%v): %v", tc.proxies, err)
|
|
}
|
|
}
|
|
r, _ := http.NewRequest(http.MethodGet, "/rooms", nil)
|
|
r.RemoteAddr = tc.remote
|
|
if tc.xff != "" {
|
|
r.Header.Set("X-Forwarded-For", tc.xff)
|
|
}
|
|
if tc.xRealIP != "" {
|
|
r.Header.Set("X-Real-IP", tc.xRealIP)
|
|
}
|
|
if got := s.clientIP(r); got != tc.want {
|
|
t.Fatalf("clientIP = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseTrustedProxiesRejectsGarbage proves a malformed entry is a hard error
|
|
// (the command turns it into a startup failure) rather than a silently ignored
|
|
// misconfiguration that would leave the rate limit collapsed behind the proxy.
|
|
func TestParseTrustedProxiesRejectsGarbage(t *testing.T) {
|
|
if _, err := parseTrustedProxies([]string{"not-an-ip"}); err == nil {
|
|
t.Fatal("expected error for non-IP/CIDR entry, got nil")
|
|
}
|
|
if _, err := parseTrustedProxies([]string{"10.0.0.0/8", "127.0.0.1"}); err != nil {
|
|
t.Fatalf("valid entries rejected: %v", err)
|
|
}
|
|
}
|