diff --git a/README.md b/README.md index 6b34da4..75a81ef 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,9 @@ This is an HTTP handler module, so it can be used wherever `http.handlers` modul "match": [], "key": "", "window": "", - "max_events": 0 + "max_events": 0, + "ipv4_prefix": 0, + "ipv6_prefix": 0 } }, "jitter": 0.0, @@ -96,6 +98,8 @@ This is an HTTP handler module, so it can be used wherever `http.handlers` modul All fields are optional, but to be useful, you'll need to define at least one zone, and a zone requires `window` and `max_events` to be set. Keys can be static (no placeholders) or dynamic (with placeholders). Matchers can be used to filter requests that apply to a zone. Replace `` with your RL zone's name. +The `ipv4_prefix` and `ipv6_prefix` fields allow grouping rate limit keys by network subnet when the key resolves to an IP address. For example, setting `ipv6_prefix` to `64` will mask all IPv6 addresses to their `/64` network prefix, so all addresses within the same `/64` share a single rate limit bucket. This is useful for preventing abuse from clients cycling through many addresses within an IPv6 prefix. Each address family is configured independently; when a prefix is not set (or `0`), addresses of that family are treated individually as usual. + To enable distributed RL, set `distributed` to a non-null object. The default read and write intervals are 5s, but you should tune these for your individual deployments. To log the key when a rate limit is hit, set `log_key` to `true`. @@ -143,9 +147,11 @@ rate_limit { match { } - key - window - events + key + window + events + ipv4_prefix + ipv6_prefix } distributed { read_interval @@ -245,3 +251,38 @@ rate_limit { respond "I'm behind the rate limiter!" ``` + +### Network prefix rate limiting + +When rate limiting by client IP, an attacker with access to an IPv6 prefix (commonly a `/64`) can cycle through many addresses to bypass per-IP rate limits. The `ipv6_prefix` option solves this by grouping all addresses within the same network into a single rate limit bucket. + +In this example, all IPv6 clients within the same `/64` network share one rate limit bucket, while each IPv4 client is rate limited individually: + +#### JSON + +```json +{ + "handler": "rate_limit", + "rate_limits": { + "per_network": { + "key": "{http.request.remote.host}", + "window": "1m", + "max_events": 100, + "ipv6_prefix": 64 + } + } +} +``` + +#### Caddyfile + +``` +rate_limit { + zone per_network { + key {remote_host} + events 100 + window 1m + ipv6_prefix 64 + } +} +``` diff --git a/caddyfile.go b/caddyfile.go index ab927bd..958e718 100644 --- a/caddyfile.go +++ b/caddyfile.go @@ -40,9 +40,11 @@ func parseCaddyfile(helper httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, e // // rate_limit { // zone { -// key -// window -// events +// key +// window +// events +// ipv4_prefix +// ipv6_prefix // match { // // } @@ -109,6 +111,32 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } zone.MaxEvents = maxEvents + case "ipv4_prefix": + if !d.NextArg() { + return d.ArgErr() + } + if zone.IPv4Prefix != 0 { + return d.Errf("zone ipv4_prefix already specified: %v", zone.IPv4Prefix) + } + prefix, err := strconv.Atoi(d.Val()) + if err != nil { + return d.Errf("invalid ipv4_prefix integer '%s': %v", d.Val(), err) + } + zone.IPv4Prefix = prefix + + case "ipv6_prefix": + if !d.NextArg() { + return d.ArgErr() + } + if zone.IPv6Prefix != 0 { + return d.Errf("zone ipv6_prefix already specified: %v", zone.IPv6Prefix) + } + prefix, err := strconv.Atoi(d.Val()) + if err != nil { + return d.Errf("invalid ipv6_prefix integer '%s': %v", d.Val(), err) + } + zone.IPv6Prefix = prefix + case "match": matcherSet, err := caddyhttp.ParseCaddyfileNestedMatcherSet(d) if err != nil { diff --git a/handler.go b/handler.go index f64d07d..c4ba5d0 100644 --- a/handler.go +++ b/handler.go @@ -179,6 +179,7 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhtt // make key for the individual rate limiter in this zone key := repl.ReplaceAll(rl.Key, "") + key = applyNetworkPrefix(key, rl.IPv4Prefix, rl.IPv6Prefix) limiter := rl.limitersMap.getOrInsert(key, rl.MaxEvents, time.Duration(rl.Window)) if h.Distributed == nil { @@ -197,6 +198,40 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhtt return next.ServeHTTP(w, r) } +// applyNetworkPrefix masks an IP address key to a network prefix if the +// corresponding prefix length is configured. If the key is not a valid IP +// address, or the prefix length for that IP version is 0 (unconfigured), +// the key is returned unchanged. +func applyNetworkPrefix(key string, ipv4Prefix, ipv6Prefix int) string { + if ipv4Prefix == 0 && ipv6Prefix == 0 { + return key + } + + ip := net.ParseIP(key) + if ip == nil { + return key + } + + var prefixLen, maxBits int + if ip.To4() != nil { + if ipv4Prefix == 0 { + return key + } + prefixLen = ipv4Prefix + maxBits = 32 + } else { + if ipv6Prefix == 0 { + return key + } + prefixLen = ipv6Prefix + maxBits = 128 + } + + mask := net.CIDRMask(prefixLen, maxBits) + network := net.IPNet{IP: ip.Mask(mask), Mask: mask} + return network.String() +} + func (h *Handler) rateLimitExceeded(w http.ResponseWriter, r *http.Request, repl *caddy.Replacer, zoneName string, key string, wait time.Duration) error { // add jitter, if configured if h.random != nil { diff --git a/handler_test.go b/handler_test.go index 2a9ce00..9408c21 100644 --- a/handler_test.go +++ b/handler_test.go @@ -23,6 +23,59 @@ import ( "github.com/caddyserver/caddy/v2/caddytest" ) +func TestApplyNetworkPrefix(t *testing.T) { + tests := []struct { + name string + key string + ipv4Prefix int + ipv6Prefix int + expected string + }{ + // IPv6 with /64 prefix - different addresses in the same /64 produce the same key + {"ipv6 /64 full addr", "2001:db8:1234:5678:9abc:def0:1234:5678", 0, 64, "2001:db8:1234:5678::/64"}, + {"ipv6 /64 all ones host", "2001:db8:1234:5678:ffff:ffff:ffff:ffff", 0, 64, "2001:db8:1234:5678::/64"}, + {"ipv6 /64 short addr", "2001:db8:1234:5678::1", 0, 64, "2001:db8:1234:5678::/64"}, + + // IPv6 with other prefix lengths + {"ipv6 /48", "2001:db8:1234:5678::1", 0, 48, "2001:db8:1234::/48"}, + {"ipv6 /128", "2001:db8:1234:5678::1", 0, 128, "2001:db8:1234:5678::1/128"}, + + // IPv4 with prefix configured + {"ipv4 /24", "192.168.1.100", 24, 0, "192.168.1.0/24"}, + {"ipv4 /8", "10.0.0.50", 8, 0, "10.0.0.0/8"}, + {"ipv4 /32", "172.16.5.4", 32, 0, "172.16.5.4/32"}, + + // Both prefixes configured - each applies to its own address family + {"ipv6 with both configured", "2001:db8::1", 24, 64, "2001:db8::/64"}, + {"ipv4 with both configured", "192.168.1.100", 24, 64, "192.168.1.0/24"}, + + // Only IPv6 prefix configured - IPv4 addresses pass through unchanged + {"ipv4 unchanged when only ipv6 configured", "192.168.1.100", 0, 64, "192.168.1.100"}, + + // Only IPv4 prefix configured - IPv6 addresses pass through unchanged + {"ipv6 unchanged when only ipv4 configured", "2001:db8::1", 24, 0, "2001:db8::1"}, + + // No prefixes configured - everything passes through unchanged + {"ipv6 no prefix", "2001:db8::1", 0, 0, "2001:db8::1"}, + {"ipv4 no prefix", "192.168.1.100", 0, 0, "192.168.1.100"}, + + // Non-IP keys always pass through unchanged + {"static key", "static", 0, 64, "static"}, + {"placeholder key", "{http.request.uri}", 0, 64, "{http.request.uri}"}, + {"empty key", "", 0, 64, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := applyNetworkPrefix(tt.key, tt.ipv4Prefix, tt.ipv6Prefix) + if result != tt.expected { + t.Errorf("applyNetworkPrefix(%q, %d, %d) = %q, want %q", + tt.key, tt.ipv4Prefix, tt.ipv6Prefix, result, tt.expected) + } + }) + } +} + const referenceTime = 1000000 func initTime() { diff --git a/ratelimit.go b/ratelimit.go index ca9a24d..819fd7b 100644 --- a/ratelimit.go +++ b/ratelimit.go @@ -42,6 +42,21 @@ type RateLimit struct { // Duration of the sliding window. Window caddy.Duration `json:"window,omitempty"` + // IPv4Prefix is the number of bits in the subnet mask to apply to IPv4 + // addresses when grouping rate limit keys. For example, a value of 24 + // will group all addresses in the same /24 subnet into one rate limit + // bucket. Default (0) means no grouping — each IPv4 address is treated + // individually. + IPv4Prefix int `json:"ipv4_prefix,omitempty"` + + // IPv6Prefix is the number of bits in the subnet mask to apply to IPv6 + // addresses when grouping rate limit keys. For example, a value of 64 + // will group all addresses in the same /64 network into one rate limit + // bucket, preventing abuse from clients cycling through addresses within + // a prefix. Default (0) means no grouping — each IPv6 address is treated + // individually. + IPv6Prefix int `json:"ipv6_prefix,omitempty"` + matcherSets caddyhttp.MatcherSets zoneName string @@ -56,6 +71,12 @@ func (rl *RateLimit) provision(ctx caddy.Context, name string) error { if rl.MaxEvents < 0 { return fmt.Errorf("max_events must be at least zero") } + if rl.IPv4Prefix < 0 || rl.IPv4Prefix > 32 { + return fmt.Errorf("ipv4_prefix must be between 0 and 32") + } + if rl.IPv6Prefix < 0 || rl.IPv6Prefix > 128 { + return fmt.Errorf("ipv6_prefix must be between 0 and 128") + } if len(rl.MatcherSetsRaw) > 0 { matcherSets, err := ctx.LoadModule(rl, "MatcherSetsRaw")