Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
],

Expand Down
2 changes: 1 addition & 1 deletion docs/advanced/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
sascha-egerer marked this conversation as resolved.

See [Request Context](/advanced/request-context) for post-handler failure signaling.

Expand Down
84 changes: 31 additions & 53 deletions docs/advanced/dynamic-throttle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'] ?? '';
Expand All @@ -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.
:::
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions docs/advanced/request-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
63 changes: 22 additions & 41 deletions docs/common-attacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
);
```

Expand All @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading