Skip to content
Open
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 @@ -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
2 changes: 1 addition & 1 deletion docs/advanced/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ 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 no measurable overhead compared to the previous monolithic implementation. Each evaluator is a lightweight, stateless object (except `Fail2BanEvaluator` and `Allow2BanEvaluator`, which are retained for post-handler failure processing). 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.

Expand Down
80 changes: 80 additions & 0 deletions docs/advanced/config-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
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 built independently — frequently rebuilt from a PortableConfig.
$vendorBaseline = $vendorPortable->toConfig($cache); // shared product defaults
$environmentLayer = $envPortable->toConfig($cache); // staging vs. production
$tenantLayer = $tenantPortable->toConfig($cache); // per-customer policy
$deploymentTweak = (new Config($cache))->setFailOpen(false);

// Later layers win. These two calls are equivalent:
$effective = $vendorBaseline->mergedWith($environmentLayer, $tenantLayer, $deploymentTweak);
$effective = Config::compose($vendorBaseline, $environmentLayer, $tenantLayer, $deploymentTweak);
```

`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 — see its row above — 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)`.

## 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.
16 changes: 8 additions & 8 deletions docs/advanced/discriminator-normalizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ When a request is evaluated, the key goes through two normalization stages:
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,15 +53,15 @@ 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 `_`
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`
Expand Down Expand Up @@ -171,7 +171,7 @@ 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

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion docs/advanced/infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ RewriteRule ^(.*)$ index.php [L]
# BEGIN Phirewall
Require not ip 192.168.1.101
Require not ip 10.0.0.50
Require not ip 2001:db8::1
Require not ip 2001:db8::5
# END Phirewall

# More custom rules (preserved)
Expand Down
4 changes: 4 additions & 0 deletions docs/advanced/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,10 @@ $maskedIp = preg_replace('/\.\d+$/', '.xxx', $event->key);
$this->logger->info('Event', ['key' => $maskedIp]);
```

::: warning Keys can be secrets
The discriminator in event payloads (`$event->key`) and in the ban-registry cache entry is the **raw** value the rule keyed on. When a rule keys on a credential-bearing header, that is a live secret — never log it verbatim and never expose the ban registry. Key such rules with `KeyExtractors::hashedHeader()` so only a sha256 fingerprint is stored and emitted.
:::

## Related Pages

- [Track & Notifications](/advanced/track-notifications) -- track rules, thresholds, and notification patterns
Expand Down
Loading
Loading