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
3 changes: 3 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ export default defineConfig({
{ text: 'Rate Limiting', link: '/features/rate-limiting' },
{ text: 'Fail2Ban & Allow2Ban', link: '/features/fail2ban' },
{ text: 'Bot Detection & Matchers', link: '/features/bot-detection' },
{ text: 'Trusted Bots', link: '/features/trusted-bots' },
{ text: 'OWASP Core Rule Set', link: '/features/owasp-crs' },
{ text: 'Bot & AI Crawler Presets', link: '/features/bot-presets' },
{ text: 'Bad-IP Blocklist Preset', link: '/features/bad-ip-preset' },
{ text: 'Storage Backends', link: '/features/storage' },
]
},
Expand Down
28 changes: 11 additions & 17 deletions docs/advanced/config-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,28 @@ outline: deep

# Config Composition

Real deployments rarely have a single source of firewall rules. A vendor ships a baseline, an environment (staging vs. production) adds its own rules, a tenant overrides a few, and a single deployment applies a last-minute tweak. `Config::compose()` (and the fluent `Config::mergedWith()`) merges these layers into one effective `Config` (**without mutating any input**) so each layer can be owned, versioned, and shipped independently, often as a [`PortableConfig`](/advanced/portable-config).
Real deployments rarely have a single source of firewall rules. A vendor ships a baseline, an environment (staging vs. production) adds its own rules, a tenant overrides a few, and a single deployment applies a last-minute tweak. `Config::with()` applies these layers into one effective `Config` (**without mutating any input**) so each layer can be owned, versioned, and shipped independently. A layer is any `ConfigLayer` - a live `Config` or a [`PortableConfig`](/advanced/portable-config).

## Usage

```php
use Flowd\Phirewall\Config;

// Each layer is owned and versioned independently, usually as a PortableConfig.
// Materialize them onto your cache with Config::combine(); later layers win.
// Apply them onto your cache with Config::with(); later layers win.
// The cache lives only on Config; the portable layers never carry one.
$effective = (new Config($cache))->combine(
$effective = (new Config($cache))->with(
$vendorPortable, // shared product defaults
$environmentPortable, // staging vs. production
$tenantPortable, // per-customer policy
);

// Already holding Config instances? compose() / mergedWith() layer those directly
// A Config is itself a ConfigLayer, so configs apply directly through the same call
// (same precedence; later layers win):
$effective = $vendorConfig->mergedWith($environmentConfig, $tenantConfig);
$effective = Config::compose($vendorConfig, $environmentConfig, $tenantConfig);
$effective = $base->with($vendorConfig, $environmentConfig, $tenantConfig);
```

`compose()` is static and reads as "base first, overlays after"; `mergedWith()` is the instance form for when you already hold the base. Both return a fresh `Config`; the base and every overlay are left untouched.

| Form | Signature | Reads as |
|------|-----------|----------|
| `Config::compose(...$configs)` | static, variadic | base first, overlays after |
| `$base->mergedWith(...$overlays)` | instance, variadic | overlays applied onto `$base` |
`with()` is the one instance method for composition: it takes variadic `ConfigLayer`s and returns a fresh `Config`; the base and every overlay are left untouched.

## Merge semantics

Expand All @@ -53,21 +47,21 @@ A `Config` does not track which options were *set* versus *left at their default

Because "default-valued" is read as "no opinion", an overlay **cannot turn a toggle back off** once an earlier layer turned it on. If the vendor baseline calls `enableResponseHeaders()` (changing the toggle from its `false` default to `true`), a tenant overlay that leaves the toggle at `false` will *not* switch it back off; its `false` is indistinguishable from "unspecified", so the baseline's explicit `true` wins. The same applies to `failOpen` and the other boolean toggles. (`enabled` is the deliberate exception: as its row above notes, it uses last-layer-wins, so a later layer *can* re-assert it.)

If you need a later layer to *force* a non-default option back to the default, do not rely on composition: build the final `Config` and set the option explicitly after composing, e.g. `Config::compose(...)->setFailOpen(true)`.
If you need a later layer to *force* a non-default option back to the default, do not rely on composition: build the final `Config` and set the option explicitly after composing, e.g. `(new Config($cache))->with(...)->setFailOpen(true)`.

### Limitation: composing the IP resolver does not rewrite IP-aware matchers
### IP resolver: autowired matchers compose, an explicit resolver is fixed

