Fix SEC-06: proxy-aware gRPC rate limiting
- Add grpcClientIP() helper that mirrors middleware.ClientIP for proxy-aware IP extraction from gRPC metadata - Update rateLimitInterceptor to use grpcClientIP with the TrustedProxy config setting - Only trust x-forwarded-for/x-real-ip metadata when the peer address matches the configured trusted proxy - Add 7 unit tests covering: no proxy, xff, x-real-ip preference, untrusted peer ignoring headers, no headers fallback, invalid header fallback, and no peer Security: gRPC rate limiter now extracts real client IPs behind a reverse proxy using the same trust model as the REST middleware (DEF-03). Headers from untrusted peers are ignored, preventing IP-spoofing for rate-limit bypass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
|
||||
@@ -650,3 +651,128 @@ func TestCredentialFieldsAbsentFromAccountResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- grpcClientIP tests (SEC-06) ----
|
||||
|
||||
// fakeAddr implements net.Addr for testing peer contexts.
|
||||
type fakeAddr struct {
|
||||
addr string
|
||||
network string
|
||||
}
|
||||
|
||||
func (a fakeAddr) String() string { return a.addr }
|
||||
func (a fakeAddr) Network() string { return a.network }
|
||||
|
||||
// TestGRPCClientIP_NoProxy verifies that when no trusted proxy is configured
|
||||
// the function returns the peer IP directly.
|
||||
func TestGRPCClientIP_NoProxy(t *testing.T) {
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
Addr: fakeAddr{addr: "10.0.0.5:54321", network: "tcp"},
|
||||
})
|
||||
|
||||
got := grpcClientIP(ctx, nil)
|
||||
if got != "10.0.0.5" {
|
||||
t.Errorf("grpcClientIP(no proxy) = %q, want %q", got, "10.0.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCClientIP_TrustedProxy_XForwardedFor verifies that when the peer
|
||||
// matches the trusted proxy, the real client IP is extracted from
|
||||
// x-forwarded-for metadata.
|
||||
func TestGRPCClientIP_TrustedProxy_XForwardedFor(t *testing.T) {
|
||||
proxyIP := net.ParseIP("192.168.1.1")
|
||||
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
|
||||
})
|
||||
md := metadata.Pairs("x-forwarded-for", "203.0.113.50, 10.0.0.1")
|
||||
ctx = metadata.NewIncomingContext(ctx, md)
|
||||
|
||||
got := grpcClientIP(ctx, proxyIP)
|
||||
if got != "203.0.113.50" {
|
||||
t.Errorf("grpcClientIP(xff) = %q, want %q", got, "203.0.113.50")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCClientIP_TrustedProxy_XRealIP verifies that x-real-ip is preferred
|
||||
// over x-forwarded-for when both are present.
|
||||
func TestGRPCClientIP_TrustedProxy_XRealIP(t *testing.T) {
|
||||
proxyIP := net.ParseIP("192.168.1.1")
|
||||
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
|
||||
})
|
||||
md := metadata.Pairs(
|
||||
"x-real-ip", "198.51.100.10",
|
||||
"x-forwarded-for", "203.0.113.50",
|
||||
)
|
||||
ctx = metadata.NewIncomingContext(ctx, md)
|
||||
|
||||
got := grpcClientIP(ctx, proxyIP)
|
||||
if got != "198.51.100.10" {
|
||||
t.Errorf("grpcClientIP(x-real-ip preferred) = %q, want %q", got, "198.51.100.10")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCClientIP_UntrustedPeer_IgnoresHeaders verifies that forwarded
|
||||
// headers are ignored when the peer does NOT match the trusted proxy.
|
||||
// Security: This prevents IP-spoofing by untrusted clients.
|
||||
func TestGRPCClientIP_UntrustedPeer_IgnoresHeaders(t *testing.T) {
|
||||
proxyIP := net.ParseIP("192.168.1.1")
|
||||
|
||||
// Peer is NOT the trusted proxy.
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
Addr: fakeAddr{addr: "10.0.0.99:54321", network: "tcp"},
|
||||
})
|
||||
md := metadata.Pairs(
|
||||
"x-forwarded-for", "203.0.113.50",
|
||||
"x-real-ip", "198.51.100.10",
|
||||
)
|
||||
ctx = metadata.NewIncomingContext(ctx, md)
|
||||
|
||||
got := grpcClientIP(ctx, proxyIP)
|
||||
if got != "10.0.0.99" {
|
||||
t.Errorf("grpcClientIP(untrusted peer) = %q, want %q", got, "10.0.0.99")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCClientIP_TrustedProxy_NoHeaders verifies that when the peer matches
|
||||
// the proxy but no forwarded headers are set, the peer IP is returned as fallback.
|
||||
func TestGRPCClientIP_TrustedProxy_NoHeaders(t *testing.T) {
|
||||
proxyIP := net.ParseIP("192.168.1.1")
|
||||
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
|
||||
})
|
||||
|
||||
got := grpcClientIP(ctx, proxyIP)
|
||||
if got != "192.168.1.1" {
|
||||
t.Errorf("grpcClientIP(proxy, no headers) = %q, want %q", got, "192.168.1.1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCClientIP_TrustedProxy_InvalidHeader verifies that invalid IPs in
|
||||
// headers are ignored and the peer IP is returned.
|
||||
func TestGRPCClientIP_TrustedProxy_InvalidHeader(t *testing.T) {
|
||||
proxyIP := net.ParseIP("192.168.1.1")
|
||||
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
Addr: fakeAddr{addr: "192.168.1.1:12345", network: "tcp"},
|
||||
})
|
||||
md := metadata.Pairs("x-forwarded-for", "not-an-ip")
|
||||
ctx = metadata.NewIncomingContext(ctx, md)
|
||||
|
||||
got := grpcClientIP(ctx, proxyIP)
|
||||
if got != "192.168.1.1" {
|
||||
t.Errorf("grpcClientIP(invalid header) = %q, want %q", got, "192.168.1.1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCClientIP_NoPeer verifies that an empty string is returned when
|
||||
// there is no peer in the context.
|
||||
func TestGRPCClientIP_NoPeer(t *testing.T) {
|
||||
got := grpcClientIP(context.Background(), nil)
|
||||
if got != "" {
|
||||
t.Errorf("grpcClientIP(no peer) = %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user