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:
2026-03-13 00:43:09 -07:00
parent 586d4e3355
commit d3b63b1f87
2 changed files with 181 additions and 8 deletions

View File

@@ -289,28 +289,75 @@ func (l *grpcRateLimiter) cleanup() {
// rateLimitInterceptor applies per-IP rate limiting using the same token-bucket
// parameters as the REST rate limiter (10 req/s, burst 10).
//
// Security (SEC-06): uses grpcClientIP to extract the real client IP when
// behind a trusted reverse proxy, matching the REST middleware behaviour.
func (s *Server) rateLimitInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
ip := ""
if p, ok := peer.FromContext(ctx); ok {
host, _, err := net.SplitHostPort(p.Addr.String())
if err == nil {
ip = host
} else {
ip = p.Addr.String()
}
var trustedProxy net.IP
if s.cfg.Server.TrustedProxy != "" {
trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
}
ip := grpcClientIP(ctx, trustedProxy)
if ip != "" && !s.rateLimiter.allow(ip) {
return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded")
}
return handler(ctx, req)
}
// grpcClientIP extracts the real client IP from gRPC context, optionally
// honouring proxy headers when the peer matches the trusted proxy.
//
// Security (SEC-06): mirrors middleware.ClientIP for the REST server.
// X-Forwarded-For and X-Real-IP metadata are only trusted when the immediate
// peer address matches trustedProxy exactly, preventing IP-spoofing attacks.
// Only the first (leftmost) value in x-forwarded-for is used (original client).
// gRPC lowercases all metadata keys, so we look up "x-forwarded-for" and
// "x-real-ip".
func grpcClientIP(ctx context.Context, trustedProxy net.IP) string {
peerIP := ""
if p, ok := peer.FromContext(ctx); ok {
host, _, err := net.SplitHostPort(p.Addr.String())
if err == nil {
peerIP = host
} else {
peerIP = p.Addr.String()
}
}
if trustedProxy != nil && peerIP != "" {
remoteIP := net.ParseIP(peerIP)
if remoteIP != nil && remoteIP.Equal(trustedProxy) {
// Peer is the trusted proxy — extract real client IP from metadata.
// Prefer x-real-ip (single value) over x-forwarded-for (may be a
// comma-separated list when multiple proxies are chained).
md, ok := metadata.FromIncomingContext(ctx)
if ok {
if vals := md.Get("x-real-ip"); len(vals) > 0 {
if ip := net.ParseIP(strings.TrimSpace(vals[0])); ip != nil {
return ip.String()
}
}
if vals := md.Get("x-forwarded-for"); len(vals) > 0 {
// Take the first (leftmost) address — the original client.
first, _, _ := strings.Cut(vals[0], ",")
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
return ip.String()
}
}
}
}
}
return peerIP
}
// extractBearerFromMD extracts the Bearer token from gRPC metadata.
// The key lookup is case-insensitive per gRPC metadata convention (all keys
// are lowercased by the framework; we match on "authorization").