IP-aware matchers (`IpMatcher`, the file/snapshot IP blocklists, `TrustedBotMatcher`) capture their IP resolver **when the rule is constructed**. Because composition copies already-built rule objects, composing a layer with a different IP resolver only affects rules added *after* it; it does **not** retroactively change how earlier layers' IP rules resolve the client IP. Set the resolver on each source `Config` (via `setIpResolver()`) **before** adding its IP rules, rather than expecting a later layer to override it.
IP-aware matchers (`IpMatcher`, the file/snapshot IP blocklists, `TrustedBotMatcher`) **autowire** the client-IP resolver. Constructed without an explicit resolver, they resolve the client IP through the `Config` they run under, at request time. So a composed `Config` applies its own merged IP resolver to these matchers no matter which layer defined them, the same way keyless counter rules (throttle, fail2ban, allow2ban, track) resolve their default IP key against the `Config` they run under.

This limitation does **not** apply to counter rules (throttle, fail2ban, allow2ban, track) added **without** an explicit `key`. Their default IP key is resolved per request against the `Config` they run under, so a composed `Config` correctly applies its own merged IP resolver to such rules no matter which layer defined them.
The exception is a matcher **given an explicit resolver in its constructor**: it keeps that resolver and ignores the composed `Config`'s. Composition copies already-built rule objects, so it cannot rewrite a resolver baked into a matcher. If you want every layer's IP rules to follow one resolver, leave the matchers' resolver unset and set it once on the final `Config` with `setIpResolver()`; reserve an explicit per-matcher resolver for the rare rule that must resolve the client IP differently from the rest.

## Example

```php
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Http\Firewall;

$effective = $vendorBaseline->mergedWith($environmentOverlay, $tenantOverlay, $deploymentTweak);
$effective = $vendorBaseline->with($environmentOverlay, $tenantOverlay, $deploymentTweak);

// Rules unioned by name, base ordering preserved:
$effective->blocklists->rules(); // ['scanners' (tenant wins), 'bad-net', 'admin-probe', ...]
Expand Down
12 changes: 6 additions & 6 deletions docs/advanced/portable-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ outline: deep
- **diff and review it in git**, or
- **share one ruleset across many apps, processes, or languages**

…and then materialize a live [`Config`](/getting-started) from it with [`Config::combine()`](/advanced/config-composition); the schema is pure data and never carries a cache. Closures are never serialized, so the surface is intentionally a safe, declarative subset (see [Not portable by design](#not-portable-by-design)).
…and then apply it onto a live [`Config`](/getting-started) with [`Config::with()`](/advanced/config-composition); the schema is pure data and never carries a cache. A `PortableConfig` is a [`ConfigLayer`](/advanced/config-composition), so it composes through the same `with()` call as any other layer. Closures are never serialized, so the surface is intentionally a safe, declarative subset (see [Not portable by design](#not-portable-by-design)).

## Building and round-tripping

Build a ruleset fluently, export it with `toArray()` (or `json_encode()` the result), and rebuild it with `fromArray()`, then materialize it onto a `Config` with `Config::combine()`:
Build a ruleset fluently, export it with `toArray()` (or `json_encode()` the result), and rebuild it with `fromArray()`, then apply it onto a `Config` with `Config::with()`:

```php
use Flowd\Phirewall\Config;
Expand Down Expand Up @@ -43,7 +43,7 @@ $portable = PortableConfig::create()
$json = json_encode($portable->toArray(), JSON_THROW_ON_ERROR);

// … and rebuild a live Config somewhere else.
$config = (new Config($cache))->combine(PortableConfig::fromArray(json_decode($json, true, 512, JSON_THROW_ON_ERROR)));
$config = (new Config($cache))->with(PortableConfig::fromArray(json_decode($json, true, 512, JSON_THROW_ON_ERROR)));
$firewall = new Firewall($config);
```

Expand Down Expand Up @@ -142,7 +142,7 @@ use Flowd\Phirewall\Portable\PortableConfig;

// $store->load() returns the signed blob from your DB / cache / config service.
$portable = PortableConfig::loadSigned($store->load(), $secret);
$firewall = new Firewall((new Config($cache))->combine($portable));
$firewall = new Firewall((new Config($cache))->with($portable));
```

