diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 53401b9..00bb319 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -25,8 +25,8 @@ export default defineConfig({ link: 'https://flowd.de' }, { - text: 'v0.9.0', - link: 'https://github.com/flowd/phirewall/releases/tag/0.9.0' + text: 'v0.10.0', + link: 'https://github.com/flowd/phirewall/releases/tag/0.10.0' } ], diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index fc558f0..af14f34 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -98,7 +98,7 @@ For each Fail2Ban rule: 1. Checks if the key is already banned - if so, dispatches `Fail2BanBlocked` and returns a blocked result immediately 2. If the filter matches, increments the failure counter and blocks the request (`403`). A match below the threshold sets `DecisionPath::Fail2BanMatched` and dispatches `Fail2BanMatched`; the Nth match additionally bans the key, sets `DecisionPath::Fail2BanBanned`, and dispatches `Fail2BanBanned` (never both events) -The pre-handler path (during `decide()`) blocks on every match and bans at the threshold. The post-handler path (via `processRecordedSignal()`) shares the same `count >= threshold` ban comparison but never blocks the current request and never dispatches `Fail2BanMatched`. The ban fires on the Nth match, consistent with Allow2Ban. +The pre-handler path (during `decide()`) blocks on every match and bans at the threshold. The post-handler path (via `processRecordedSignal()`) shares the same `count >= threshold` ban comparison but never dispatches `Fail2BanMatched` and by default never blocks the current request; with `Config::enableBlockOnSignalBan()` the middleware replaces the handler response with the blocked response when the signal imposed the ban. The ban fires on the Nth match, consistent with Allow2Ban. See [Request Context](/advanced/request-context) for post-handler failure signaling. diff --git a/docs/advanced/dynamic-throttle.md b/docs/advanced/dynamic-throttle.md index 88632e1..5c5f974 100644 --- a/docs/advanced/dynamic-throttle.md +++ b/docs/advanced/dynamic-throttle.md @@ -228,43 +228,35 @@ $config->throttles->add('api', ### Approach 2: Separate Rules per Tier -Create separate rules and use the key closure returning `null` to skip: +Create separate rules, each scoped to its tier: ```php -use Flowd\Phirewall\Config\ClosureRequestMatcher; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Flowd\Phirewall\Http\TrustedProxyResolver; -// Make null-key rules proxy-aware (used by the anonymous fallback below). +// Make keyless rules proxy-aware (used by the anonymous fallback below). $config->setIpResolver((new TrustedProxyResolver(['10.0.0.0/8', '172.16.0.0/12']))->resolve(...)); -// Free tier: 100 requests/minute +// Free tier: 100 requests/minute per user $config->throttles->add('free-tier', limit: 100, period: 60, - key: function ($request): ?string { - if ($request->getAttribute('plan') !== 'free') return null; - return $request->getAttribute('userId'); - }, + scope: fn($request): bool => $request->getAttribute('plan') === 'free', + key: fn($request): ?string => $request->getAttribute('userId'), ); -// Pro tier: 1000 requests/minute +// Pro tier: 1000 requests/minute per user $config->throttles->add('pro-tier', limit: 1000, period: 60, - key: function ($request): ?string { - if ($request->getAttribute('plan') !== 'pro') return null; - return $request->getAttribute('userId'); - }, + scope: fn($request): bool => $request->getAttribute('plan') === 'pro', + key: fn($request): ?string => $request->getAttribute('userId'), ); -// Anonymous fallback: 50 requests/minute per client IP (requests without a userId). -// Null key defaults to the resolved client IP; the scope selects anonymous requests. -$config->throttles->addRule(new ThrottleRule( - 'anonymous', +// Anonymous fallback: 50 requests/minute per client IP (requests without a +// userId). The keyless rule counts per resolved client IP. +$config->throttles->add('anonymous', limit: 50, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher(fn($request): bool => $request->getAttribute('userId') === null), -)); + scope: fn($request): bool => $request->getAttribute('userId') === null, +); ``` ::: tip Read tier and identity from request attributes, not headers @@ -276,64 +268,48 @@ $config->throttles->addRule(new ThrottleRule( Assign different limits to endpoints based on their resource cost: ```php -use Flowd\Phirewall\Config\ClosureRequestMatcher; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Flowd\Phirewall\Http\TrustedProxyResolver; // Resolve the real client IP behind a proxy. Setting it on the Config makes the -// null-key rules below proxy-aware; the userId-keyed export rule reuses the same +// keyless rules below proxy-aware; the userId-keyed export rule reuses the same // resolver for its IP fallback. $proxyResolver = new TrustedProxyResolver(['10.0.0.0/8', '172.16.0.0/12']); $config->setIpResolver($proxyResolver->resolve(...)); // Cheap read operations: 1000 req/min (GET requests, keyed on the client IP) -$config->throttles->addRule(new ThrottleRule( - 'read-operations', +$config->throttles->add('read-operations', limit: 1000, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher(fn($request): bool => $request->getMethod() === 'GET'), -)); + scope: fn($request): bool => $request->getMethod() === 'GET', +); // Moderate write operations: 100 req/min -$config->throttles->addRule(new ThrottleRule( - 'write-operations', +$config->throttles->add('write-operations', limit: 100, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher( - fn($request): bool => in_array($request->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true) - ), -)); + scope: fn($request): bool => in_array($request->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true), +); // Expensive export endpoints: 10 req/hour, keyed per user with a client-IP fallback $config->throttles->add('export-endpoints', limit: 10, period: 3600, - key: function ($request) use ($proxyResolver): ?string { - if (!str_starts_with($request->getUri()->getPath(), '/api/export')) return null; - return $request->getAttribute('userId') ?? $proxyResolver->resolve($request); - }, + scope: fn($request): bool => str_starts_with($request->getUri()->getPath(), '/api/export'), + key: fn($request): ?string => $request->getAttribute('userId') ?? $proxyResolver->resolve($request), ); ``` ## Conditional Bypass -Skip rate limiting for certain scenarios with a scope filter; the rule is skipped for any request the filter does not match. The key stays `null`, so matched requests are counted against the resolved client IP: +Skip rate limiting for certain scenarios with a scope filter; the rule is skipped for any request the filter does not match. The key stays omitted, so matched requests are counted against the resolved client IP: ```php -use Flowd\Phirewall\Config\ClosureRequestMatcher; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Flowd\Phirewall\Http\TrustedProxyResolver; -// Make the null-key rule proxy-aware. +// Make the keyless rule proxy-aware. $config->setIpResolver((new TrustedProxyResolver(['10.0.0.0/8', '172.16.0.0/12']))->resolve(...)); -$config->throttles->addRule(new ThrottleRule( - 'api-limit', - limit: 100, - period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher(function ($request): bool { +$config->throttles->add('api-limit', limit: 100, period: 60, + scope: function ($request): bool { // Skip requests reaching us directly from the internal network // (checked against the raw peer, not the resolved client). $peer = $request->getServerParams()['REMOTE_ADDR'] ?? ''; @@ -344,10 +320,12 @@ $config->throttles->addRule(new ThrottleRule( if (str_starts_with($request->getUri()->getPath(), '/webhooks/')) return false; return true; - }), -)); + }, +); ``` +For a matcher-backed scope (an `IpMatcher` instance, a preset filter), construct a `Flowd\Phirewall\Config\Rule\ThrottleRule` directly and register it via `$config->throttles->addRule(new ThrottleRule(..., scope: $matcher))`. + ::: tip For trusted traffic that should bypass **all** rules (not only throttles), use [safelists](/features/safelists-blocklists) instead. Safelisted requests skip the entire firewall pipeline, including blocklists, fail2ban, and track rules. ::: @@ -408,7 +386,7 @@ $firewall->resetAll(); 1. **Use descriptive rule names.** Names appear in `ThrottleExceeded` events and, when `enableResponseHeaders()` is active, in the `X-Phirewall-Matched` response header. (The `X-RateLimit-*` headers carry only numeric limit/remaining/reset values, not the rule name.) Use `api-free-tier` instead of `rule1`. -2. **Return `null` to skip.** This is the primary mechanism for conditional rate limiting. When a key closure returns `null`, the rule is skipped with zero overhead. +2. **Scope conditional throttles.** Pass `scope:` to restrict which requests count and leave the key omitted, so matching requests count per resolved client IP. A key closure that returns `null` also skips the rule; use that form only when the rule keys on something other than the client IP (a header, a username, and so on). 3. **Pre-load external data.** Never query databases or external services inside key or limit closures. Load data at configuration time. diff --git a/docs/advanced/request-context.md b/docs/advanced/request-context.md index ae68c8c..b982e64 100644 --- a/docs/advanced/request-context.md +++ b/docs/advanced/request-context.md @@ -46,6 +46,12 @@ Here is what happens step by step: 5. After your handler returns a response, the middleware processes each recorded signal through the matching counter engine (fail2ban or allow2ban) 6. If the count crosses the threshold, the key is banned for future requests +By default the banning signal never changes the current response: the handler's response is delivered as-is and the ban takes effect from the next request. Opt in to a 403 for the banning request itself with `$config->enableBlockOnSignalBan()` (portable option `blockOnSignalBan`) - the middleware then replaces the handler response with the regular blocked response, including `Retry-After` for allow2ban bans. + +::: warning The 403 does not stop the handler +Signals are processed after the handler returns, so the 403 only changes what the client sees. The application has already fully processed the possibly malicious request: database writes, e-mails, and other side effects have happened by the time the response is replaced. When processing must stop as soon as the failure is known, that decision belongs in the handler itself - record the signal and abort your own processing there (for example, return your error response right after `recordFailure()` instead of continuing). +::: + ## Setup Configure a fail2ban rule with a filter that **always returns `false`**. This means the firewall never counts failures automatically; your handler does it instead: diff --git a/docs/common-attacks.md b/docs/common-attacks.md index ded4b40..b5e03f4 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -45,21 +45,15 @@ Only genuine failures are counted, so a user who logs in correctly on the first Add a rate limit specifically on the login path to slow down attackers: ```php -use Flowd\Phirewall\Config\ClosureRequestMatcher; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Psr\Http\Message\ServerRequestInterface; -// A null key defaults to the resolved client IP (proxy-aware via the Config's -// IP resolver); the scope filter restricts the throttle to the login path. -$config->throttles->addRule(new ThrottleRule( - 'login-throttle', +// The scope filter restricts the throttle to the login path; the keyless +// rule counts per resolved client IP (proxy-aware via the Config's IP resolver). +$config->throttles->add('login-throttle', limit: 10, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher( - fn(ServerRequestInterface $req): bool => $req->getUri()->getPath() === '/login' - ), -)); + scope: fn(ServerRequestInterface $req): bool => $req->getUri()->getPath() === '/login', +); ``` ### Credential Stuffing (Per-Username) @@ -70,10 +64,9 @@ Throttle per username to prevent attackers from testing many passwords against a $config->throttles->add('account-throttle', limit: 5, period: 60, + scope: fn(ServerRequestInterface $req): bool => + $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', key: function (ServerRequestInterface $req): ?string { - if ($req->getMethod() !== 'POST' || $req->getUri()->getPath() !== '/login') { - return null; - } // Key on the submitted credential read from the request body, not a // client-settable header: an attacker could rotate or omit X-Username // to dodge the per-account limit entirely. @@ -348,22 +341,16 @@ $config->throttles->add('api', Apply stricter limits to mutating operations: ```php -use Flowd\Phirewall\Config\ClosureRequestMatcher; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Psr\Http\Message\ServerRequestInterface; -// A null key defaults to the resolved client IP; the scope filter restricts the -// throttle to mutating methods. -$config->throttles->addRule(new ThrottleRule( - 'write-ops', +// The scope filter restricts the throttle to mutating methods; the keyless +// rule counts per resolved client IP. +$config->throttles->add('write-ops', limit: 50, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher( - fn(ServerRequestInterface $req): bool => - in_array($req->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true) - ), -)); + scope: fn(ServerRequestInterface $req): bool => + in_array($req->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true), +); ``` ### Allow2Ban for High-Volume Abuse @@ -410,12 +397,10 @@ $proxy = new TrustedProxyResolver(['10.0.0.0/8', '172.16.0.0/12']); $config->throttles->add('export', limit: 10, period: 3600, - key: function (ServerRequestInterface $req) use ($proxy): ?string { - if (str_starts_with($req->getUri()->getPath(), '/api/export')) { - return $req->getAttribute('userId') ?? $proxy->resolve($req); - } - return null; - }, + scope: fn(ServerRequestInterface $req): bool => + str_starts_with($req->getUri()->getPath(), '/api/export'), + key: fn(ServerRequestInterface $req): ?string => + $req->getAttribute('userId') ?? $proxy->resolve($req), ); ``` @@ -439,9 +424,7 @@ Combine all layers into a single production configuration: ```php use Flowd\Phirewall\Config; -use Flowd\Phirewall\Config\ClosureRequestMatcher; use Flowd\Phirewall\Config\Rule\SafelistRule; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Flowd\Phirewall\Http\TrustedProxyResolver; use Flowd\Phirewall\Matchers\TrustedBotMatcher; use Flowd\Phirewall\Middleware; @@ -500,15 +483,13 @@ $config->fail2ban->add('login-brute-force', // ── Layer 5: Throttling ─────────────────────────────────────────────── $config->throttles->multi('api', [1 => 5, 60 => 200]); -// Null key defaults to the resolved client IP (the resolver set above); -// the scope filter restricts the throttle to the login path. -$config->throttles->addRule(new ThrottleRule( - 'login', +// The scope restricts the throttle to the login path; the keyless rule +// counts per resolved client IP (the resolver set above). +$config->throttles->add('login', limit: 10, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher(fn($req): bool => $req->getUri()->getPath() === '/login'), -)); + scope: fn($req): bool => $req->getUri()->getPath() === '/login', +); // ── Layer 6: Allow2Ban ──────────────────────────────────────────────── $config->allow2ban->add('volume-ban', diff --git a/docs/examples.md b/docs/examples.md index 3b77007..f5d1c45 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -974,8 +974,6 @@ Tiered per-client-IP rate limits for an API, with a tighter cap on an expensive ```php use Flowd\Phirewall\Config; -use Flowd\Phirewall\Config\ClosureRequestMatcher; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Flowd\Phirewall\Http\TrustedProxyResolver; use Flowd\Phirewall\Store\RedisCache; use Predis\Client as PredisClient; @@ -997,15 +995,11 @@ $config->throttles->add('global', limit: 1000, period: 60, ); -// Expensive endpoint limit. Null key defaults to the resolved client IP; -// the scope restricts the throttle to the search endpoint. -$config->throttles->addRule(new ThrottleRule( - 'search', - limit: 20, - period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher(fn($req): bool => $req->getUri()->getPath() === '/api/search'), -)); +// Expensive endpoint limit. The scope restricts the throttle to the search +// endpoint; the keyless rule counts per resolved client IP. +$config->throttles->add('search', limit: 20, period: 60, + scope: fn($req): bool => $req->getUri()->getPath() === '/api/search', +); ``` --- @@ -1083,8 +1077,6 @@ Complete login protection with throttling, Fail2Ban, and tracking. ```php use Flowd\Phirewall\Config; -use Flowd\Phirewall\Config\ClosureRequestMatcher; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Flowd\Phirewall\Http\TrustedProxyResolver; use Flowd\Phirewall\Store\RedisCache; use Predis\Client as PredisClient; @@ -1093,7 +1085,7 @@ $redis = new PredisClient(getenv('REDIS_URL') ?: 'redis://localhost:6379'); $config = new Config(new RedisCache($redis)); // Resolve the real client IP behind a proxy. Setting it on the Config makes -// null-key rules proxy-aware, so the scoped login throttles below key on the +// keyless rules proxy-aware, so the scoped login throttles below key on the // resolved client IP without touching the raw REMOTE_ADDR peer. $config->setIpResolver((new TrustedProxyResolver(['10.0.0.0/8', '172.16.0.0/12']))->resolve(...)); @@ -1120,28 +1112,20 @@ $config->safelists->add('health', fn($req) => $req->getUri()->getPath() === '/health' ); -// Throttle login attempts: 10 per minute per client IP. Null key defaults to -// the resolved client IP; the scope restricts the throttle to login POSTs. -$config->throttles->addRule(new ThrottleRule( - 'login-rate', +// Throttle login attempts: 10 per minute per client IP. The scope restricts +// the throttle to login POSTs; the keyless rule counts per resolved client IP. +$config->throttles->add('login-rate', limit: 10, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher( - fn($req): bool => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST' - ), -)); + scope: fn($req): bool => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST', +); // Burst detection: 3 login attempts in 10 seconds -$config->throttles->addRule(new ThrottleRule( - 'login-burst', +$config->throttles->add('login-burst', limit: 3, period: 10, - keyExtractor: null, - scope: new ClosureRequestMatcher( - fn($req): bool => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST' - ), -)); + scope: fn($req): bool => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST', +); // Allow2Ban: ban after 5 login attempts in 5 minutes. Login POSTs are // legitimate, so they pass until the threshold (a Fail2Ban filter would @@ -1443,9 +1427,7 @@ A production configuration combining safelists, blocklists, OWASP rules, bot det ```php use Flowd\Phirewall\Config; -use Flowd\Phirewall\Config\ClosureRequestMatcher; use Flowd\Phirewall\Config\Rule\SafelistRule; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Flowd\Phirewall\Http\TrustedProxyResolver; use Flowd\Phirewall\Matchers\TrustedBotMatcher; use Flowd\Phirewall\Middleware; @@ -1556,23 +1538,17 @@ $config->throttles->add('burst', limit: 50, period: 5, ); -$config->throttles->addRule(new ThrottleRule( - 'write-ops', +$config->throttles->add('write-ops', limit: 100, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher( - fn($req): bool => in_array($req->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true) - ), -)); + scope: fn($req): bool => in_array($req->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true), +); -$config->throttles->addRule(new ThrottleRule( - 'login', +$config->throttles->add('login', limit: 10, period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher(fn($req): bool => $req->getUri()->getPath() === '/login'), -)); + scope: fn($req): bool => $req->getUri()->getPath() === '/login', +); // === CUSTOM RESPONSES === $config->blocklistedResponseFactory = new ClosureBlocklistedResponseFactory( diff --git a/docs/faq.md b/docs/faq.md index c28bf79..827febc 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -117,6 +117,8 @@ To switch to fail-closed mode (exceptions propagate, resulting in a 500 error): $config->setFailOpen(false); ``` +The policy also decides how pattern-blocklist regexes handle a PCRE engine error at match time: no match under fail-open, a match (block) under fail-closed. The OWASP CRS engine is separate: its `@rx` operator always treats an engine error as a match, regardless of this policy. + ::: warning In fail-open mode, a down cache means firewall rules are not being enforced. Monitor your cache backend health and alert on `FirewallError` events. See [Observability](/advanced/observability) for monitoring setup. ::: diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index 62f8c4a..32aac78 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -683,7 +683,7 @@ $config->throttles->add('global', ``` Note the parameter rename `ban:` to `banSeconds:`; the key still defaults to the client IP. -- A **signal-only** rule (`filter: fn() => false` driven by `RequestContext::recordFailure()`) is **unaffected**: `recordFailure()` still only counts and may ban, never blocks the current request and never dispatches `Fail2BanMatched`. This is the recommended pattern for handler-verified login failures. The shipped presets need no change either: the core presets use blocklists (not fail2ban), and the OWASP CRS fail2ban preset matches only unambiguously malicious traffic, which is meant to block on sight. +- A **signal-only** rule (`filter: fn() => false` driven by `RequestContext::recordFailure()`) is **unaffected**: `recordFailure()` still only counts and may ban, never dispatches `Fail2BanMatched`, and by default never blocks the current request (opt in to a 403 for the banning request with `Config::enableBlockOnSignalBan()`). This is the recommended pattern for handler-verified login failures. The shipped presets need no change either: the core presets use blocklists (not fail2ban), and the OWASP CRS fail2ban preset matches only unambiguously malicious traffic, which is meant to block on sight. **Allow2Ban gained an optional filter** (see [Filtered Counting](#filtered-counting)). Existing filterless Allow2Ban rules keep the exact previous behavior (a hard volume cap counting every request), so no change is required. diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index 19ab978..fe25c78 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -27,7 +27,8 @@ $config->throttles->add( string $name, int|Closure $limit, int|Closure $period, - ?Closure $key = null + ?Closure $key = null, + ?Closure $scope = null ): ThrottleSection ``` @@ -37,13 +38,19 @@ $config->throttles->add( | `$limit` | `int\|Closure` | Max requests per window, or a [dynamic closure](#dynamic-limits) | | `$period` | `int\|Closure` | Window size in seconds, or a [dynamic closure](#dynamic-limits) | | `$key` | `?Closure` | `fn(ServerRequestInterface): ?string`, return a key to group by, or `null` to skip. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | +| `$scope` | `?Closure` | `fn(ServerRequestInterface): bool`, restricts which requests the throttle counts; non-matching requests skip the rule. Omit to count every request. | ```php // 100 requests per minute per IP $config->throttles->add('ip-limit', limit: 100, period: 60); + +// Path-scoped: only /search requests count, still per client IP +$config->throttles->add('search', limit: 10, period: 60, + scope: fn($req) => $req->getUri()->getPath() === '/search', +); ``` -When the key closure returns `null`, the rule is skipped for that request. This lets you apply throttles conditionally, only to certain paths, methods, or user types. +Use `scope` to apply a throttle conditionally, only to certain paths, methods, or user types: the key stays omitted, so matching requests are counted per resolved client IP. A key closure that returns `null` also skips the rule for that request; reach for that form only when the rule keys on something other than the client IP (a header, a username, and so on). ```text Window 1 (00:00-00:59) Window 2 (01:00-01:59) Window 3 (02:00-02:59) @@ -63,7 +70,8 @@ $config->throttles->sliding( string $name, int|Closure $limit, int|Closure $period, - ?Closure $key = null + ?Closure $key = null, + ?Closure $scope = null ): ThrottleSection ``` @@ -105,7 +113,8 @@ The `multi()` method registers multiple throttle windows under a single logical $config->throttles->multi( string $name, array $windowLimits, - ?Closure $key = null + ?Closure $key = null, + ?Closure $scope = null ): ThrottleSection ``` @@ -114,6 +123,7 @@ $config->throttles->multi( | `$name` | `string` | Logical name prefix | | `$windowLimits` | `array` | Map of period (seconds) => limit (max requests) | | `$key` | `?Closure` | Key extractor closure (shared across all windows). Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | +| `$scope` | `?Closure` | Scope filter (shared across all windows); non-matching requests skip every window. | Each entry creates a sub-rule named `{$name}:{$period}s`. Windows are evaluated shortest-first (burst before sustained). @@ -130,8 +140,6 @@ A request is blocked if it exceeds **any** of the windows. This catches both rap ### Practical Multi-Window Examples ```php -use Flowd\Phirewall\Http\TrustedProxyResolver; - // API with generous sustained limits but strict burst protection $config->throttles->multi('public-api', [ 1 => 5, // 5 req/s burst @@ -139,19 +147,13 @@ $config->throttles->multi('public-api', [ 3600 => 5000, // 5000 req/hour daily budget ]); -// Login endpoint with tight controls. multi() shares one key closure across -// its windows and has to scope by path, so resolve the client IP in the -// closure (the same TrustedProxyResolver you pass to setIpResolver) instead of -// reading the raw REMOTE_ADDR peer address, which collapses onto the proxy. -$proxyResolver = new TrustedProxyResolver(['10.0.0.0/8', '172.16.0.0/12']); - +// Login endpoint with tight controls: the scope restricts counting to the +// login path (shared across all windows), and the keyless rule counts per +// client IP through the Config IP resolver. $config->throttles->multi('login', [ 60 => 5, // 5 attempts/min 3600 => 20, // 20 attempts/hour -], fn($req) => $req->getUri()->getPath() === '/login' - ? $proxyResolver->resolve($req) - : null -); +], scope: fn($req) => $req->getUri()->getPath() === '/login'); ``` ## Dynamic Limits @@ -243,16 +245,6 @@ Prefer `hashedHeader()` over `header()` whenever the header carries a credential Write your own closure for any logic: ```php -// Only rate limit login attempts -$config->throttles->add('login-rate', limit: 10, period: 60, - key: function ($req): ?string { - if ($req->getUri()->getPath() === '/login') { - return $req->getServerParams()['REMOTE_ADDR'] ?? null; - } - return null; // Skip non-login requests - } -); - // Composite key: IP + path $config->throttles->add('per-endpoint', limit: 50, period: 60, key: function ($req): ?string { @@ -263,7 +255,7 @@ $config->throttles->add('per-endpoint', limit: 50, period: 60, ``` ::: tip -These snippets read `REMOTE_ADDR` directly to keep the closures short. In production behind a proxy, derive the IP part from your `TrustedProxyResolver` (`$proxyResolver->resolve($req)`) so it is the real client IP, not the proxy peer address. +This snippet reads `REMOTE_ADDR` directly to keep the closure short. In production behind a proxy, derive the IP part from your `TrustedProxyResolver` (`$proxyResolver->resolve($req)`) so it is the real client IP, not the proxy peer address. And to rate limit only certain requests while keeping the default client IP key, use `scope:` instead of a null-returning key closure (see [Fixed Window Throttle](#fixed-window-throttle)). ::: ## Tiered Rate Limits @@ -271,8 +263,6 @@ These snippets read `REMOTE_ADDR` directly to keep the closures short. In produc Define multiple throttle rules with different limits for different use cases. All rules are evaluated independently; a request must satisfy all of them. ```php -use Flowd\Phirewall\Config\ClosureRequestMatcher; -use Flowd\Phirewall\Config\Rule\ThrottleRule; use Flowd\Phirewall\Http\TrustedProxyResolver; $proxyResolver = new TrustedProxyResolver([ @@ -287,26 +277,16 @@ $config->throttles->add('global-ip', limit: 1000, period: 60, ); -// Tier 2: Stricter limit for write operations. Null key defaults to the -// resolved client IP; the scope restricts the throttle to mutating methods. -$config->throttles->addRule(new ThrottleRule( - 'write-operations', - limit: 100, - period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher( - fn($req): bool => in_array($req->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true) - ), -)); +// Tier 2: Stricter limit for write operations. The scope restricts the +// throttle to mutating methods; the keyless rule counts per client IP. +$config->throttles->add('write-operations', limit: 100, period: 60, + scope: fn($req): bool => in_array($req->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true), +); // Tier 3: Per-endpoint limit for expensive operations -$config->throttles->addRule(new ThrottleRule( - 'search-endpoint', - limit: 20, - period: 60, - keyExtractor: null, - scope: new ClosureRequestMatcher(fn($req): bool => $req->getUri()->getPath() === '/api/search'), -)); +$config->throttles->add('search-endpoint', limit: 20, period: 60, + scope: fn($req): bool => $req->getUri()->getPath() === '/api/search', +); ``` ## Per-User Limits diff --git a/docs/getting-started.md b/docs/getting-started.md index abca168..408343c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -856,6 +856,8 @@ $config->setFailOpen(true); $config->setFailOpen(false); ``` +The policy also governs pattern-blocklist regex matching: a compile-valid pattern that errors at match time (e.g. the backtrack limit exceeded) counts as no match under fail-open, while a fail-closed firewall treats the error as a match, so a forced engine error cannot slip past a block rule. The OWASP CRS engine is separate: its `@rx` operator always treats an engine error as a match, regardless of this policy (see [OWASP CRS](/features/owasp-crs#operator-evaluators)). + ## Response Headers Diagnostic `X-Phirewall` headers are opt-in and can be added to blocked or safelisted responses: