ClientQueue limits concurrent requests per client (X-Client-Id, then Authorization, session, IP) and queues the rest first-in-first-out, with bounded depth, max wait and idle eviction. Chain composes it with auth for the Setup*Routes middleware slot. Exports burst, wait and depth metrics. The test server uses it with a limit of 10.
22 KiB
Middleware Package
HTTP middleware utilities for security and performance.
Table of Contents
Rate Limiting
Production-grade rate limiting using token bucket algorithm.
Quick Start
import "github.com/bitechdev/ResolveSpec/pkg/middleware"
// Create rate limiter: 100 requests per second, burst of 20
rateLimiter := middleware.NewRateLimiter(100, 20)
// Apply to all routes
router.Use(rateLimiter.Middleware)
Basic Usage
package main
import (
"log"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/middleware"
"github.com/gorilla/mux"
)
func main() {
router := mux.NewRouter()
// Rate limit: 10 requests per second, burst of 5
rateLimiter := middleware.NewRateLimiter(10, 5)
router.Use(rateLimiter.Middleware)
router.HandleFunc("/api/data", dataHandler)
log.Fatal(http.ListenAndServe(":8080", router))
}
Custom Key Extraction
By default, rate limiting is per IP address. Customize the key:
// Rate limit by User ID from header
keyFunc := func(r *http.Request) string {
userID := r.Header.Get("X-User-ID")
if userID == "" {
return r.RemoteAddr // Fallback to IP
}
return "user:" + userID
}
router.Use(rateLimiter.MiddlewareWithKeyFunc(keyFunc))
Advanced Key Functions
By API Key:
keyFunc := func(r *http.Request) string {
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
return r.RemoteAddr
}
return "api:" + apiKey
}
By Authenticated User:
keyFunc := func(r *http.Request) string {
// Extract from JWT or session
user := getUserFromContext(r.Context())
if user != nil {
return "user:" + user.ID
}
return r.RemoteAddr
}
By Path + User:
keyFunc := func(r *http.Request) string {
user := getUserFromContext(r.Context())
if user != nil {
return fmt.Sprintf("user:%s:path:%s", user.ID, r.URL.Path)
}
return r.URL.Path + ":" + r.RemoteAddr
}
Different Limits Per Route
func main() {
router := mux.NewRouter()
// Public endpoints: 10 rps
publicLimiter := middleware.NewRateLimiter(10, 5)
// API endpoints: 100 rps
apiLimiter := middleware.NewRateLimiter(100, 20)
// Admin endpoints: 1000 rps
adminLimiter := middleware.NewRateLimiter(1000, 50)
// Apply different limiters to subrouters
publicRouter := router.PathPrefix("/public").Subrouter()
publicRouter.Use(publicLimiter.Middleware)
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.Use(apiLimiter.Middleware)
adminRouter := router.PathPrefix("/admin").Subrouter()
adminRouter.Use(adminLimiter.Middleware)
}
Rate Limit Response
When rate limited, clients receive:
HTTP/1.1 429 Too Many Requests
Content-Type: text/plain
{"error":"rate_limit_exceeded","message":"Too many requests"}
Configuration Examples
Tight Rate Limit (Anti-abuse):
// 1 request per second, burst of 3
rateLimiter := middleware.NewRateLimiter(1, 3)
Moderate Rate Limit (Standard API):
// 100 requests per second, burst of 20
rateLimiter := middleware.NewRateLimiter(100, 20)
Generous Rate Limit (Internal Services):
// 1000 requests per second, burst of 100
rateLimiter := middleware.NewRateLimiter(1000, 100)
Time-based Limits:
// 60 requests per minute = 1 request per second
rateLimiter := middleware.NewRateLimiter(1, 10)
// 1000 requests per hour ≈ 0.28 requests per second
rateLimiter := middleware.NewRateLimiter(0.28, 50)
Understanding Burst
The burst parameter allows short bursts above the rate:
// Rate: 10 rps, Burst: 5
// Allows up to 5 requests immediately, then 10/second
rateLimiter := middleware.NewRateLimiter(10, 5)
Bucket fills at rate: 10 tokens/second Bucket capacity: 5 tokens Request consumes: 1 token
Example traffic pattern:
- T=0s: 5 requests → ✅ All allowed (burst)
- T=0.1s: 1 request → ❌ Denied (bucket empty)
- T=0.5s: 1 request → ✅ Allowed (bucket refilled 0.5 tokens)
- T=1s: 1 request → ✅ Allowed (bucket has ~1 token)
Cleanup Behavior
The rate limiter automatically cleans up inactive limiters every 5 minutes to prevent memory leaks.
Performance Characteristics
- Memory: ~100 bytes per active limiter
- Throughput: >1M requests/second
- Latency: <1μs per request
- Concurrency: Lock-free for rate checks
Production Deployment
With Reverse Proxy:
// Use X-Forwarded-For or X-Real-IP
keyFunc := func(r *http.Request) string {
// Check proxy headers first
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
return r.RemoteAddr
}
router.Use(rateLimiter.MiddlewareWithKeyFunc(keyFunc))
Environment-based Configuration:
import "os"
func getRateLimiter() *middleware.RateLimiter {
rps := getEnvFloat("RATE_LIMIT_RPS", 100)
burst := getEnvInt("RATE_LIMIT_BURST", 20)
return middleware.NewRateLimiter(rps, burst)
}
Testing Rate Limits
# Send 10 requests rapidly
for i in {1..10}; do
curl -w "Status: %{http_code}\n" http://localhost:8080/api/data
done
Expected output:
Status: 200 # Request 1-5 (within burst)
Status: 200
Status: 200
Status: 200
Status: 200
Status: 429 # Request 6-10 (rate limited)
Status: 429
Status: 429
Status: 429
Status: 429
Complete Example
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"github.com/bitechdev/ResolveSpec/pkg/middleware"
"github.com/gorilla/mux"
)
func main() {
// Configuration from environment
rps, _ := strconv.ParseFloat(os.Getenv("RATE_LIMIT_RPS"), 64)
if rps == 0 {
rps = 100 // Default
}
burst, _ := strconv.Atoi(os.Getenv("RATE_LIMIT_BURST"))
if burst == 0 {
burst = 20 // Default
}
// Create rate limiter
rateLimiter := middleware.NewRateLimiter(rps, burst)
// Custom key extraction
keyFunc := func(r *http.Request) string {
// Try API key first
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
return "api:" + apiKey
}
// Try authenticated user
if userID := r.Header.Get("X-User-ID"); userID != "" {
return "user:" + userID
}
// Fall back to IP
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return ip
}
return r.RemoteAddr
}
// Create router
router := mux.NewRouter()
// Apply rate limiting
router.Use(rateLimiter.MiddlewareWithKeyFunc(keyFunc))
// Routes
router.HandleFunc("/api/data", dataHandler)
router.HandleFunc("/health", healthHandler)
log.Printf("Starting server with rate limit: %.1f rps, burst: %d", rps, burst)
log.Fatal(http.ListenAndServe(":8080", router))
}
func dataHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"message": "Data endpoint",
})
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
Best Practices
-
Set Appropriate Limits: Consider your backend capacity
- Database: Can it handle X queries/second?
- External APIs: What are their rate limits?
- Server resources: CPU, memory, connections
-
Use Burst Wisely: Allow legitimate traffic spikes
- Too low: Reject valid bursts
- Too high: Allow abuse
-
Monitor Rate Limits: Track how often limits are hit
// Log rate limit events if rateLimited { log.Printf("Rate limited: %s", clientKey) } -
Provide Feedback: Include rate limit headers (future enhancement)
X-RateLimit-Limit: 100 X-RateLimit-Remaining: 95 X-RateLimit-Reset: 1640000000 -
Tiered Limits: Different limits for different user tiers
func getRateLimiter(userTier string) *middleware.RateLimiter { switch userTier { case "premium": return middleware.NewRateLimiter(1000, 100) case "standard": return middleware.NewRateLimiter(100, 20) default: return middleware.NewRateLimiter(10, 5) } }
Client Request Queue
ClientQueue smooths bursts (for example a page load that fires ~15 requests at once) by
limiting how many requests each client runs concurrently and queueing the rest first-in-first-out.
Unlike the rate limiter it never rejects a request that can be served shortly.
q := middleware.NewClientQueue(middleware.ClientQueueConfig{
MaxConcurrent: 4, // running at once, per client
MaxQueue: 50, // waiting, per client; beyond this -> 429
MaxWait: 30 * time.Second, // waiting too long -> 503 + Retry-After
})
defer q.Close()
router.Use(q.Middleware) // gorilla/mux; or wrap any http.Handler
Adding it to restheadspec / resolvespec
Both packages' SetupMuxRoutes and SetupBunRouterRoutes take one middleware and apply it to every
route, so pass the queue there. Use Chain to combine it with auth (first is outermost, so requests
are authenticated before they can take a queue slot):
q := middleware.NewClientQueue(middleware.ClientQueueConfig{MaxConcurrent: 4})
defer q.Close()
mw := middleware.Chain(authMiddleware, q.Middleware) // authMiddleware may be nil
restheadspec.SetupMuxRoutes(muxRouter, headHandler, mw)
resolvespec.SetupMuxRoutes(muxRouter, resolveHandler, mw)
// or SetupBunRouterRoutes(router, handler, mw)
Share one ClientQueue across packages to give a client a single limit over all of them.
Client identification
The first of these that is present is used, in order:
X-Client-IdheaderAuthorizationheader- the built-in server session (
security.GetSessionIDfrom the request context, else the session cookie) - the client IP
Nothing is required from the client: without an id it falls back to the session, then to the IP. Secrets are hashed and never stored. Ids longer than 128 characters are ignored.
The session is only in the request context if the security auth middleware has run first, so put
auth before the queue with Chain (below). Without it the cookie is still used.
To get one queue per browser tab (rather than per session), have the client send an id generated once
per tab; the server cannot read sessionStorage itself:
const id = sessionStorage.clientId ??= crypto.randomUUID();
fetch(url, { headers: { "X-Client-Id": id } });
Metrics
Registered on the default Prometheus registry (the same one pkg/metrics and dbmanager use), with
no per-client labels so cardinality stays bounded.
| Metric | Type | Meaning |
|---|---|---|
clientqueue_requests_total{result} |
counter | immediate, queued, rejected_full, timeout, canceled |
clientqueue_wait_seconds |
histogram | wait for a slot; 0 for requests that ran immediately |
clientqueue_wait_max_seconds |
gauge | longest wait since process start |
clientqueue_burst_size |
histogram | peak outstanding requests per client busy period |
clientqueue_burst_max |
gauge | largest burst since process start |
clientqueue_active / clientqueue_queue_depth |
gauge | running / waiting now |
clientqueue_clients |
gauge | clients currently tracked |
A burst is one client's busy period: the peak number of its requests running plus waiting between going from idle to busy and back to idle. A page load firing 15 requests at once is a burst of 15.
# average burst size
rate(clientqueue_burst_size_sum[5m]) / rate(clientqueue_burst_size_count[5m])
# bursts larger than the concurrency limit (need queueing)
1 - (sum(rate(clientqueue_burst_size_bucket{le="10"}[5m])) / sum(rate(clientqueue_burst_size_count[5m])))
# average and p95 wait
rate(clientqueue_wait_seconds_sum[5m]) / rate(clientqueue_wait_seconds_count[5m])
histogram_quantile(0.95, sum(rate(clientqueue_wait_seconds_bucket[5m])) by (le))
# share of requests that had to queue
sum(rate(clientqueue_requests_total{result="queued"}[5m])) / sum(rate(clientqueue_requests_total[5m]))
Use these to pick MaxConcurrent: if the p95 burst is well above it and waits are short, it is doing its
job; if waits grow, the limit is too low or the database is the bottleneck. The _max gauges reset on
restart; use the histograms for anything over time. A client that never goes idle produces one long
busy period, so its burst is only recorded when it finally drains.
Behaviour
- Queued requests are dropped if the client disconnects, so abandoned requests never run.
- CORS preflights (
OPTIONS) and connection upgrades (websockets) bypass the queue. - Idle clients are forgotten after
IdleTimeout(default 5m). - The client id is client-supplied, so a hostile client can rotate ids to get more slots. It smooths
well-behaved clients; it is not an abuse control. Combine it with
RateLimiterfor that. - Queue time counts against your own request timeouts; keep
MaxWaitbelow them.
Request Size Limits
Protect against oversized request bodies with configurable size limits.
Quick Start
import "github.com/bitechdev/ResolveSpec/pkg/middleware"
// Default: 10MB limit
sizeLimiter := middleware.NewRequestSizeLimiter(0)
router.Use(sizeLimiter.Middleware)
Custom Size Limit
// 5MB limit
sizeLimiter := middleware.NewRequestSizeLimiter(5 * 1024 * 1024)
router.Use(sizeLimiter.Middleware)
// Or use constants
sizeLimiter := middleware.NewRequestSizeLimiter(middleware.Size5MB)
Available Size Constants
middleware.Size1MB // 1 MB
middleware.Size5MB // 5 MB
middleware.Size10MB // 10 MB (default)
middleware.Size50MB // 50 MB
middleware.Size100MB // 100 MB
Different Limits Per Route
func main() {
router := mux.NewRouter()
// File upload endpoint: 50MB
uploadLimiter := middleware.NewRequestSizeLimiter(middleware.Size50MB)
uploadRouter := router.PathPrefix("/upload").Subrouter()
uploadRouter.Use(uploadLimiter.Middleware)
// API endpoints: 1MB
apiLimiter := middleware.NewRequestSizeLimiter(middleware.Size1MB)
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.Use(apiLimiter.Middleware)
}
Dynamic Size Limits
// Custom size based on request
sizeFunc := func(r *http.Request) int64 {
// Premium users get 50MB
if isPremiumUser(r) {
return middleware.Size50MB
}
// Free users get 5MB
return middleware.Size5MB
}
router.Use(sizeLimiter.MiddlewareWithCustomSize(sizeFunc))
By Content-Type:
sizeFunc := func(r *http.Request) int64 {
contentType := r.Header.Get("Content-Type")
switch {
case strings.Contains(contentType, "multipart/form-data"):
return middleware.Size50MB // File uploads
case strings.Contains(contentType, "application/json"):
return middleware.Size1MB // JSON APIs
default:
return middleware.Size10MB // Default
}
}
Error Response
When size limit exceeded:
HTTP/1.1 413 Request Entity Too Large
X-Max-Request-Size: 10485760
http: request body too large
Complete Example
package main
import (
"log"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/middleware"
"github.com/gorilla/mux"
)
func main() {
router := mux.NewRouter()
// API routes: 1MB limit
api := router.PathPrefix("/api").Subrouter()
apiLimiter := middleware.NewRequestSizeLimiter(middleware.Size1MB)
api.Use(apiLimiter.Middleware)
api.HandleFunc("/users", createUserHandler).Methods("POST")
// Upload routes: 50MB limit
upload := router.PathPrefix("/upload").Subrouter()
uploadLimiter := middleware.NewRequestSizeLimiter(middleware.Size50MB)
upload.Use(uploadLimiter.Middleware)
upload.HandleFunc("/file", uploadFileHandler).Methods("POST")
log.Fatal(http.ListenAndServe(":8080", router))
}
Input Sanitization
Protect against XSS, injection attacks, and malicious input.
Quick Start
import "github.com/bitechdev/ResolveSpec/pkg/middleware"
// Default sanitizer (safe defaults)
sanitizer := middleware.DefaultSanitizer()
router.Use(sanitizer.Middleware)
Sanitizer Types
Default Sanitizer (Recommended):
sanitizer := middleware.DefaultSanitizer()
// ✓ Escapes HTML entities
// ✓ Removes null bytes
// ✓ Removes control characters
// ✓ Blocks XSS patterns (script tags, event handlers)
// ✗ Does not strip HTML (allows legitimate content)
Strict Sanitizer:
sanitizer := middleware.StrictSanitizer()
// ✓ All default features
// ✓ Strips ALL HTML tags
// ✓ Max string length: 10,000 chars
Custom Configuration
sanitizer := &middleware.Sanitizer{
StripHTML: true, // Remove HTML tags
EscapeHTML: false, // Don't escape (already stripped)
RemoveNullBytes: true, // Remove \x00
RemoveControlChars: true, // Remove dangerous control chars
MaxStringLength: 5000, // Limit to 5000 chars
// Block patterns (regex)
BlockPatterns: []*regexp.Regexp{
regexp.MustCompile(`(?i)<script`),
regexp.MustCompile(`(?i)javascript:`),
},
// Custom sanitization function
CustomSanitizer: func(s string) string {
// Your custom logic
return strings.ToLower(s)
},
}
router.Use(sanitizer.Middleware)
What Gets Sanitized
Automatic (via middleware):
- Query parameters
- Headers (User-Agent, Referer, X-Forwarded-For, X-Real-IP)
Manual (in your handler):
- Request body (JSON, form data)
- Database queries
- File names
Manual Sanitization
String Values:
sanitizer := middleware.DefaultSanitizer()
// Sanitize user input
username := sanitizer.Sanitize(r.FormValue("username"))
email := sanitizer.Sanitize(r.FormValue("email"))
Map/JSON Data:
var data map[string]interface{}
json.Unmarshal(body, &data)
// Sanitize all string values recursively
sanitizedData := sanitizer.SanitizeMap(data)
Nested Structures:
type User struct {
Name string
Email string
Bio string
Profile map[string]interface{}
}
// After unmarshaling
user.Name = sanitizer.Sanitize(user.Name)
user.Email = sanitizer.Sanitize(user.Email)
user.Bio = sanitizer.Sanitize(user.Bio)
user.Profile = sanitizer.SanitizeMap(user.Profile)
Specialized Sanitizers
Filenames:
import "github.com/bitechdev/ResolveSpec/pkg/middleware"
filename := middleware.SanitizeFilename(uploadedFilename)
// Removes: .., /, \, null bytes
// Limits: 255 characters
Emails:
email := middleware.SanitizeEmail(" USER@EXAMPLE.COM ")
// Result: "user@example.com"
// Trims, lowercases, removes null bytes
URLs:
url := middleware.SanitizeURL(userInput)
// Blocks: javascript:, data: protocols
// Removes: null bytes
Blocked Patterns (Default)
The default sanitizer blocks:
- Script tags:
<script>...</script> - JavaScript protocol:
javascript:alert(1) - Event handlers:
onclick="...",onerror="..." - Iframes:
<iframe src="..."> - Objects:
<object data="..."> - Embeds:
<embed src="...">
Security Best Practices
1. Layer Defense:
// Layer 1: Middleware (query params, headers)
router.Use(sanitizer.Middleware)
// Layer 2: Input validation (in handler)
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var user User
json.NewDecoder(r.Body).Decode(&user)
// Sanitize
user.Name = sanitizer.Sanitize(user.Name)
user.Email = middleware.SanitizeEmail(user.Email)
// Validate
if !isValidEmail(user.Email) {
http.Error(w, "Invalid email", 400)
return
}
// Use parameterized queries (prevents SQL injection)
db.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
user.Name, user.Email)
}
2. Context-Aware Sanitization:
// HTML content (user posts, comments)
sanitizer := middleware.StrictSanitizer()
post.Content = sanitizer.Sanitize(post.Content)
// Structured data (JSON API)
sanitizer := middleware.DefaultSanitizer()
data = sanitizer.SanitizeMap(jsonData)
// Search queries (preserve special chars)
query = middleware.SanitizeFilename(searchTerm) // Light sanitization
3. Output Encoding:
// When rendering HTML
import "html/template"
tmpl := template.Must(template.New("page").Parse(`
<h1>{{.Title}}</h1>
<p>{{.Content}}</p>
`))
// template.HTML automatically escapes
tmpl.Execute(w, data)
Complete Example
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/bitechdev/ResolveSpec/pkg/middleware"
"github.com/gorilla/mux"
)
func main() {
router := mux.NewRouter()
// Apply sanitization middleware
sanitizer := middleware.DefaultSanitizer()
router.Use(sanitizer.Middleware)
router.HandleFunc("/api/users", createUserHandler).Methods("POST")
log.Fatal(http.ListenAndServe(":8080", router))
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
sanitizer := middleware.DefaultSanitizer()
var user struct {
Name string `json:"name"`
Email string `json:"email"`
Bio string `json:"bio"`
}
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "Invalid JSON", 400)
return
}
// Sanitize inputs
user.Name = sanitizer.Sanitize(user.Name)
user.Email = middleware.SanitizeEmail(user.Email)
user.Bio = sanitizer.Sanitize(user.Bio)
// Validate
if len(user.Name) == 0 || len(user.Email) == 0 {
http.Error(w, "Name and email required", 400)
return
}
// Save to database (use parameterized queries!)
// db.Exec("INSERT INTO users (name, email, bio) VALUES (?, ?, ?)",
// user.Name, user.Email, user.Bio)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"status": "created",
})
}
Testing Sanitization
# Test XSS prevention
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"name": "<script>alert(1)</script>John",
"email": "test@example.com",
"bio": "My bio with <iframe src=\"evil.com\"></iframe>"
}'
# Script tags and iframes should be removed
Performance
- Overhead: <1ms per request for typical payloads
- Regex compilation: Done once at initialization
- Safe for production: Minimal performance impact