Frontend Labs
⋯

Day 93 — Per-IP Rate Limiting with CIDR Awareness

Learning objective Rate limit at both /32 (single IP) and /24 (subnet) granularity; handle IPv6
Key packages net, sync, net/http, strings
Security context DDoS attackers control IP ranges (ASNs), not just single IPs

Key Concepts

  • X-Forwarded-For trust: Only trust XFF[len-1] (last hop, set by your trusted L4 proxy). Never trust the full chain from untrusted clients.
  • IPv6 /48 grouping: IPv6 bots spread across a /48. Group by first 48 bits.
  • Counter expiry: Un-evicted stale counters grow memory unbounded. Background goroutine cleans up.

Exercise — CIDR-Aware Rate Limiter

package ddos

import (
    "fmt"
    "net"
    "net/http"
    "strings"
    "sync"
    "time"
)

type counterEntry struct {
    bucket  *TokenBucket
    lastSee time.Time
}

type CIDRRateLimiter struct {
    mu      sync.RWMutex
    ipLimit  int     // per /32 or /128
    subLimit int     // per /24 or /48
    rate     float64 // tokens/second
    entries  map[string]*counterEntry
    done     chan struct{}
}

func NewCIDRRateLimiter(ipLimit, subLimit int, rate float64) *CIDRRateLimiter {
    rl := &CIDRRateLimiter{
        ipLimit:  ipLimit,
        subLimit: subLimit,
        rate:     rate,
        entries:  make(map[string]*counterEntry),
        done:     make(chan struct{}),
    }
    go rl.cleanup()
    return rl
}

// realIP extracts the real client IP, trusting only one XFF hop
func realIP(r *http.Request) string {
    xff := r.Header.Get("X-Forwarded-For")
    if xff == "" {
        host, _, _ := net.SplitHostPort(r.RemoteAddr)
        return host
    }
    // Trust only the rightmost entry added by our L4 proxy
    parts := strings.Split(xff, ",")
    ip := strings.TrimSpace(parts[len(parts)-1])
    if net.ParseIP(ip) == nil {
        host, _, _ := net.SplitHostPort(r.RemoteAddr)
        return host
    }
    return ip
}

// subnetKey returns the /24 (IPv4) or /48 (IPv6) key
func subnetKey(ipStr string) string {
    ip := net.ParseIP(ipStr)
    if ip == nil {
        return ipStr
    }
    if ip4 := ip.To4(); ip4 != nil {
        // IPv4: mask to /24
        return fmt.Sprintf("%d.%d.%d.0/24", ip4[0], ip4[1], ip4[2])
    }
    // IPv6: mask to /48 (first 6 bytes)
    return fmt.Sprintf("%02x%02x:%02x%02x:%02x%02x::/48",
        ip[0], ip[1], ip[2], ip[3], ip[4], ip[5])
}

func (rl *CIDRRateLimiter) getBucket(key string, limit int) *TokenBucket {
    rl.mu.Lock()
    defer rl.mu.Unlock()
    e, ok := rl.entries[key]
    if !ok {
        e = &counterEntry{bucket: NewTokenBucket(float64(limit), rl.rate)}
        rl.entries[key] = e
    }
    e.lastSee = time.Now()
    return e.bucket
}

func (rl *CIDRRateLimiter) Allow(r *http.Request) bool {
    ip := realIP(r)
    subnet := subnetKey(ip)

    // Both per-IP and per-subnet must allow
    if !rl.getBucket(ip, rl.ipLimit).Allow() {
        return false
    }
    if !rl.getBucket(subnet, rl.subLimit).Allow() {
        return false
    }
    return true
}

func (rl *CIDRRateLimiter) cleanup() {
    ticker := time.NewTicker(5 * time.Minute)
    defer ticker.Stop()
    for {
        select {
        case <-rl.done:
            return
        case <-ticker.C:
            cutoff := time.Now().Add(-10 * time.Minute)
            rl.mu.Lock()
            for k, e := range rl.entries {
                if e.lastSee.Before(cutoff) {
                    delete(rl.entries, k)
                }
            }
            rl.mu.Unlock()
        }
    }
}

func (rl *CIDRRateLimiter) Stop() { close(rl.done) }

func (rl *CIDRRateLimiter) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !rl.Allow(r) {
            w.Header().Set("Retry-After", "1")
            http.Error(w, `{"error":"rate_limit_exceeded"}`, http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Generated from week13.md · 8 Oct 2026 16:36