Rate Limit Plugin¶
Namespace: \Kanopi\Firewall\Plugins\RateLimit
Implements rate limiting to prevent abuse and DDoS attacks.
Configuration Example¶
plugins:
- plugin: "Kanopi\\Firewall\\Plugins\\RateLimit"
response: block
weight: 100 # Run after other plugins
enable: true
metadata:
# Fallback for a rule that omits its own rate or sample, and for
# paths with no rule at all -- see "Paths with no rule of their own".
default_rate: 60 # Requests allowed
default_sample: 60 # Time window in seconds
default_expiration_time: 300 # Block duration in seconds
# Set false to limit only the paths listed under config:
# limit_unlisted_paths: false
# Storage backend for rate limit data
storage:
# Option 1: Redis (recommended for production)
type: "Kanopi\\Firewall\\RateLimitStorage\\RedisRateLimitStorage"
config:
redis:
host: localhost
port: 6379
# Authentication options:
# auth: "password"
# auth: ["password"]
# auth: ["username", "password"]
# Option 2: File storage
# type: "Kanopi\\Firewall\\RateLimitStorage\\FileRateLimitStorage"
# config:
# file: /var/log/firewall/ratelimit.data
# Option 3: Database storage
# type: "Kanopi\\Firewall\\RateLimitStorage\\DatabaseRateLimitStorage"
# config:
# storage-table: firewall_ratelimit
# connection:
# dsn: "mysql://user:pass@localhost/db"
# Option 4: PSR-6 cache pool
# type: "Kanopi\\Firewall\\RateLimitStorage\\CacheRateLimitStorage"
# config:
# # Class implementing Psr\Cache\CacheItemPoolInterface
# adaptor: "Symfony\\Component\\Cache\\Adapter\\FilesystemAdapter"
# # Constructor arguments, spread in order
# args: ['firewall', 0, '/var/cache/firewall']
# ttl: 3600
# Option 5: In-memory (testing only)
# type: "Kanopi\\Firewall\\RateLimitStorage\\InMemoryRateLimitStorage"
config:
# Strict rate limit for homepage
- path: "/"
rate: 10
sample: 60
# API endpoints with higher limits
- path: "/api/*"
rate: 100
sample: 60
# Admin area with moderate limits
- path: "/admin/*"
rate: 30
sample: 60
# Login endpoint with strict limits
- path: "/login"
rate: 5
sample: 300 # 5 attempts per 5 minutes
# Use regex for complex patterns
- path: '/\.(php|asp|aspx)$/i'
rate: 1
sample: 3600 # Block direct script access
What a limit counts by¶
By default, the client IP and the rule's own pattern — so /api/* at 100/min is 100 requests across every endpoint under it, from one address. key: changes that:
config:
- path: /login
rate: 5
sample: 300
key: [post.name] # per account, across every address
- path: /api/*
rate: 100
sample: 60
key: [client_ip, path] # per endpoint, not per API
| Component | |
|---|---|
client_ip | The client address. The default identity |
rule_pattern | The rule's path value — /api/* |
path | The request path — /api/users |
method, host, query, scheme, port | As the URL plugin reads them |
header.x, post.x, cookie.x, query.x | Same vocabulary, same nesting |
metadata.default_key sets it for every rule that declares none. A rule's own key: wins.
Why the default is often wrong¶
- Credential stuffing spreads across addresses. Ten thousand IPs at three attempts each stays under a 10-per-5-minutes rule on every bucket, and the account is gone.
key: [post.name]counts the account instead, and the attack shows up in one bucket. - Carrier and corporate NAT share one. An office, school or mobile network is one address, so a limit tuned for one person throttles a thousand.
- API keys from rotating egress are not countable at all by IP.
key: [header.x-api-key]makes them countable.
Counting by an account can lock that account out
key: [post.name] counts every attempt against the named account, from anywhere — which is the point, and also means anyone can spend a victim's budget for them. An attacker who makes five failed logins as alice locks alice out for the rest of the window.
That is the classic account-lockout trade, and it is real: choose it when stopping credential stuffing matters more than an attacker being able to deny one account for five minutes.
Add an account key — do not swap the address one out for it
An account key gives every account its own budget, so one address walking a username list is never limited by it. Each name is a fresh bucket, and since a non-address key does not ban an address, that address is never stopped at all:
5 addresses against "victim" (rate 3) → 3 through, then refused
one of them switches to alice/bob/carol → all succeed
is that address banned? → no
The two keys catch opposite attacks — many addresses against one account, and one address against many accounts — so replacing the address-keyed rule with an account-keyed one removes brute-force protection while looking like it tightens it. Run both:
config:
- path: /login
rate: 5
sample: 300
key: [post.name] # the account, from anywhere
- path: /login
rate: 50
sample: 300 # and the address, much looser
firewall-check --lint warns when a path has an identity-keyed rule and no address-keyed one.
A non-address key does not ban an address
The durable block list is keyed on the client IP. A rule counting by anything else therefore refuses the request but does not write an IP ban, and that is the default.
Otherwise an attacker could exhaust a victim's account budget from their own machines, and the victim's next login — from their own address — would trip the limit and put that address on the block list, where it is refused for everything and lengthened by blocking_escalation each time. Remote, unauthenticated, against arbitrary users.
metadata.record: true opts back in, for a deployment where the counted identity and the address are the same thing.
A composed key is stored hashed
A key can name post.password or header.authorization, and a rate limit is not a reason for a credential to be written to Redis, a database, or a file on disk. Declare a key: and the stored key becomes an opaque hash.
The default key is stored in the clear exactly as it always was, so upgrading resets nobody's counters. Only rules that opt in change shape.
A component that resolves to nothing — a header that was not sent — still occupies its position, so a request missing the field does not share a bucket with one whose field is genuinely empty.
Paths with no rule of their own¶
Every path is rate limited by default, not only the ones listed under config:. A request matching no rule falls through to a catch-all built from default_rate and default_sample.
That is worth stating plainly, because it has two consequences:
- Adding a rule to protect
/user/loginalso brings a site-wide cap on every other URL, at whateverdefault_ratesays. - Every request to the site performs a read-modify-write of the counter store, including requests the operator never intended to limit.
To limit only what you listed:
metadata:
default_rate: 60
default_sample: 60
limit_unlisted_paths: false
config:
- path: /user/login
rate: 5
sample: 300
An unlisted path is then not counted and not recorded — it never reaches the counter store at all.
default_rate still applies to a listed rule that omits its own rate, which is why this is a separate key rather than a special value for default_rate. A rate that meant "switch the catch-all off" would silently unlimit those rules too.
default_rate: 0 does not mean unlimited
The limit check is count >= rate, and a count is never negative, so a rate of 0 is satisfied by no request at all — including the first. Before v2.19.2 that refused every request on the site.
Since v2.19.2 a rate below 1 is treated as unenforceable and logged at construction, so it no longer takes a site down. Use limit_unlisted_paths: false to express the intent properly.
Path Patterns¶
- Exact match:
/login - Wildcard:
/api/*(matches /api/users, /api/posts/123, etc.) - Regex:
/^\/api\/v[0-9]+\//(matches /api/v1/, /api/v2/, etc.)