Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 `<name>` 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`.
Expand Down Expand Up @@ -143,9 +147,11 @@ rate_limit {
match {
<matchers>
}
key <string>
window <duration>
events <max_events>
key <string>
window <duration>
events <max_events>
ipv4_prefix <bits>
ipv6_prefix <bits>
}
distributed {
read_interval <duration>
Expand Down Expand Up @@ -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
}
}
```
34 changes: 31 additions & 3 deletions caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ func parseCaddyfile(helper httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, e
//
// rate_limit {
// zone <name> {
// key <string>
// window <duration>
// events <max_events>
// key <string>
// window <duration>
// events <max_events>
// ipv4_prefix <bits>
// ipv6_prefix <bits>
// match {
// <matchers>
// }
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
21 changes: 21 additions & 0 deletions ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down