Under classic PHP-FPM userland state does not carry over between requests, so this runs once per request and always reflects the current rules. To avoid querying the database on every request, put a shared cache (APCu, for example) in front of the store.
Expand All @@ -156,7 +156,7 @@ Under a long-running worker runtime (Swoole, RoadRunner, FrankenPHP worker mode,
$row = $store->load();
if ($loadedVersion !== $row['version']) {
$portable = PortableConfig::loadSigned($row['blob'], $secret);
$firewall = new Firewall((new Config($cache))->combine($portable));
$firewall = new Firewall((new Config($cache))->with($portable));
$loadedVersion = $row['version'];
}
```
Expand Down Expand Up @@ -191,7 +191,7 @@ A few capabilities cannot be represented as pure data and are intentionally **ex
| Excluded | Why |
|----------|-----|
| Trusted-bot reverse-DNS safelisting (`TrustedBotMatcher`) | needs live DNS resolution and an optional cache at request time |
| OWASP Core Rule Set (`blocklists->owasp()`) | a ruleset is parsed `SecRule` objects / rule files, not a small data blob |
| OWASP Core Rule Set (`CoreRuleSetMatcher`, in `flowd/phirewall-preset-owasp-crs`) | a ruleset is parsed `SecRule` objects / rule files, not a small data blob |
| File-backed lists (`fileIp`, `filePatternBackend`) | filesystem paths are environment-specific; the in-memory pattern backend is the portable equivalent |
| Closure-driven dynamic throttle limits/periods, `$config->throttles->multi()` | limits/periods can be arbitrary PHP closures and cannot be serialized (express the multi-window case as several `throttles->add()` entries; `sliding` is supported) |
| Response factories, `ipResolver`, `discriminatorNormalizer` | these are closures / objects, not declarative data |
Expand Down
14 changes: 7 additions & 7 deletions docs/advanced/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,31 @@ outline: deep

# Presets

Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a [`PortableConfig`](/advanced/portable-config) returned by an accessor (e.g. `Presets::scannerBlocking()`): plain, inspectable, serializable data you can diff, sign, or layer.
Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a [`PortableConfig`](/advanced/portable-config) returned by an accessor (e.g. `Presets::scannerBlocking()`): plain, inspectable, serializable data you can diff, sign, or layer. Every preset is a `ConfigLayer`, so it composes through the same `Config::with()` call as any other layer.

Materialize one or several onto your own cache with [`Config::combine()`](/advanced/config-composition); presets are pure data and never receive a cache. Every rule is namespaced `preset.<area>.*`, so a later layer that redefines it by name overrides predictably.
Apply one or several onto your own cache with [`Config::with()`](/advanced/config-composition); presets are pure data and never receive a cache. Every rule is namespaced `preset.<area>.*`, so a later layer that redefines it by name overrides predictably.

## Usage

```php
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Preset\Presets;

// A preset on its own; combine it onto a Config you build with your cache:
$config = (new Config($cache))->combine(Presets::scannerBlocking());
// A preset on its own; apply it onto a Config you build with your cache:
$config = (new Config($cache))->with(Presets::scannerBlocking());

// Inspect / serialize the underlying portable schema:
$schema = Presets::scannerBlocking()->toArray();

// Stack several presets, then your own rules last (later layers win by name):
$config = (new Config($cache))->combine(
$config = (new Config($cache))->with(
Presets::scannerBlocking(),
Presets::sensitivePathBlocking(),
$myPortable,
);
```

Preset rules emit the same [observability events](/advanced/observability) as hand-written ones; wire your PSR-14 dispatcher into the `Config` you combine onto (`new Config($cache, $dispatcher)`).
Preset rules emit the same [observability events](/advanced/observability) as hand-written ones; wire your PSR-14 dispatcher into the `Config` you apply onto (`new Config($cache, $dispatcher)`).

## Shipped presets

Expand All @@ -42,7 +42,7 @@ Resolve any preset by name with `Presets::get($name)` (a `PortableConfig`), pass
## Conventions and overrides

- The shipped presets target signals that are universal across applications (scanner User-Agents, missing browser headers, well-known sensitive paths), so they assume nothing about your routing. A preset you build yourself is just a `PortableConfig`, so it can key on whatever fits your environment, including routes your own apps standardize.
- Override any rule by combining the preset with your own portable rules that redefine the rule by the same name (later layer wins), or by rebuilding the preset's schema.
- Override any rule by applying the preset with your own portable rules that redefine the rule by the same name (later layer wins), or by rebuilding the preset's schema.

> **Note:** `scannerBlocking()`'s `suspicious-headers` rule is the more aggressive of the two: some legitimate API clients, privacy tools, and embedded browsers also omit `Accept-*` headers. Drop or override it by name if your traffic includes non-browser clients.

Expand Down
27 changes: 19 additions & 8 deletions docs/common-attacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,15 @@ $config->throttles->add('account-throttle',

Block common SQL injection patterns using OWASP CRS rules.

::: tip OWASP CRS is a separate package
The SecRule engine and CRS presets ship in the companion package. Install it first:
`composer require flowd/phirewall-preset-owasp-crs`. See [OWASP CRS](/features/owasp-crs) for details.
:::

```php
use Flowd\Phirewall\Owasp\SecRuleLoader;
use Flowd\Phirewall\Config\Rule\BlocklistRule;
use Flowd\PhirewallPresetOwaspCrs\Engine\CoreRuleSetMatcher;
use Flowd\PhirewallPresetOwaspCrs\Engine\SecRuleLoader;

$rules = SecRuleLoader::fromString(<<<'CRS'
# UNION SELECT attacks
Expand Down Expand Up @@ -120,7 +127,7 @@ SecRule ARGS "@rx (?i)information_schema" \
"id:942170,phase:2,deny,msg:'SQL Injection: DB enumeration'"
CRS);

$config->blocklists->owasp('sqli', $rules);
$config->blocklists->addRule(new BlocklistRule('sqli', new CoreRuleSetMatcher($rules)));
```

::: tip
Expand Down Expand Up @@ -158,7 +165,7 @@ SecRule ARGS "@rx (?i)<(object|embed|applet)[^>]*>" \
"id:941150,phase:2,deny,msg:'XSS: Object/embed tag'"
CRS);

$config->blocklists->owasp('xss', $rules);
$config->blocklists->addRule(new BlocklistRule('xss', new CoreRuleSetMatcher($rules)));
```

## Remote Code Execution (RCE)
Expand All @@ -180,7 +187,7 @@ SecRule ARGS "@rx `[^`]+`" \
"id:933120,phase:2,deny,msg:'RCE: Backtick execution'"
CRS);

$config->blocklists->owasp('rce', $rules);
$config->blocklists->addRule(new BlocklistRule('rce', new CoreRuleSetMatcher($rules)));
```

## Path Traversal
Expand All @@ -202,7 +209,7 @@ SecRule ARGS "@rx \.\.[\\/]" \
"id:930120,phase:2,deny,msg:'Path Traversal in parameter'"
CRS);

$config->blocklists->owasp('path-traversal', $rules);
$config->blocklists->addRule(new BlocklistRule('path-traversal', new CoreRuleSetMatcher($rules)));
```

Or use a simple blocklist closure:
Expand Down Expand Up @@ -407,10 +414,14 @@ Combine all layers into a production-ready configuration:

```php
use Flowd\Phirewall\Config;
use Flowd\Phirewall\Config\Rule\SafelistRule;
use Flowd\Phirewall\Http\TrustedProxyResolver;
use Flowd\Phirewall\KeyExtractors;
use Flowd\Phirewall\Matchers\TrustedBotMatcher;
use Flowd\Phirewall\Middleware;
use Flowd\Phirewall\Owasp\SecRuleLoader;
use Flowd\Phirewall\Config\Rule\BlocklistRule;
use Flowd\PhirewallPresetOwaspCrs\Engine\CoreRuleSetMatcher;
use Flowd\PhirewallPresetOwaspCrs\Engine\SecRuleLoader;
use Flowd\Phirewall\Store\RedisCache;
use Psr\Http\Message\ServerRequestInterface;
use Nyholm\Psr7\Factory\Psr17Factory;
Expand All @@ -427,7 +438,7 @@ $config->setIpResolver(KeyExtractors::clientIp($proxy));
$config->safelists->add('health',
fn($req): bool => $req->getUri()->getPath() === '/health'
);
$config->safelists->trustedBots(cache: new RedisCache($redis));
$config->safelists->addRule(new SafelistRule('trusted-bots', new TrustedBotMatcher(ipResolver: $config->getIpResolver(), cache: new RedisCache($redis))));
$config->safelists->ip('office', ['203.0.113.0/24']);

// ── Layer 2: Blocklists ────────────────────────────────────────────────
Expand All @@ -450,7 +461,7 @@ SecRule ARGS "@rx (?i)\bon(load|error|click)\s*=" "id:941110,phase:2,deny,msg:'X
SecRule ARGS "@rx (?i)(eval|exec|system|shell_exec)\s*\(" "id:933100,phase:2,deny,msg:'RCE'"
SecRule REQUEST_URI "@rx \.\.[\\/]" "id:930100,phase:2,deny,msg:'Path Traversal'"
CRS);
$config->blocklists->owasp('owasp', $rules);
$config->blocklists->addRule(new BlocklistRule('owasp', new CoreRuleSetMatcher($rules)));

// ── Layer 4: Fail2Ban ─────────────────────────────────────────────────
// The filter never matches pre-handler; your login handler signals each
Expand Down
Loading
Loading