Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
15854c9
Document 0.5.0: portable config, composition, presets + proxy/IP, PSR…
sascha-egerer May 31, 2026
fdaec28
Document the IP-resolver composition caveat
sascha-egerer Jun 1, 2026
b982428
Update portable/composition/presets docs for Config::combine() materi…
sascha-egerer Jun 1, 2026
258ebb3
Align 0.5.0 docs with the final API
sascha-egerer Jun 8, 2026
ac9af2f
Balance the home feature grid and tighten the Presets card
sascha-egerer Jun 8, 2026
245d12f
Drop redundant key: KeyExtractors::ip() from rule examples
sascha-egerer Jun 8, 2026
00770c6
Drop the manual TYPO3 integration; point to the flowd/typo3-firewall …
sascha-egerer Jun 8, 2026
9c0baea
Switch the docs site to pnpm and upgrade VitePress
sascha-egerer Jun 8, 2026
dc5f210
Document the TYPO3 phirewall.php config-file format
sascha-egerer Jun 8, 2026
2bc43d5
Stop keying rate-limit examples on client-controlled headers
sascha-egerer Jun 8, 2026
9d81932
Adjust github workflow to use pnpm over npm
sascha-egerer Jun 8, 2026
9e55757
Review and refine the 0.5.0 documentation
sascha-egerer Jun 9, 2026
bf530d4
Read trusted per-request values from request attributes, not headers
sascha-egerer Jun 9, 2026
ea45cb2
Drop the forgeable X-Login-Failed marker example for login brute force
sascha-egerer Jun 9, 2026
b476737
Re-scope the portable-config database example to per-request loading
sascha-egerer Jun 9, 2026
2050e96
Drop the route-scoped apiRateLimiting and loginProtection presets
sascha-egerer Jun 9, 2026
f8e9138
Stop pinning TYPO3 versions in the framework support list
sascha-egerer Jun 9, 2026
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
11 changes: 6 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
version: 11
cache: true
- run: pnpm ci
- run: pnpm run build
- uses: actions/upload-artifact@v4
with:
name: docs-dist
Expand Down
3 changes: 3 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export default defineConfig({
{ text: 'Architecture', link: '/advanced/architecture' },
{ text: 'Dynamic Throttle & Sliding Window', link: '/advanced/dynamic-throttle' },
{ text: 'Request Context', link: '/advanced/request-context' },
{ text: 'Portable Config', link: '/advanced/portable-config' },
{ text: 'Config Composition', link: '/advanced/config-composition' },
{ text: 'Presets', link: '/advanced/presets' },
{ text: 'Track & Notifications', link: '/advanced/track-notifications' },
{ text: 'Observability', link: '/advanced/observability' },
{ text: 'Infrastructure Adapters', link: '/advanced/infrastructure' },
Expand Down
20 changes: 10 additions & 10 deletions docs/advanced/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ outline: deep

# Architecture

Phirewall's core decision engine uses an **evaluator pipeline** -- a sequential chain of single-responsibility evaluator classes, each handling one type of firewall rule. The pipeline processes every request and short-circuits on the first decisive result.
Phirewall's core decision engine uses an **evaluator pipeline**, a sequential chain of single-responsibility evaluator classes, each handling one type of firewall rule. The pipeline processes every request and short-circuits on the first decisive result.

## Evaluator Pipeline

Expand All @@ -14,7 +14,7 @@ When `Firewall::decide()` is called, it creates an `EvaluationContext` and passe
Request
|
v
TrackEvaluator (passive counting -- always continues)
TrackEvaluator (passive counting, always continues)
|
v
SafelistEvaluator (match? --> allow, skip remaining)
Expand Down Expand Up @@ -74,8 +74,8 @@ The `EvaluationContext` is a mutable transport object that carries shared config

The context also provides helper methods:

- `dispatch(object $event): void` -- dispatches a PSR-14 event if a dispatcher is configured
- `responseHeaders(string $type, string $rule): array` -- builds `X-Phirewall` response headers when enabled
- `dispatch(object $event): void` - dispatches a PSR-14 event if a dispatcher is configured
- `responseHeaders(string $type, string $rule): array` - builds `X-Phirewall` response headers when enabled

## Evaluators

Expand All @@ -95,7 +95,7 @@ Checks blocklist rules. On the first match, dispatches `BlocklistMatched`, sets

For each fail2ban rule:

1. Checks if the key is already banned -- if so, returns a blocked result immediately
1. Checks if the key is already banned - if so, returns a blocked result immediately
2. If the filter matches, increments the failure counter and bans if the threshold is reached

Both the pre-handler path (during `decide()`) and the post-handler path (via `processRecordedSignal()`) use the same `count >= threshold` comparison: the Nth matching request triggers the ban and is itself blocked. This matches rack-attack's `maxretry` semantics and is consistent with Allow2Ban.
Expand Down Expand Up @@ -136,13 +136,13 @@ The order is optimized so cheap checks run before expensive ones, and passive tr

## Performance

The evaluator pipeline adds no measurable overhead compared to the previous monolithic implementation. Each evaluator is a lightweight, stateless object (except `Fail2BanEvaluator`, which is retained for post-handler failure processing). The pipeline iterates a fixed-size array with early exit on the first decisive result.
The evaluator pipeline adds negligible overhead. Each evaluator is a lightweight object; the `Fail2BanEvaluator` and `Allow2BanEvaluator` are additionally retained on the firewall so post-handler signal processing can reuse them. The pipeline iterates a fixed-size array with early exit on the first decisive result.

Performance timing for every `decide()` call is captured in the `PerformanceMeasured` event, which includes the `DecisionPath` and `durationMicros`. See [Observability](/advanced/observability#performancemeasured) for details.

## Related Pages

- [Observability](/advanced/observability) -- PSR-14 events, diagnostics counters, performance monitoring
- [Request Context](/advanced/request-context) -- post-handler failure signaling for Fail2Ban
- [Rate Limiting](/features/rate-limiting) -- throttle rules, sliding windows, and multi-throttle
- [Fail2Ban & Allow2Ban](/features/fail2ban) -- automatic banning configuration
- [Observability](/advanced/observability) - PSR-14 events, diagnostics counters, performance monitoring
- [Request Context](/advanced/request-context) - post-handler failure signaling for Fail2Ban
- [Rate Limiting](/features/rate-limiting) - throttle rules, sliding windows, and multi-throttle
- [Fail2Ban & Allow2Ban](/features/fail2ban) - automatic banning configuration
90 changes: 90 additions & 0 deletions docs/advanced/config-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
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).

## 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.
// The cache lives only on Config; the portable layers never carry one.
$effective = (new Config($cache))->combine(
$vendorPortable, // shared product defaults
$environmentPortable, // staging vs. production
$tenantPortable, // per-customer policy
);

// Already holding Config instances? compose() / mergedWith() layer those directly
// (same precedence; later layers win):
$effective = $vendorConfig->mergedWith($environmentConfig, $tenantConfig);
$effective = Config::compose($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` |

## Merge semantics

Overlays are applied left to right, so **later sources win**.

| Aspect | Rule |
|--------|------|
| **Rules** (safelists, blocklists, throttles, fail2ban, allow2ban, tracks) | Merged **by name** within each section. A later same-named rule **replaces** the earlier one in place (base ordering preserved); genuinely new names are appended. A union, never duplicates. |
| **Pattern backends** | Merged by name with the same later-wins rule. |
| **`enabled`** | **Last layer wins (fail-safe)**: the composed value is the `enabled` state of the highest-priority (last) layer. An explicit `enable()` / `disable()` / `setEnabled()` on the winning layer always takes effect, so an ambiguous composition is never left silently disabled. |
| **Other scalar / object options** (`keyPrefix`, `failOpen`, the response-header toggles, the IP resolver, the discriminator normalizer, the response factories) | **Last explicit value wins**: the value comes from the last layer whose value differs from the field default. A layer that left an option at its default never clobbers an explicit choice from an earlier layer. |
| **Infrastructure** (PSR-16 cache, PSR-14 event dispatcher, clock) | Inherited from the **base** layer; overlays do not override it. |

### Why "last explicit value wins"?

A `Config` does not track which options were *set* versus *left at their default*. Composition therefore treats "still at the field default" as "no opinion": only a value that differs from the default counts as an explicit choice that can override an earlier layer. This is what lets a thin overlay add a single rule without silently resetting the baseline's `keyPrefix` or `failOpen` policy back to the defaults.

### Limitation: an overlay cannot reset a toggle to its 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)`.

### Limitation: composing the IP resolver does not rewrite IP-aware matchers

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.

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.

## Example

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

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

// Rules unioned by name, base ordering preserved:
$effective->blocklists->rules(); // ['scanners' (tenant wins), 'bad-net', 'admin-probe', ...]
$effective->allow2ban->rules(); // ['volume-cap'] contributed by the tenant overlay

// Last-explicit-wins options:
$effective->getKeyPrefix(); // 'deploy-eu-1' (last layer that set it)
$effective->isFailOpen(); // false (only the deployment layer set it)
$effective->responseHeadersEnabled(); // true (set by the environment overlay)

$firewall = new Firewall($effective);
```

See [`examples/30-config-composition.php`](https://github.com/flowd/phirewall/blob/main/examples/30-config-composition.php) for a full vendor → environment → tenant → deployment walkthrough that prints an overridden-by-name rule, the unioned rule sets, and the last-wins options, then proves the composed firewall enforces every layer while leaving the inputs unchanged.

## Related pages

- [Portable Config](/advanced/portable-config) - ship each layer as serializable data.
- [Presets](/advanced/presets) - bundled `Config`s designed to be composed under your own rules.
- [Getting Started](/getting-started) - the base `Config` and its options.
42 changes: 21 additions & 21 deletions docs/advanced/discriminator-normalizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ Phirewall provides two layers of key normalization to ensure consistent counting

When a request is evaluated, the key goes through two normalization stages:

1. **Discriminator Normalizer** (optional, user-configured) -- transforms the raw key before it reaches the cache key generator. Use this for domain-specific normalization like case-insensitive matching.
2. **Cache Key Generator** (automatic) -- rule names are sanitized to safe characters; user-extracted keys are SHA-256 hashed for collision-free, fixed-length cache keys.
1. **Discriminator Normalizer** (optional, user-configured): transforms the raw key before it reaches the cache key generator. Use this for domain-specific normalization like case-insensitive matching.
2. **Cache Key Generator** (automatic): rule names are sanitized to safe characters; user-extracted keys are SHA-256 hashed for collision-free, fixed-length cache keys.

## The Bypass Problem

Without normalization, attackers can bypass rate limiting by manipulating the key used for counting:

```
phirewall:throttle:api:<hash of "192.168.1.100"> ← Real IP
phirewall:throttle:api:<hash of "192.168.1.100 "> ← Trailing space
phirewall:throttle:api:<hash of " 192.168.1.100"> ← Leading space
phirewall.throttle.api.<hash of "192.168.1.100"> ← Real IP
phirewall.throttle.api.<hash of "192.168.1.100 "> ← Trailing space
phirewall.throttle.api.<hash of " 192.168.1.100"> ← Leading space
```

Each of these would produce a different SHA-256 hash and create a separate counter, effectively multiplying the attacker's rate limit. The discriminator normalizer prevents this by transforming keys before hashing.
Expand Down Expand Up @@ -53,18 +53,18 @@ The normalizer is a `Closure` that receives a string and returns a string. It is
Phirewall's `CacheKeyGenerator` produces cache keys in this format:

```
{prefix}:{type}:{normalized_rule_name}:{hashed_key}
{prefix}.{type}.{normalized_rule_name}.{hashed_key}
```

### Rule Name Normalization

Rule names are sanitized for safe use in cache keys:

1. **Trimmed** -- leading and trailing whitespace removed
2. **Sanitized** -- only `A-Za-z0-9._:-` characters are kept; all others replaced with `_`
3. **Deduplicated** -- consecutive underscores collapsed to one
4. **Truncated** -- names longer than 120 characters are shortened with a SHA-1 suffix
5. **Empty-safe** -- empty strings are replaced with `empty`
1. **Trimmed** - leading and trailing whitespace removed
2. **Sanitized** - only `A-Za-z0-9._-` characters are kept; all others replaced with `_`
3. **Deduplicated** - consecutive underscores collapsed to one
4. **Truncated** - names longer than 120 characters are shortened with a SHA-1 suffix
5. **Empty-safe** - empty strings are replaced with `empty`

Rule names are memoized internally for performance.

Expand All @@ -77,10 +77,10 @@ User-extracted keys (IP addresses, usernames, API keys, etc.) are hashed with SH
```

This ensures:
- **Fixed length** -- regardless of input length, the cache key is always the same size
- **No special characters** -- hex output is always safe for any cache backend
- **Collision-free** -- SHA-256 has negligible collision probability
- **No memory leak** -- unlike memoized normalization, hashing is stateless and safe for long-running processes
- **Fixed length** - regardless of input length, the cache key is always the same size
- **No special characters** - hex output is always safe for any cache backend
- **Collision-free** - SHA-256 has negligible collision probability
- **No memory leak** - unlike memoized normalization, hashing is stateless and safe for long-running processes

### Examples

Expand Down Expand Up @@ -171,15 +171,15 @@ Different cache backends have different key constraints:
| File-based | OS path limit (~260 chars) | `/`, `\`, `NUL` |
| Memcached | 250 bytes | Spaces, control characters |

SHA-256 hashing ensures user keys are always exactly 64 hex characters, safe across all backends.
SHA-256 hashing ensures user keys are always exactly 64 hex characters, safe across all backends. The Memcached and File-based rows are general PSR-16 examples; they are not bundled backends (Phirewall ships `InMemoryCache`, `ApcuCache`, `RedisCache`, and `PdoCache`).

### Security

Without normalization, user-supplied values (like User-Agent strings, API keys, or email addresses) could:

- **Enable bypass attacks** -- padding, encoding, or case variations create distinct keys for the same identity
- **Exhaust cache memory** -- long user-agents or query strings create bloated keys
- **Leak sensitive data** -- raw keys may appear in cache monitoring tools; hashed keys are opaque
- **Enable bypass attacks** - padding, encoding, or case variations create distinct keys for the same identity
- **Exhaust cache memory** - long user-agents or query strings create bloated keys
- **Leak sensitive data** - raw keys may appear in cache monitoring tools; hashed keys are opaque

## Discriminator Normalizer vs. Key Extractor Normalization

Expand All @@ -199,10 +199,10 @@ Use `$config->setKeyPrefix()` to change the prefix and avoid collisions when sha

```php
$config->setKeyPrefix('myapp');
// Keys become: myapp:throttle:..., myapp:fail2ban:..., etc.
// Keys become: myapp.throttle..., myapp.fail2ban..., etc.
```

The prefix itself is validated -- it cannot be empty.
The prefix is validated: it is trimmed (whitespace and a trailing `:` stripped), must be non-empty, and may not contain a PSR-16 reserved character (`{}()/\@:`) or any control/whitespace character; otherwise `setKeyPrefix()` throws `InvalidArgumentException` at the call site.

## Best Practices

Expand Down
Loading
Loading