Skip to content

Commit 91e9d56

Browse files
tadasantclaude
andcommitted
Add IP-based rate limiting to protect against abuse
Implements rate limiting middleware to address service disruptions caused by excessive requests. The rate limiter enforces per-IP limits of 60 requests per minute and 1000 requests per hour (configurable via environment variables). Features: - Dual rate limiting (per-minute and per-hour) using token bucket algorithm - Proper handling of X-Forwarded-For and X-Real-IP headers for proxied requests - Skip paths for health, ping, and metrics endpoints - Background cleanup of stale visitor entries - Configurable via MCP_REGISTRY_RATE_LIMIT_* environment variables - Returns RFC 7807 problem+json responses with Retry-After header Closes #826 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 823c1a1 commit 91e9d56

File tree

5 files changed

+558
-9
lines changed

5 files changed

+558
-9
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,11 @@ MCP_REGISTRY_OIDC_EXTRA_CLAIMS=[{"hd":"modelcontextprotocol.io"}]
3838
# Grant admin permissions to OIDC-authenticated users
3939
MCP_REGISTRY_OIDC_EDIT_PERMISSIONS=*
4040
MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS=*
41+
42+
# Rate limiting configuration
43+
# Enable or disable rate limiting (default: true)
44+
MCP_REGISTRY_RATE_LIMIT_ENABLED=true
45+
# Maximum requests per minute per IP address (default: 60)
46+
MCP_REGISTRY_RATE_LIMIT_REQUESTS_PER_MINUTE=60
47+
# Maximum requests per hour per IP address (default: 1000)
48+
MCP_REGISTRY_RATE_LIMIT_REQUESTS_PER_HOUR=1000
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Package ratelimit provides IP-based rate limiting middleware for HTTP servers.
2+
package ratelimit
3+
4+
import (
5+
"encoding/json"
6+
"net"
7+
"net/http"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"golang.org/x/time/rate"
13+
)
14+
15+
// Config holds the rate limiting configuration
16+
type Config struct {
17+
// RequestsPerMinute is the maximum number of requests allowed per minute per IP
18+
RequestsPerMinute int
19+
// RequestsPerHour is the maximum number of requests allowed per hour per IP
20+
RequestsPerHour int
21+
// CleanupInterval is how often to clean up stale entries (default: 10 minutes)
22+
CleanupInterval time.Duration
23+
// SkipPaths are paths that should not be rate limited
24+
SkipPaths []string
25+
}
26+
27+
// DefaultConfig returns the default rate limiting configuration
28+
func DefaultConfig() Config {
29+
return Config{
30+
RequestsPerMinute: 60,
31+
RequestsPerHour: 1000,
32+
CleanupInterval: 10 * time.Minute,
33+
SkipPaths: []string{"/health", "/ping", "/metrics"},
34+
}
35+
}
36+
37+
// visitor tracks rate limiting state for a single IP address
38+
type visitor struct {
39+
minuteLimiter *rate.Limiter
40+
hourLimiter *rate.Limiter
41+
lastSeen time.Time
42+
}
43+
44+
// RateLimiter implements IP-based rate limiting
45+
type RateLimiter struct {
46+
config Config
47+
visitors map[string]*visitor
48+
mu sync.RWMutex
49+
stopCh chan struct{}
50+
}
51+
52+
// New creates a new RateLimiter with the given configuration
53+
func New(cfg Config) *RateLimiter {
54+
rl := &RateLimiter{
55+
config: cfg,
56+
visitors: make(map[string]*visitor),
57+
stopCh: make(chan struct{}),
58+
}
59+
60+
// Start background cleanup goroutine
61+
go rl.cleanupLoop()
62+
63+
return rl
64+
}
65+
66+
// Stop stops the background cleanup goroutine
67+
func (rl *RateLimiter) Stop() {
68+
close(rl.stopCh)
69+
}
70+
71+
// cleanupLoop periodically removes stale visitor entries
72+
func (rl *RateLimiter) cleanupLoop() {
73+
ticker := time.NewTicker(rl.config.CleanupInterval)
74+
defer ticker.Stop()
75+
76+
for {
77+
select {
78+
case <-ticker.C:
79+
rl.cleanup()
80+
case <-rl.stopCh:
81+
return
82+
}
83+
}
84+
}
85+
86+
// cleanup removes visitors that haven't been seen in the last hour
87+
func (rl *RateLimiter) cleanup() {
88+
rl.mu.Lock()
89+
defer rl.mu.Unlock()
90+
91+
threshold := time.Now().Add(-time.Hour)
92+
for ip, v := range rl.visitors {
93+
if v.lastSeen.Before(threshold) {
94+
delete(rl.visitors, ip)
95+
}
96+
}
97+
}
98+
99+
// getVisitor returns the visitor for the given IP, creating one if necessary
100+
func (rl *RateLimiter) getVisitor(ip string) *visitor {
101+
rl.mu.Lock()
102+
defer rl.mu.Unlock()
103+
104+
v, exists := rl.visitors[ip]
105+
if !exists {
106+
// Create rate limiters:
107+
// - Minute limiter: allows RequestsPerMinute requests per minute with burst of same
108+
// - Hour limiter: allows RequestsPerHour requests per hour with burst of same
109+
minuteRate := rate.Limit(float64(rl.config.RequestsPerMinute) / 60.0) // requests per second
110+
hourRate := rate.Limit(float64(rl.config.RequestsPerHour) / 3600.0) // requests per second
111+
112+
v = &visitor{
113+
minuteLimiter: rate.NewLimiter(minuteRate, rl.config.RequestsPerMinute),
114+
hourLimiter: rate.NewLimiter(hourRate, rl.config.RequestsPerHour),
115+
lastSeen: time.Now(),
116+
}
117+
rl.visitors[ip] = v
118+
} else {
119+
v.lastSeen = time.Now()
120+
}
121+
122+
return v
123+
}
124+
125+
// Allow checks if a request from the given IP should be allowed
126+
func (rl *RateLimiter) Allow(ip string) bool {
127+
v := rl.getVisitor(ip)
128+
129+
// Both limiters must allow the request
130+
if !v.minuteLimiter.Allow() {
131+
return false
132+
}
133+
if !v.hourLimiter.Allow() {
134+
return false
135+
}
136+
return true
137+
}
138+
139+
// shouldSkip returns true if the path should not be rate limited
140+
func (rl *RateLimiter) shouldSkip(path string) bool {
141+
for _, skipPath := range rl.config.SkipPaths {
142+
if path == skipPath || strings.HasPrefix(path, skipPath+"/") {
143+
return true
144+
}
145+
}
146+
return false
147+
}
148+
149+
// getClientIP extracts the client IP from the request
150+
// It considers X-Forwarded-For and X-Real-IP headers for reverse proxy scenarios
151+
func getClientIP(r *http.Request) string {
152+
// Check X-Forwarded-For header (can contain multiple IPs)
153+
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
154+
// Take the first IP (original client)
155+
if idx := strings.Index(xff, ","); idx != -1 {
156+
xff = xff[:idx]
157+
}
158+
xff = strings.TrimSpace(xff)
159+
if xff != "" {
160+
return xff
161+
}
162+
}
163+
164+
// Check X-Real-IP header
165+
if xri := r.Header.Get("X-Real-IP"); xri != "" {
166+
return strings.TrimSpace(xri)
167+
}
168+
169+
// Fall back to RemoteAddr
170+
ip, _, err := net.SplitHostPort(r.RemoteAddr)
171+
if err != nil {
172+
// RemoteAddr might not have a port
173+
return r.RemoteAddr
174+
}
175+
return ip
176+
}
177+
178+
// Middleware returns an HTTP middleware that enforces rate limiting
179+
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
180+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
181+
// Skip rate limiting for certain paths
182+
if rl.shouldSkip(r.URL.Path) {
183+
next.ServeHTTP(w, r)
184+
return
185+
}
186+
187+
ip := getClientIP(r)
188+
189+
if !rl.Allow(ip) {
190+
w.Header().Set("Content-Type", "application/problem+json")
191+
w.Header().Set("Retry-After", "60")
192+
w.WriteHeader(http.StatusTooManyRequests)
193+
194+
errorBody := map[string]interface{}{
195+
"title": "Too Many Requests",
196+
"status": http.StatusTooManyRequests,
197+
"detail": "Rate limit exceeded. Please reduce request frequency and retry after some time.",
198+
}
199+
200+
jsonData, err := json.Marshal(errorBody)
201+
if err != nil {
202+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
203+
return
204+
}
205+
_, _ = w.Write(jsonData)
206+
return
207+
}
208+
209+
next.ServeHTTP(w, r)
210+
})
211+
}

0 commit comments

Comments
 (0)