feat(membership): trust reverse-proxy forwarded client IP for rate limit
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>
This commit is contained in:
@@ -59,6 +59,7 @@ func main() {
|
||||
natsStore = flag.String("nats-store", "./local_files/jetstream", "embedded JetStream store dir")
|
||||
busAuth = flag.String("bus-auth", "off", "control-plane auth rollout: off|soft|enforce (feature flag bus-auth)")
|
||||
corsOrigins = flag.String("cors-origins", "", "comma-separated CORS allowlist of browser origins permitted to call the control plane (e.g. http://localhost:5173,https://chat.example.com); empty = CORS off. Enables the browser-native uniweb client (issue uniweb/0001)")
|
||||
trustedProxies = flag.String("trusted-proxies", "", "comma-separated IPs/CIDRs of reverse proxies whose X-Forwarded-For/X-Real-IP is trusted for the per-IP rate limit; empty = trust the direct connection only. Set to the same-origin proxy's address (e.g. the Caddy node) so the rate limit stays per-client behind the proxy")
|
||||
tlsCert = flag.String("tls-cert", "", "PATH to the NATS server certificate (deploy/tls/server.crt); enables TLS on the embedded data plane")
|
||||
tlsKey = flag.String("tls-key", "", "path to the NATS server private key (deploy/tls/server.key); required with --tls-cert")
|
||||
// Cluster (issue 0003a): empty --cluster-name keeps the server standalone.
|
||||
@@ -357,6 +358,17 @@ func main() {
|
||||
srv.AllowedOrigins = origins
|
||||
log.Printf("CORS: allowing %d browser origin(s): %s", len(origins), strings.Join(origins, ", "))
|
||||
}
|
||||
// Trusted reverse proxies for the per-IP rate limit. Behind the same-origin
|
||||
// Caddy proxy every request arrives with the proxy's IP, which would collapse
|
||||
// the per-IP rate limit into one bucket for the whole world; naming the proxy
|
||||
// here lets the limiter believe its X-Forwarded-For and key on the real client
|
||||
// instead. Empty flag => trust the direct connection only (unchanged behavior).
|
||||
if proxies := splitRoutes(*trustedProxies); len(proxies) > 0 {
|
||||
if err := srv.SetTrustedProxies(proxies); err != nil {
|
||||
log.Fatalf("invalid --trusted-proxies: %v", err)
|
||||
}
|
||||
log.Printf("rate limit: trusting forwarded client IP from proxies: %s", strings.Join(proxies, ", "))
|
||||
}
|
||||
|
||||
// Replicated anti-replay (issue 0006a, audit 0008 N3): a clustered node MUST
|
||||
// share its nonce store across the cluster, or a request accepted on one node
|
||||
|
||||
Reference in New Issue
Block a user