diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 02056b8..3d023b6 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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' }, diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index d5a1e08..d8996c6 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -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. diff --git a/docs/advanced/config-composition.md b/docs/advanced/config-composition.md new file mode 100644 index 0000000..a54eb4c --- /dev/null +++ b/docs/advanced/config-composition.md @@ -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. diff --git a/docs/advanced/discriminator-normalizer.md b/docs/advanced/discriminator-normalizer.md index f22cca5..ee215ef 100644 --- a/docs/advanced/discriminator-normalizer.md +++ b/docs/advanced/discriminator-normalizer.md @@ -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: ← Real IP -phirewall:throttle:api: ← Trailing space -phirewall:throttle:api: ← Leading space +phirewall.throttle.api. ← Real IP +phirewall.throttle.api. ← Trailing space +phirewall.throttle.api. ← 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. @@ -53,7 +53,7 @@ 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 @@ -61,7 +61,7 @@ Phirewall's `CacheKeyGenerator` produces cache keys in this format: 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` @@ -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 @@ -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 diff --git a/docs/advanced/infrastructure.md b/docs/advanced/infrastructure.md index 9ddebfa..cc1cc7f 100644 --- a/docs/advanced/infrastructure.md +++ b/docs/advanced/infrastructure.md @@ -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) diff --git a/docs/advanced/observability.md b/docs/advanced/observability.md index 781bbe6..ee96589 100644 --- a/docs/advanced/observability.md +++ b/docs/advanced/observability.md @@ -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 diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md new file mode 100644 index 0000000..f5f4345 --- /dev/null +++ b/docs/advanced/portable-config.md @@ -0,0 +1,206 @@ +--- +outline: deep +--- + +# Portable Config + +`PortableConfig` expresses a firewall ruleset as plain, JSON-serializable data instead of PHP closures. Because a ruleset is just data, you can: + +- **store it in a database** and reload it on change (hot-reload), +- **ship it through a config service** (etcd, Consul, S3, a settings table), +- **diff and review it in git**, or +- **share one ruleset across many apps, processes, or languages** + +…and then rebuild a live [`Config`](/getting-started) from it with `toConfig()`. 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()` → `toConfig()`: + +```php +use Flowd\Phirewall\Http\Firewall; +use Flowd\Phirewall\Pattern\PatternKind; +use Flowd\Phirewall\Portable\PortableConfig; + +$portable = PortableConfig::create() + ->setKeyPrefix('shop') + ->enableRateLimitHeaders() + ->enableResponseHeaders() + ->safelist('health', PortableConfig::filterPathEquals('/health')) + ->blocklist('admin-probe', PortableConfig::filterPathPrefix('/wp-admin')) + ->blocklist('scanners', PortableConfig::filterKnownScanners()) + ->blocklist('bad-net', PortableConfig::filterIp(['203.0.113.0/24'])) + ->throttle('api', limit: 100, period: 60, key: PortableConfig::keyHashedHeader('X-Api-Key'), sliding: true) + ->allow2ban('volume-cap', threshold: 1000, period: 60, ban: 300, key: PortableConfig::keyIp()) + ->fail2ban('login', threshold: 5, period: 60, ban: 900, filter: PortableConfig::filterHeaderEquals('X-Login-Failed', '1'), key: PortableConfig::keyIp()) + ->patternBlocklist('threats', [ + PortableConfig::patternEntry(PatternKind::CIDR, '10.66.0.0/16'), + PortableConfig::patternEntry(PatternKind::PATH_REGEX, '#/\.git(/|$)#'), + ]); + +// Export as data … +$json = json_encode($portable->toArray(), JSON_THROW_ON_ERROR); + +// … and rebuild a live Config somewhere else. +$config = PortableConfig::fromArray(json_decode($json, true, 512, JSON_THROW_ON_ERROR))->toConfig($cache); +$firewall = new Firewall($config); +``` + +(A request-header marker is forgeable; for real login-failure bans prefer the post-handler [`RequestContext::recordFailure()`](/advanced/request-context) pattern.) + +`fromArray()` validates the *shape* of the data (rule/filter/key types, regex patterns compile, pattern-entry fields) and throws `InvalidArgumentException` on anything malformed. It does **not** verify *authenticity* — for that, see [Signed transport](#signed-transport). + +## The catalogue + +Everything `PortableConfig` can express today. + +### Rules + +| Builder | Notes | +|---------|-------| +| `safelist(name, filter)` | Bypass all checks when the filter matches | +| `blocklist(name, filter)` | Deny (403) when the filter matches | +| `throttle(name, limit, period, key, sliding = false, scope = null)` | Fixed or sliding-window rate limit (429); the optional `scope` filter restricts which requests the throttle counts (e.g. only `/api`) | +| `fail2ban(name, threshold, period, ban, filter, key)` | Auto-ban after repeated matching ("bad") requests | +| `allow2ban(name, threshold, period, ban, key)` | Hard volume cap — ban after too many *total* requests for a key | +| `track(name, period, filter, key, limit = null)` | Passive counting with optional alert threshold | +| `addPatternBackend(name, entries)` | Register a reusable catalogue of block patterns | +| `blocklistFromBackend(name, backendName)` | Add a blocklist that matches against a registered backend | +| `patternBlocklist(name, entries)` | Convenience: register a backend and a blocklist under one name | + +### Filters (request predicates) + +| Factory | Matches when … | +|---------|----------------| +| `filterAll()` | always | +| `filterNone()` | never — a filter that never matches; use it for a rule that must not be assertable from any request property (e.g. a fail2ban driven solely by `RequestContext::recordFailure`) | +| `filterPathEquals(path)` | the path equals `path` | +| `filterPathPrefix(prefix)` | the path starts with `prefix` | +| `filterPathRegex(pattern)` | the path matches the PCRE `pattern` (delimiters included) | +| `filterMethodEquals(method)` | the HTTP method equals `method` (case-insensitive) | +| `filterMethodIn(methods)` | the HTTP method is one of `methods` | +| `filterHeaderEquals(name, value)` | header `name` equals `value` | +| `filterHeaderPresent(name)` | header `name` is present with any non-empty value | +| `filterHeaderRegex(name, pattern)` | header `name` matches the PCRE `pattern` | +| `filterIp(ipsOrCidrs)` | the client IP is in the list (CIDR-aware, IPv4/IPv6) — backed by `IpMatcher` | +| `filterKnownScanners(patterns = null)` | the User-Agent matches a known scanner; `null` uses the curated default list — backed by `KnownScannerMatcher` | +| `filterSuspiciousHeaders(headers = null)` | a required browser header is missing; `null` uses the default set — backed by `SuspiciousHeadersMatcher` | + +`filterIp`, `filterKnownScanners`, and `filterSuspiciousHeaders` compile to the dedicated matcher classes (so you get their diagnostics and CIDR handling); the remaining filters compile to a request-predicate closure. + +::: warning +`filterHeaderEquals` is rejected on `safelist()` (and on `fromArray()` deserialize) — a static header value would be a plaintext bypass token. It remains valid on blocklists, throttles, fail2ban, and track rules. +::: + +### Key extractors + +| Factory | Keys on | +|---------|---------| +| `keyIp()` | client IP (`REMOTE_ADDR`) | +| `keyMethod()` | HTTP method | +| `keyPath()` | request path | +| `keyHeader(name)` | raw value of header `name` | +| `keyHashedHeader(name)` | sha256 fingerprint of header `name` — preferred for credential-bearing headers (`Authorization`, `Cookie`, `X-Api-Key`) so the raw value never reaches the cache/ban registry | + +::: tip +`keyIp()` keys on `REMOTE_ADDR`, which behind a CDN or load balancer is the proxy's address, not the client's. The IP resolver is a closure and therefore not portable — set it on the rebuilt `Config` with `setIpResolver(KeyExtractors::clientIp(new TrustedProxyResolver([...])))`. See [Client IP behind proxies](/getting-started#client-ip-behind-proxies). +::: + +### Pattern kinds (`PortableConfig::patternEntry()`) + +Pattern backends carry a list of entries; each entry has a `PatternKind`: + +| Kind | Matches | +|------|---------| +| `PatternKind::IP` | exact client IP | +| `PatternKind::CIDR` | client IP within a CIDR range | +| `PatternKind::PATH_EXACT` | exact path | +| `PatternKind::PATH_PREFIX` | path prefix | +| `PatternKind::PATH_REGEX` | path PCRE pattern | +| `PatternKind::HEADER_EXACT` | named header equals value (entry `target` = header name) | +| `PatternKind::HEADER_REGEX` | named header matches PCRE pattern (entry `target` = header name) | +| `PatternKind::REQUEST_REGEX` | pattern over path + query + headers | + +`patternEntry()` also accepts optional `target`, `expiresAt`, `addedAt`, and a scalar `metadata` map — all of which round-trip as data, so an entry can carry its own expiry and provenance (handy when the catalogue lives in a database). + +### Options + +| Builder | Effect on the built `Config` | +|---------|------------------------------| +| `enableRateLimitHeaders()` | emit `X-RateLimit-*` headers | +| `enableResponseHeaders()` | emit `X-Phirewall-*` headers | +| `enableOwaspDiagnosticsHeader()` | emit the OWASP diagnostics header | +| `setFailOpen(bool)` | fail-open (default) vs fail-closed on backend errors | +| `setKeyPrefix(prefix)` | cache-key prefix | + +## Pattern backends: rules in a database, hot-reloaded + +Pattern backends are the natural fit for a block catalogue you maintain *outside* code — e.g. a `blocked_patterns` table or a threat feed. Store the serialized (ideally [signed](#signed-transport)) ruleset keyed by a version, keep the compiled `Firewall` in memory, and rebuild only when the version changes: + +```php +use Flowd\Phirewall\Http\Firewall; +use Flowd\Phirewall\Portable\PortableConfig; + +// $store->load() returns ['version' => int, 'blob' => string] from your DB. +$loadedVersion = null; +$firewall = null; + +$reload = static function () use (&$store, &$loadedVersion, &$firewall, $secret, $cache): bool { + $row = $store->load(); + if ($loadedVersion === $row['version']) { + return false; // already current — no rebuild + } + + $portable = PortableConfig::loadSigned($row['blob'], $secret); + $firewall = new Firewall($portable->toConfig($cache)); + $loadedVersion = $row['version']; + + return true; +}; +``` + +When an operator publishes a new ruleset (and bumps the version), the next `$reload()` rebuilds the firewall; otherwise it is a no-op. See [`examples/29-portable-config.php`](https://github.com/flowd/phirewall/blob/main/examples/29-portable-config.php) for a runnable version with the database simulated in memory. + +## Signed transport + +When the serialized config is read back from storage you do **not** fully control — a shared filesystem, an S3 bucket, etcd, a config service, a git repo that accepts external contributions — an attacker who can write the blob could inject an allow-all safelist and disable the firewall. `fromArray()` validates shape only, not authenticity. + +`toSignedJson()` / `loadSigned()` close that gap with an HMAC-SHA256 envelope: + +```php +$signed = $portable->toSignedJson($secretKey); //
.. +$restored = PortableConfig::loadSigned($signed, $secretKey); // verifies before returning +``` + +- The envelope is JWS-compact-style: `
..`, where the signature is HMAC-SHA256 over `
.`. +- Verification uses a constant-time `hash_equals()` compare. Any tampering — payload edit, key substitution, or an `alg=none` downgrade attempt — is rejected with a `RuntimeException` *before* the rules are applied. +- Signing keys must be at least 16 bytes; **32 random bytes is recommended** (`random_bytes(32)`), stored in your secrets manager. + +::: warning Threat model +Signing protects **integrity and authenticity**, not confidentiality — the payload is base64url-encoded, not encrypted, so anyone who can read the envelope can read the ruleset. Distribute the secret only to the producer and the consumers, rotate it like any other credential, and keep it out of the serialized blob. Signing also does not make a ruleset *safe to run* if you do not trust its author; it only proves the bytes were not altered after signing. +::: + +See [`examples/28-portable-config-signing.php`](https://github.com/flowd/phirewall/blob/main/examples/28-portable-config-signing.php) for a signing + tamper-rejection walkthrough. + +## Not portable by design + +A few capabilities cannot be represented as pure data and are intentionally **excluded** from the schema. Configure these directly on the `Config` returned by `toConfig()`: + +| 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 | +| 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 `throttle()` entries; `sliding` is supported) | +| Response factories, `ipResolver`, `discriminatorNormalizer` | these are closures / objects, not declarative data | + +## Examples + +- [`examples/28-portable-config-signing.php`](https://github.com/flowd/phirewall/blob/main/examples/28-portable-config-signing.php) — signed transport and tamper rejection. +- [`examples/29-portable-config.php`](https://github.com/flowd/phirewall/blob/main/examples/29-portable-config.php) — round-trip, signing, and a database hot-reload scenario. + +## Related pages + +- [Config Composition](/advanced/config-composition) — layer a portable ruleset under environment and tenant overlays. +- [Presets](/advanced/presets) — ready-made rule bundles, each defined as a `PortableConfig`. +- [Storage Backends](/features/storage) — the PSR-16 cache `toConfig()` needs. diff --git a/docs/advanced/presets.md b/docs/advanced/presets.md new file mode 100644 index 0000000..ccd6c97 --- /dev/null +++ b/docs/advanced/presets.md @@ -0,0 +1,84 @@ +--- +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 defined internally as a [`PortableConfig`](/advanced/portable-config) — plain, inspectable, serializable data — and exposed two ways: + +- a factory returning a live `Config` (e.g. `Presets::apiRateLimiting($cache)`), and +- an accessor returning the underlying `PortableConfig` (e.g. `Presets::apiRateLimitingPortable()`), so you can serialize, diff, sign, or layer it. + +Because presets ARE `Config`s, they layer with your own rules through [`Config::compose()` / `mergedWith()`](/advanced/config-composition), and every rule is namespaced `preset..*` so override-by-name is predictable. + +## Usage + +```php +use Flowd\Phirewall\Config; +use Flowd\Phirewall\Preset\Presets; + +// A preset on its own (a Config requires a PSR-16 cache): +$config = Presets::apiRateLimiting($cache); + +// Inspect / serialize the underlying portable schema: +$schema = Presets::apiRateLimitingPortable()->toArray(); + +// Layer a preset under your own Config — your rules win by name: +$config = Presets::loginProtection($cache)->mergedWith($myConfig); + +// Stack several presets, then your overrides last: +$config = Config::compose( + Presets::scannerBlocking($cache), + Presets::sensitivePathBlocking($cache), + Presets::apiRateLimiting($cache), + $myConfig, +); +``` + +Both factory forms accept an optional PSR-14 event dispatcher as a second argument (`Presets::apiRateLimiting($cache, $dispatcher)`), so preset rules emit the same [observability events](/advanced/observability) as hand-written ones. + +## Shipped presets + +| Preset | Rules (namespaced `preset..*`) | +|--------|--------------------------------------| +| `apiRateLimiting()` | Per-client sliding-window throttles scoped to the `/api` prefix: `preset.api.burst` (20 req/1s) and `preset.api.sustained` (300 req/60s), keyed on client IP. | +| `loginProtection()` | `preset.login.throttle` (10 attempts/60s per IP on `/login`, sliding) and `preset.login.bruteforce` fail2ban (ban the IP for 15 min after 5 failures in 15 min). | +| `scannerBlocking()` | `preset.scanner.known-tools` (known scanner/exploit User-Agents) and `preset.scanner.suspicious-headers` (requests missing the standard browser `Accept` / `Accept-Language` / `Accept-Encoding` headers). | +| `sensitivePathBlocking()` | `preset.sensitive-path.probes` — pattern blocklist for `/.git`, `/.svn`, `/.hg`, `/.env*`, `/.aws/credentials`, `/.htpasswd`, `/.htaccess`, `/.DS_Store`. | + +Each preset also has a `…Portable()` accessor returning the `PortableConfig`, and the generic `Presets::portable($name)` / `Presets::config($name, $cache)` resolve a preset by one of the `Presets::names()` constants. + +## Conventions and overrides + +- `apiRateLimiting()` scopes its throttles to the `/api` path prefix; `loginProtection()` scopes its login throttle to `/login`. +- The login fail2ban (`preset.login.bruteforce`) is **driven exclusively** by your login handler calling `$context->recordFailure(Presets::LOGIN_FAILURE_RULE)` after a failed authentication; that recorded-signal path bans on the rule's IP key and bypasses the filter. The rule uses a deliberately never-match filter so it cannot be tripped by any spoofable/forgeable request property — a forged marker header would otherwise let an attacker drive failures for an arbitrary client and, behind a shared proxy/CDN, ban everyone. See [Request Context](/advanced/request-context). +- Override any rule by composing the preset with your own `Config` that redefines the rule by the same name (later layer wins), or by rebuilding the `…Portable()` schema. +- IP-keyed rules resolve the client from `REMOTE_ADDR`. Behind a load balancer or CDN, layer your own throttle keyed on a trusted client IP (see `KeyExtractors::clientIp()` with a [`TrustedProxyResolver`](/getting-started#client-ip-behind-proxies)) or on the authenticated principal, overriding the preset rule by name. + +> **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. + +## Versioning and update checks + +`Presets::VERSION` identifies the bundled rule catalogue and is bumped whenever a preset's rule set changes in a way integrators should review. `Presets::version()` is a convenience accessor for the same value. + +To surface "a newer ruleset is available", implement the `PresetUpdateChecker` interface against a source you trust and compare against `Presets::VERSION`: + +```php +interface PresetUpdateChecker +{ + public function latestVersion(string $preset): ?string; + public function isOutdated(string $preset, string $currentVersion): bool; +} +``` + +**Phirewall hardcodes no remote endpoint and performs no network I/O.** The shipped `NullPresetUpdateChecker` never reports an update (`latestVersion()` returns `null`, `isOutdated()` returns `false`). Wiring an actual source — a Packagist release feed, an internal config service, a versioned JSON document behind HTTPS, … — is the integrator's job: implement the interface and inject it where you build your `Config`. + +## Example + +See [`examples/31-presets.php`](https://github.com/flowd/phirewall/blob/main/examples/31-presets.php) for standalone use, inspecting a preset as portable data, composing a preset with a user `Config` (overriding a rule by name), and the version / update-check seam. + +## Related pages + +- [Config Composition](/advanced/config-composition) — how presets layer with your own rules. +- [Portable Config](/advanced/portable-config) — the data format every preset is built on. +- [Fail2Ban & Allow2Ban](/features/fail2ban) — the brute-force mechanism behind `loginProtection()`. diff --git a/docs/advanced/request-context.md b/docs/advanced/request-context.md index 5d0f1f0..c263731 100644 --- a/docs/advanced/request-context.md +++ b/docs/advanced/request-context.md @@ -4,7 +4,7 @@ outline: deep # Request Context -The `RequestContext` API lets your application signal **fail2ban failures** and **allow2ban hits** from inside the request handler -- after the firewall has already passed the request through. This solves a fundamental limitation: standard pre-handler filters cannot see whether credentials were valid, whether a payment failed, or whether an API key was revoked. +The `RequestContext` API lets your application signal post-handler events -- fail2ban **failures** via `recordFailure()` and allow2ban **hits** via `recordHit()` -- **from inside the request handler**, after the firewall has already passed the request through. This solves a fundamental limitation: standard fail2ban and allow2ban filters run _before_ your handler, so they cannot see whether credentials were valid, whether a payment failed, or whether an API key was revoked. ## The Problem @@ -42,8 +42,8 @@ Here is what happens step by step: 1. The middleware calls the firewall's `decide()` method on the incoming request 2. If the request passes (is not blocked), the middleware creates a `RequestContext` and attaches it to the request as a PSR-7 attribute named `phirewall.context` 3. Your handler receives the request with the attached context -4. If your handler determines that the request represents a failure, it calls `$context->recordFailure('rule-name')`. For an allow2ban hit, it calls `$context->recordHit('rule-name')` instead. The key is derived from the matching rule's `keyExtractor`; pass an explicit second argument only when the handler knows a value the firewall cannot derive (e.g. a user id from a session). -5. After your handler returns a response, the middleware processes each recorded signal through the matching counter engine +4. If your handler determines that the request represents a failure (wrong password, invalid API key, etc.), it calls `$context->recordFailure('rule-name')`. For an allow2ban hit, it calls `$context->recordHit('rule-name')` instead. The key is derived from the matching rule's `keyExtractor`; pass an explicit second argument (`$key`) only when the handler knows a value the firewall cannot derive (e.g. a user id from a session). +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 ## Setup @@ -117,20 +117,18 @@ $context?->recordFailure('login-failures', $userIdFromSession); ``` ::: warning Rule name must match -The first parameter to `recordFailure()` must **exactly** match the `name` you used in `$config->fail2ban->add()`. If no matching rule is found, the signal is silently ignored. +The first parameter to `recordFailure()` must **exactly** match the `name` you used in `$config->fail2ban->add()` (and likewise `recordHit()` must match a `$config->allow2ban->add()` rule). If no matching rule is found, the signal is silently ignored. ::: -## Recording Hits for Allow2Ban +## Recording allow2ban Hits -`recordHit()` is the allow2ban counterpart of `recordFailure()`. It signals that something countable happened during the handler (e.g. an expensive operation completed, a webhook delivered a duplicate payload, a third-party API quota was charged) so the count can drive an allow2ban threshold ban: +`recordHit()` is the allow2ban counterpart of `recordFailure()`. The same context records **allow2ban** hits -- use it to count handler-observable events the pre-handler path cannot see (an expensive operation completed, a webhook delivered a duplicate payload, a third-party API quota was charged) so the count can drive an allow2ban threshold ban. It mirrors `recordFailure()`, and `$key` is likewise optional -- omit it to reuse the matching rule's key extractor on the current request. + +First, configure an allow2ban rule. To make the rule count *only* the events recorded by the handler (not every request), have the rule's `keyExtractor` return `null` pre-handler -- the firewall then skips counting until the handler signals an explicit key via `recordHit()`: ```php use Flowd\Phirewall\KeyExtractors; -// Configure an allow2ban rule. To make the rule count *only* the events -// recorded by the handler (not every request), have the rule's keyExtractor -// return null pre-handler -- the firewall then skips counting until the -// handler signals an explicit key via recordHit(). $config->allow2ban->add( 'expensive-endpoint', threshold: 5, @@ -151,7 +149,16 @@ if ($context !== null && $this->operationWasExpensive($request)) { } ``` -If the rule's `keyExtractor` returns a value pre-handler (the common case), the second argument to `recordHit()` can be omitted -- the firewall derives the key the same way it does for `recordFailure()`. Note that in that case **both** the pre-handler counter and the handler's `recordHit()` increment the counter, so the threshold should account for the doubled count. +If the rule's `keyExtractor` returns a value pre-handler (the common case), the second argument to `recordHit()` can be omitted -- the firewall derives the key the same way it does for `recordFailure()`: + +```php +// Omitting $key reuses the rule's own key extractor on this request. +$context?->recordHit('expensive-endpoint'); +``` + +Note that when the rule's `keyExtractor` returns a value pre-handler, **both** the pre-handler counter and the handler's `recordHit()` increment the counter, so the threshold should account for the doubled count. + +Recorded failures and hits are processed together after your handler returns; retrieve them all with `getRecordedSignals()`. ## API Reference @@ -161,10 +168,10 @@ The `RequestContext` class is a mutable recorder that the middleware attaches to | Method | Signature | Description | |--------|-----------|-------------| -| `recordFailure()` | `(string $ruleName, ?string $key = null): void` | Record a fail2ban failure signal | -| `recordHit()` | `(string $ruleName, ?string $key = null): void` | Record an allow2ban hit signal | +| `recordFailure()` | `(string $ruleName, ?string $key = null): void` | Record a fail2ban **failure** signal | +| `recordHit()` | `(string $ruleName, ?string $key = null): void` | Record an allow2ban **hit** signal | | `getResult()` | `(): FirewallResult` | Access the pre-handler firewall decision | -| `getRecordedSignals()` | `(): list` | Get all recorded signals (fail2ban + allow2ban) | +| `getRecordedSignals()` | `(): list` | Get all recorded signals (failures and hits) | | `hasRecordedSignals()` | `(): bool` | Whether any signals have been recorded | **Constants:** @@ -175,20 +182,22 @@ The `RequestContext` class is a mutable recorder that the middleware attaches to ### recordFailure() / recordHit() Parameters +Both methods take the same parameters: + | Parameter | Type | Description | |-----------|------|-------------| -| `$ruleName` | `string` | Must match the `name` of a configured `fail2ban->add()` (for `recordFailure`) or `allow2ban->add()` (for `recordHit`) rule | -| `$key` | `?string` | Optional discriminator override. When `null` (the default), the firewall extracts the key from the rule's own `keyExtractor` against the current request. | +| `$ruleName` | `string` | Must match the `name` of a configured `fail2ban->add()` rule (for `recordFailure()`) or `allow2ban->add()` rule (for `recordHit()`) | +| `$key` | `?string` | The discriminator key to count against (e.g., IP address, username). **Optional** -- when omitted (`null`), the firewall applies the matching rule's own key extractor to the current request, so your handler does not need to repeat the rule's keying logic. | ### RecordedSignal -An immutable value object representing a single recorded signal. +An immutable value object representing a single recorded signal (the elements returned by `getRecordedSignals()`). | Property | Type | Description | |----------|------|-------------| | `$ruleName` | `string` | The fail2ban or allow2ban rule this signal is recorded against | | `$banType` | `BanType` | `BanType::Fail2Ban` (from `recordFailure()`) or `BanType::Allow2Ban` (from `recordHit()`) | -| `$key` | `?string` | The discriminator override, or `null` to defer to the rule's `keyExtractor` | +| `$key` | `?string` | The discriminator key override, or `null` to defer to the matching rule's key extractor | ## Accessing the Firewall Decision @@ -203,7 +212,7 @@ $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); if ($context !== null) { $result = $context->getResult(); - $result->outcome->value; // 'passed', 'safelisted', etc. + $result->outcome->value; // 'pass', 'safelisted', etc. $result->isPass(); // true if the request was allowed through $result->rule; // Name of the matching rule (null if simply passed) } diff --git a/docs/advanced/track-notifications.md b/docs/advanced/track-notifications.md index d2e80b8..e461eff 100644 --- a/docs/advanced/track-notifications.md +++ b/docs/advanced/track-notifications.md @@ -355,7 +355,7 @@ The returned array is organized by category, each with a total and a breakdown b ] ``` -Categories tracked: `safelisted`, `blocklisted`, `throttle_exceeded`, `fail2ban_banned`, `track_hit`, `passed`, `fail2ban_blocked`. +Categories tracked: `safelisted`, `blocklisted`, `throttle_exceeded`, `fail2ban_banned`, `allow2ban_banned`, `track_hit`, `passed`, `fail2ban_blocked`. ### Exposing as a Prometheus-Style Metrics Endpoint diff --git a/docs/common-attacks.md b/docs/common-attacks.md index caf305f..e86cb44 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -10,19 +10,51 @@ Ready-to-use Phirewall configurations for defending against common web applicati Protect login endpoints with layered rate limiting and fail2ban. -### Fail2Ban on Login Failures +### Post-Handler Failure Signaling (recommended) -Ban IPs after repeated failed login attempts. The `filter` predicate determines what counts as a failure: +The accurate way to ban on *real* failed logins is to record the failure **after** your handler has verified the credentials, using [RequestContext](/features/fail2ban#post-handler-signaling-with-requestcontext). The fail2ban rule's filter never matches on its own (`fn() => false`); your handler decides what counts as a failure and records it, and the middleware processes the recorded signal once the handler returns. This is the pattern shown in [`examples/02-brute-force-protection.php`](https://github.com/flowd/phirewall/blob/main/examples/02-brute-force-protection.php). ```php use Flowd\Phirewall\Config; +use Flowd\Phirewall\Context\RequestContext; use Flowd\Phirewall\KeyExtractors; use Flowd\Phirewall\Store\RedisCache; use Psr\Http\Message\ServerRequestInterface; $config = new Config(new RedisCache($redis)); -// Ban after 5 failed logins in 5 minutes for 1 hour +// Ban after 3 verified failures in 5 minutes for 1 hour. +// The filter never matches — failures are signaled by the handler. +$config->fail2ban->add('login-failures', + threshold: 3, period: 300, ban: 3600, + filter: fn(ServerRequestInterface $req): bool => false, + key: KeyExtractors::ip(), +); + +// In your login handler, AFTER checking credentials: +if (!$this->authenticate($username, $password)) { + $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); + // As of 0.5.0 the key argument is optional — when omitted, the rule's own + // key extractor (here KeyExtractors::ip()) resolves the discriminator. + $context?->recordFailure('login-failures'); +} +``` + +Only genuine failures are counted, so a user who logs in correctly on the first try is never one attempt closer to a ban. + +### Fail2Ban on a Request Marker + +If you cannot integrate `RequestContext` (for example, the auth check lives in a separate service), a fail2ban filter can count a marker header instead. The filter inspects the **incoming request**, so the marker must be set by a **trusted middleware that runs before Phirewall** — never by the login handler, which runs *after* the firewall and can only set *response* headers that the pre-handler filter will never see: + +```php +use Flowd\Phirewall\Config; +use Flowd\Phirewall\KeyExtractors; +use Flowd\Phirewall\Store\RedisCache; +use Psr\Http\Message\ServerRequestInterface; + +$config = new Config(new RedisCache($redis)); + +// Ban after 5 marked failures in 5 minutes for 1 hour. $config->fail2ban->add('login-brute-force', threshold: 5, period: 300, @@ -35,29 +67,11 @@ $config->fail2ban->add('login-brute-force', ); ``` -Your login handler sets the `X-Login-Failed` header on failed attempts before the response is returned. - -### Post-Handler Failure Signaling - -For more precise control, use [RequestContext](/features/fail2ban#post-handler-signaling-with-requestcontext) to signal failures only after verifying credentials: - -```php -use Flowd\Phirewall\Context\RequestContext; - -$config->fail2ban->add('login-failures', - threshold: 3, period: 300, ban: 3600, - filter: fn($req): bool => false, // Never counts automatically - key: KeyExtractors::ip(), -); +The `X-Login-Failed` **request** header must be set by a trusted upstream component **before** Phirewall evaluates the request — not by the login handler, which runs *after* the firewall and can only set response headers the pre-handler filter never sees. -// In your login handler: -if (!$this->authenticate($username, $password)) { - $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); - // No second argument needed -- the firewall extracts the key from the - // rule's own keyExtractor against this request. - $context?->recordFailure('login-failures'); -} -``` +::: warning +Trust the `X-Login-Failed` marker only if an upstream component your application controls sets it — and strip any inbound copy of that header at the edge, so a client cannot forge it. When in doubt, prefer the post-handler `RequestContext` approach above. +::: ### Login Endpoint Throttle @@ -239,7 +253,7 @@ Block known attack tools (sqlmap, nikto, nuclei, etc.) with a single call: $config->blocklists->knownScanners(); ``` -The default list covers ~25 tools. Extend or replace it: +The default list covers 24 tools. Extend or replace it: ```php use Flowd\Phirewall\Matchers\KnownScannerMatcher; @@ -379,6 +393,10 @@ $config->throttles->add('api-key', ); ``` +::: warning Header keys are client-controlled +A throttle, fail2ban, or allow2ban rule keyed on a request header (`X-Api-Key`, `X-User-Id`, …) is only as trustworthy as that header. A client can rotate or drop the header to land in a fresh counter on every request and never reach the threshold — a trivial bypass. Key such rules on a value the client cannot freely change: the client IP (via `KeyExtractors::clientIp()` with a `TrustedProxyResolver`), the authenticated principal your auth layer sets *after* verifying it, or a composite of both. When you must key on a credential-bearing header, use `KeyExtractors::hashedHeader('X-Api-Key')` — the raw value otherwise reaches the ban registry and event payloads (and your logs) in cleartext. +::: + ### Expensive Endpoint Protection Apply stricter limits to resource-intensive endpoints: @@ -515,7 +533,7 @@ Track → Safelist → Blocklist → Fail2Ban → Throttle → Allow2Ban → Pas 2. **Safelist your health checks.** Internal monitoring endpoints should bypass all firewall rules to avoid false alerts. -3. **Use `clientIp()` behind proxies.** If your application runs behind a load balancer or CDN, configure a `TrustedProxyResolver` so rate limits and bans apply to the real client IP. +3. **Use `clientIp()` behind proxies.** If your application runs behind a load balancer or CDN, configure a `TrustedProxyResolver` so rate limits and bans apply to the real client IP — raw `KeyExtractors::ip()` would collapse every client onto the proxy's address. See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies). 4. **Start with logging, then enforce.** Use [Track rules](/advanced/track-notifications) to observe traffic patterns before enabling blocking rules. diff --git a/docs/examples.md b/docs/examples.md index 4741c8a..677f967 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,7 +8,7 @@ Complete, copy-pasteable configurations for common scenarios. Each example is se ## Running the Built-in Examples -The Phirewall repository includes 27 runnable examples: +The Phirewall repository includes 31 runnable examples: ```bash git clone https://github.com/flowd/phirewall @@ -46,6 +46,10 @@ php examples/01-basic-setup.php | 25 | [track-threshold](https://github.com/flowd/phirewall/blob/main/examples/25-track-threshold.php) | Track with threshold for alerting | | 26 | [psr17-factories](https://github.com/flowd/phirewall/blob/main/examples/26-psr17-factories.php) | PSR-17 response factory integration | | 27 | [request-context](https://github.com/flowd/phirewall/blob/main/examples/27-request-context.php) | Post-handler fail2ban signaling | +| 28 | [portable-config-signing](https://github.com/flowd/phirewall/blob/main/examples/28-portable-config-signing.php) | Signed PortableConfig transport (HMAC-SHA256) | +| 29 | [portable-config](https://github.com/flowd/phirewall/blob/main/examples/29-portable-config.php) | PortableConfig as a first-class transport with DB hot-reload | +| 30 | [config-composition](https://github.com/flowd/phirewall/blob/main/examples/30-config-composition.php) | Layering configs (vendor → environment → tenant → deployment) | +| 31 | [presets](https://github.com/flowd/phirewall/blob/main/examples/31-presets.php) | Ready-to-use rule presets and the update-check seam | --- @@ -179,7 +183,11 @@ echo 'Status: ' . $response->getStatusCode() . "\n"; ### Symfony -Requires `symfony/psr-http-message-bridge` and `nyholm/psr7`. Phirewall runs as a PSR-15 middleware wrapped by Symfony's PSR bridge. +Requires `symfony/psr-http-message-bridge` and `nyholm/psr7`. Phirewall runs as a PSR-15 middleware wrapped by Symfony's PSR bridge — the bridge factories (`HttpMessageFactoryInterface`, `HttpFoundationFactoryInterface`) and the `nyholm/psr7` PSR-17 factory then autowire into the listener below. + +::: warning +This bridge runs Phirewall with a pass-through handler, so the `RequestContext` attribute it attaches for app-recorded fail2ban/allow2ban signals lives on the throwaway PSR request and is not visible to your Symfony controllers. Use the pre-handler rule filters for blocking; post-handler `recordFailure()`/`recordHit()` from a controller is not propagated by this basic bridge. +::: **`src/Factory/PhirewallFactory.php`** @@ -220,8 +228,11 @@ class PhirewallFactory $config->setFailOpen(true); // ── Trusted Proxies ────────────────────────────────────── - if ($this->trustedProxies !== []) { - $proxyResolver = new TrustedProxyResolver($this->trustedProxies); + // Drop empty entries so an unset/blank env var disables the + // resolver instead of registering an empty proxy list. + $trustedProxies = array_values(array_filter($this->trustedProxies)); + if ($trustedProxies !== []) { + $proxyResolver = new TrustedProxyResolver($trustedProxies); $config->setIpResolver( KeyExtractors::clientIp($proxyResolver) ); @@ -294,75 +305,87 @@ class PhirewallFactory services: App\Factory\PhirewallFactory: arguments: - $trustedProxies: ['10.0.0.0/8', '172.16.0.0/12'] + $trustedProxies: '%env(csv:PHIREWALL_TRUSTED_PROXIES)%' Flowd\Phirewall\Middleware: factory: ['@App\Factory\PhirewallFactory', 'create'] ``` -**`src/EventSubscriber/PhirewallSubscriber.php`** +Set the proxy CIDRs in your environment (e.g. `.env`): `PHIREWALL_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12`. The listener below auto-registers via `#[AsEventListener]` + autoconfigure — no manual `tags:` entry needed. + +**`src/EventListener/PhirewallListener.php`** + +A two-phase listener: it runs Phirewall on `kernel.request` (blocking early when a rule fires) and re-attaches the `X-RateLimit-*` headers Phirewall adds on the allowed path during `kernel.response`. A single-phase subscriber that only acts when the status is non-200 would silently drop those headers. ```php ['onKernelRequest', 256]]; + if (!$event->isMainRequest()) { + return; + } + $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); + $psrResponse = $this->middleware->process($psrRequest, $this->passThroughHandler()); + if ($psrResponse->getStatusCode() === 200) { + if ($psrResponse->getHeaders() !== []) { + $event->getRequest()->attributes->set(self::HEADERS_ATTRIBUTE, $psrResponse->getHeaders()); + } + return; + } + $event->setResponse($this->httpFoundationFactory->createResponse($psrResponse)); } - public function onKernelRequest(RequestEvent $event): void + #[AsEventListener(event: KernelEvents::RESPONSE)] + public function onKernelResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; } + /** @var array> $headers */ + $headers = $event->getRequest()->attributes->get(self::HEADERS_ATTRIBUTE, []); + foreach ($headers as $name => $values) { + $event->getResponse()->headers->set($name, $values); + } + } - $psr17 = new Psr17Factory(); - $psrFactory = new PsrHttpFactory($psr17, $psr17, $psr17, $psr17); - $httpFoundationFactory = new HttpFoundationFactory(); - - // Convert Symfony request to PSR-7 - $psrRequest = $psrFactory->createRequest($event->getRequest()); - - // Run Phirewall as a pass-through handler - $psrResponse = $this->middleware->process( - $psrRequest, - new class ($psr17) implements \Psr\Http\Server\RequestHandlerInterface { - public function __construct(private readonly Psr17Factory $factory) {} - - public function handle( - \Psr\Http\Message\ServerRequestInterface $request, - ): \Psr\Http\Message\ResponseInterface { - // Return 200 -- Symfony continues processing - return $this->factory->createResponse(200); - } + private function passThroughHandler(): RequestHandlerInterface + { + return new class ($this->responseFactory) implements RequestHandlerInterface { + public function __construct(private readonly ResponseFactoryInterface $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->responseFactory->createResponse(200); } - ); - - // If Phirewall blocked the request, short-circuit - if ($psrResponse->getStatusCode() !== 200) { - $event->setResponse( - $httpFoundationFactory->createResponse($psrResponse) - ); - } + }; } } ``` @@ -371,7 +394,15 @@ class PhirewallSubscriber implements EventSubscriberInterface ### Laravel -Requires `nyholm/psr7`. Register the service provider and add the middleware to your HTTP kernel. +`Flowd\Phirewall\Middleware` is a PSR-15 middleware (`process(...)`), **not** a Laravel middleware (`handle($request, $next)`) — registering the class directly throws. A thin bridge middleware adapts it. Install the bridge: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +::: warning +This bridge runs Phirewall with a probe handler, so the `RequestContext` attribute it attaches for app-recorded fail2ban/allow2ban signals lives on the throwaway PSR request and is not visible to your Laravel controllers. Use the pre-handler rule filters for blocking; post-handler `recordFailure()`/`recordHit()` from a controller is not propagated by this basic bridge. +::: **`app/Providers/PhirewallServiceProvider.php`** @@ -391,11 +422,21 @@ use Flowd\Phirewall\Store\ApcuCache; use Illuminate\Support\ServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ServerRequestInterface; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; class PhirewallServiceProvider extends ServiceProvider { public function register(): void { + // PSR-7/PSR-17 bridge factories used by the Phirewall middleware. + // HttpFoundationFactory has a no-arg constructor, so Laravel + // autowires it without an explicit binding. + $this->app->singleton(Psr17Factory::class); + $this->app->singleton(PsrHttpFactory::class, fn ($app) => new PsrHttpFactory( + $app->make(Psr17Factory::class), $app->make(Psr17Factory::class), + $app->make(Psr17Factory::class), $app->make(Psr17Factory::class), + )); + $this->app->singleton(PhirewallMiddleware::class, function () { // ── Storage ────────────────────────────────────────── // ApcuCache requires ext-apcu (zero config, single-server) @@ -489,24 +530,94 @@ class PhirewallServiceProvider extends ServiceProvider } ``` -**`bootstrap/app.php`** (Laravel 11+) +**`app/Http/Middleware/Phirewall.php`** + +The bridge adapts the PSR-15 engine to Laravel's middleware contract. It uses a probe handler so the real Laravel response is never round-tripped through PSR-7 — `StreamedResponse`/`BinaryFileResponse` and other special responses are preserved. On the allowed path it copies Phirewall's `X-RateLimit-*` headers onto the real response. ```php -use Flowd\Phirewall\Middleware as PhirewallMiddleware; +psrHttpFactory->createRequest($request); + $probe = new class($this->psr17) implements RequestHandlerInterface { + private bool $invoked = false; + public function __construct(private readonly Psr17Factory $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->invoked = true; + return $this->responseFactory->createResponse(); + } + public function wasInvoked(): bool { return $this->invoked; } + }; + $psrResponse = $this->firewall->process($psrRequest, $probe); + if (! $probe->wasInvoked()) { + return $this->httpFoundationFactory->createResponse($psrResponse); + } + $response = $next($request); + foreach ($psrResponse->getHeaders() as $name => $values) { + $response->headers->set($name, $values); + } + return $response; + } +} +``` + +Register the service provider in `bootstrap/providers.php` (Laravel 11+) or the `providers` array in `config/app.php` (Laravel 10 and earlier). + +**`bootstrap/app.php`** (Laravel 11/12) + +```php +withMiddleware(function (Middleware $middleware) { - // Run Phirewall as the outermost middleware - $middleware->prepend(PhirewallMiddleware::class); + ->withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prepend(Phirewall::class); }) - ->create(); + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); ``` **`app/Http/Kernel.php`** (Laravel 10 and earlier) ```php protected $middleware = [ - \Flowd\Phirewall\Middleware::class, // outermost -- before everything + \App\Http\Middleware\Phirewall::class, // outermost -- before everything // ... other global middleware ]; ``` @@ -1527,7 +1638,7 @@ $dispatcher = new class ($logger) implements EventDispatcherInterface { $event instanceof BlocklistMatched => $this->logger->warning('Request blocklisted', $context), $event instanceof ThrottleExceeded => $this->logger->notice('Rate limited', $context), $event instanceof SafelistMatched => $this->logger->debug('Safelisted', $context), - $event instanceof FirewallError => $this->logger->error('Firewall error', ['error' => $event->throwable->getMessage()]), + $event instanceof FirewallError => $this->logger->error('Firewall error', ['error' => $event->exception->getMessage()]), default => null, }; diff --git a/docs/faq.md b/docs/faq.md index 0ec7bb1..ef5ebea 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -96,8 +96,16 @@ $config->throttles->add('api', limit: 100, period: 60, ); ``` +A few 0.5.0 specifics: + +- **`allowedHeaders` defaults to `['X-Forwarded-For']`** (a single header). If your stack emits the RFC 7239 `Forwarded` header, pass it explicitly: `new TrustedProxyResolver([...], ['Forwarded'])`. +- **Only the last `X-Forwarded-For` / `Forwarded` instance is trusted** — a duplicate header line prepended by a client is ignored. +- **IPv6 is canonicalized** — IPv4-mapped peers (`::ffff:1.2.3.4`) match IPv4 rules, and alternate IPv6 spellings are treated as one identity by `ip()` / CIDR list matching (rate-limit and ban keys use the spelling the resolver returns). + +See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies) for the full behavior. + ::: danger -Never trust `X-Forwarded-For` without configuring trusted proxies. An attacker can spoof this header to bypass rate limiting. +`KeyExtractors::ip()` reads `REMOTE_ADDR`, which behind a CDN or load balancer is the *proxy's* address — so every client collapses onto one key. Always install a client-IP resolver in that case. And never trust `X-Forwarded-For` without configuring trusted proxies: an attacker can otherwise spoof this header to bypass rate limiting. ::: ### What happens when the cache backend is unavailable? @@ -193,7 +201,7 @@ $config->fail2ban('login', 5, 300, 3600, filter: ..., key: ...); // New (section API) $config->safelists->add('health', fn($request) => ...); $config->throttles->add('ip', 100, 60, fn($request) => ...); -$config->fail2ban->add('login', threshold: 5, period: 300, banSeconds: 3600, filter: ..., key: ...); +$config->fail2ban->add('login', threshold: 5, period: 300, ban: 3600, filter: ..., key: ...); ``` See the [Getting Started](/getting-started) guide for the full section API reference. diff --git a/docs/features/bot-detection.md b/docs/features/bot-detection.md index 355155b..60b1baa 100644 --- a/docs/features/bot-detection.md +++ b/docs/features/bot-detection.md @@ -8,7 +8,7 @@ Phirewall provides three specialized matchers for bot and scanner detection: **K ## Known Scanner Blocking -The `knownScanners()` method blocks requests whose User-Agent matches known attack tools and vulnerability scanners. It ships with a curated default list covering 25+ well-known tools. +The `knownScanners()` method blocks requests whose User-Agent matches known attack tools and vulnerability scanners. It ships with a curated default list covering 24 well-known tools. ### Quick Setup diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index cdf81ac..73aff2a 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -87,6 +87,10 @@ $config->fail2ban->add('login-brute-force', Counting every POST to `/login` is simpler and works well for most applications. Legitimate users who log in successfully within the threshold are unaffected. Set a generous enough threshold (5-10) so users who mistype their password are not banned. ::: +::: tip Skip the boilerplate with a preset +The [`loginProtection()` preset](/advanced/presets) bundles a login throttle and a brute-force fail2ban rule, ready to compose with your own `Config`. See [Presets](/advanced/presets). +::: + ### Credential Stuffing Defense Credential stuffing uses stolen username/password lists from data breaches. Defend against it by combining IP-based banning with user-based throttling: @@ -263,11 +267,11 @@ $context?->recordFailure('login-failures', $userIdFromSession); | Method | Description | |--------|-------------| -| `$context->recordFailure(string $ruleName, ?string $key = null)` | Record a fail2ban failure signal. `$ruleName` must match a configured fail2ban rule. When `$key` is `null` the firewall derives it from the rule's `keyExtractor`. | +| `$context->recordFailure(string $ruleName, ?string $key = null)` | Record a fail2ban failure signal. `$ruleName` must match a configured fail2ban rule name. As of 0.5.0 `$key` is **optional** — when omitted, the rule's own key extractor resolves the discriminator from the current request, so the handler no longer needs to know whether the rule keys on IP, header, or anything else. | | `$context->recordHit(string $ruleName, ?string $key = null)` | Counterpart for allow2ban rules -- same shape, routed through the allow2ban evaluator. See [Request Context](/advanced/request-context#recording-hits-for-allow2ban). | | `$context->getResult()` | Returns the `FirewallResult` from the pre-handler evaluation | | `$context->hasRecordedSignals()` | Whether any signals have been recorded | -| `$context->getRecordedSignals()` | Returns all recorded `RecordedSignal` objects | +| `$context->getRecordedSignals()` | Returns all recorded `RecordedSignal` objects (renamed from `getRecordedFailures()` / `RecordedFailure` in 0.5.0) | ::: tip Use the null-safe operator (`$context?->recordFailure(...)`) so your handler works safely both with and without the middleware in the stack -- useful in unit tests where the middleware may not be present. @@ -368,6 +372,10 @@ $config->allow2ban->add( ); ``` +::: warning Header keys are client-controlled +A throttle, fail2ban, or allow2ban rule keyed on a request header (`X-Api-Key`, `X-User-Id`, …) is only as trustworthy as that header. A client can rotate or drop the header to land in a fresh counter on every request and never reach the threshold — a trivial bypass. Key such rules on a value the client cannot freely change: the client IP (via `KeyExtractors::clientIp()` with a `TrustedProxyResolver`), the authenticated principal your auth layer sets *after* verifying it, or a composite of both. When you must key on a credential-bearing header, use `KeyExtractors::hashedHeader('X-Api-Key')` — the raw value otherwise reaches the ban registry and event payloads (and your logs) in cleartext. +::: + ### Unauthenticated Endpoint Abuse Ban clients that repeatedly access authenticated endpoints without credentials: diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index 77280a0..c23f34b 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -106,7 +106,7 @@ Phirewall supports a subset of the ModSecurity SecRule language: | `REQUEST_URI` | Full request URI including query string | | `REQUEST_METHOD` | HTTP method (GET, POST, etc.) | | `QUERY_STRING` | Raw query string | -| `REQUEST_FILENAME` | Request path without query string | +| `REQUEST_FILENAME` | Basename (final path segment), without query string | | `REQUEST_HEADERS` | All request header values | | `REQUEST_HEADERS_NAMES` | Names of all request headers | | `REQUEST_COOKIES` | All cookie values | @@ -342,7 +342,7 @@ insert into ``` ::: warning -`@pmFromFile` includes path traversal protection. Paths containing `..` are rejected to prevent loading files outside the rules directory. +`@pmFromFile` paths are resolved relative to the rule file's directory, and `..` traversal segments are rejected. Treat SecRule files as trusted operator configuration — never build rule text from untrusted input, since the operand selects which file is read. ::: ## Architecture @@ -373,7 +373,7 @@ Each CRS variable maps to a `VariableCollectorInterface` implementation: | `REQUEST_URI` | `RequestUriCollector` | Full URI including query string | | `REQUEST_METHOD` | `RequestMethodCollector` | HTTP method | | `QUERY_STRING` | `QueryStringCollector` | Raw query string | -| `REQUEST_FILENAME` | `RequestFilenameCollector` | URI path without query string | +| `REQUEST_FILENAME` | `RequestFilenameCollector` | Basename (final path segment), without query string | | `REQUEST_HEADERS` | `RequestHeadersCollector` | All header values | | `REQUEST_HEADERS_NAMES` | `RequestHeadersNamesCollector` | Header names | | `REQUEST_COOKIES` | `RequestCookiesCollector` | All cookie values | diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index 4815b4b..e502f32 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -14,6 +14,10 @@ Three throttle strategies are available: | **Sliding window** | `sliding()` | Smooth rate limits without double-burst | | **Multi-window** | `multi()` | Combined burst + sustained limits | +::: tip +For a ready-made per-client API rate limit (burst + sustained, scoped to `/api`), the [`apiRateLimiting()` preset](/advanced/presets) ships the rules below pre-configured. +::: + ## Fixed Window Throttle The default strategy. Time is divided into fixed windows (e.g., 60-second intervals aligned to clock time) and each unique key gets a counter that resets at the end of the window. @@ -217,7 +221,7 @@ Phirewall ships with common key extractors for typical rate limiting scenarios: | `KeyExtractors::ip()` | Client IP from `REMOTE_ADDR` | `?string` | | `KeyExtractors::clientIp($resolver)` | Client IP via trusted proxy resolver | `?string` | | `KeyExtractors::header('X-User-Id')` | Raw value of a specific header | `?string` | -| `KeyExtractors::hashedHeader('X-Api-Key')` | sha256 fingerprint of a header value | `?string` | +| `KeyExtractors::hashedHeader('X-Api-Key')` | sha256 fingerprint of a header value; preferred for credential-bearing headers (raw value never stored/emitted) | `?string` | | `KeyExtractors::method()` | HTTP method (uppercase) | `?string` | | `KeyExtractors::path()` | Request path (always returns a value, never skips) | `string` | | `KeyExtractors::userAgent()` | User-Agent header value | `?string` | @@ -320,6 +324,10 @@ $config->throttles->add('api-anon', Your application's authentication middleware should set headers like `X-User-Id` and `X-Plan` on the request before it reaches the Phirewall middleware. This allows clean separation of concerns. ::: +::: warning Header keys are client-controlled +A throttle, fail2ban, or allow2ban rule keyed on a request header (`X-Api-Key`, `X-User-Id`, …) is only as trustworthy as that header. A client can rotate or drop the header to land in a fresh counter on every request and never reach the threshold — a trivial bypass. Key such rules on a value the client cannot freely change: the client IP (via `KeyExtractors::clientIp()` with a `TrustedProxyResolver`), the authenticated principal your auth layer sets *after* verifying it, or a composite of both. When you must key on a credential-bearing header, use `KeyExtractors::hashedHeader('X-Api-Key')` — the raw value otherwise reaches the ban registry and event payloads (and your logs) in cleartext. +::: + ## Rate Limit Headers Enable standard `X-RateLimit-*` headers on all responses: @@ -404,8 +412,10 @@ You can also set a global IP resolver so all IP-aware matchers use it automatica $config->setIpResolver(KeyExtractors::clientIp($resolver)); ``` +The resolver's `allowedHeaders` argument now defaults to `['X-Forwarded-For']` (a single header) — pass `['Forwarded']` explicitly if your stack emits the RFC 7239 header. Only the last forwarded-header instance is parsed, and IPv6 addresses are canonicalized (IPv4-mapped peers match IPv4 rules). See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies) for the full 0.5.0 behavior. + ::: danger -Never trust `X-Forwarded-For` without configuring trusted proxies. An attacker can spoof this header to bypass rate limiting entirely. +`KeyExtractors::ip()` keys on raw `REMOTE_ADDR` — behind a load balancer or CDN that is the proxy IP, so every client shares one throttle key and your limits stop working. Configure a `TrustedProxyResolver` so rate limits apply to the real client. And never trust `X-Forwarded-For` without configuring trusted proxies: an attacker can otherwise spoof this header to bypass rate limiting entirely. ::: ## Events diff --git a/docs/features/safelists-blocklists.md b/docs/features/safelists-blocklists.md index ed659dd..af75769 100644 --- a/docs/features/safelists-blocklists.md +++ b/docs/features/safelists-blocklists.md @@ -225,7 +225,7 @@ $config->blocklists->knownScanners( The built-in list covers: sqlmap, nikto, nmap, masscan, zmeu, havij, acunetix, nessus, openvas, w3af, dirbuster, gobuster, wfuzz, hydra, medusa, burpsuite, skipfish, whatweb, metasploit, nuclei, ffuf, feroxbuster, joomscan, and wpscan. ```php -// Use defaults -- blocks 24+ known attack tools +// Use defaults -- blocks 24 known attack tools $config->blocklists->knownScanners(); // Add custom patterns on top of defaults @@ -316,6 +316,10 @@ The file format is one entry per line, with optional expiry and timestamp fields 203.0.113.50|1711929600|1711843200 ``` +::: warning Protect the blocklist file +This file holds live security state. Store it **outside your web document root** and restrict access to the application user (e.g. `0750` directory, `0640` file). A world-readable copy leaks every banned/blocked address; a writable one lets a local attacker edit the list — including removing their own ban. +::: + ### OWASP Core Rule Set Register OWASP CRS rules as a blocklist to detect SQL injection, XSS, and other attacks: @@ -377,6 +381,10 @@ $backend->append(new PatternEntry( )); ``` +::: warning Protect the blocklist file +This file holds live security state. Store it **outside your web document root** and restrict access to the application user (e.g. `0750` directory, `0640` file). A world-readable copy leaks every banned/blocked address; a writable one lets a local attacker edit the list — including removing their own ban. +::: + ### Two-Step Registration When you need to share a backend between multiple rules or keep a reference for later modification, use the two-step approach: @@ -464,6 +472,10 @@ $entries = array_map( $config->blocklists->patternBlocklist('threat-intel', $entries); ``` +::: tip +Pattern backends are also the serializable, database-friendly equivalent of file-backed lists. To keep a block catalogue outside code — in a settings table or config service — and hot-reload it on change, express it as a [Portable Config](/advanced/portable-config). +::: + ## IP Resolution {#ip-resolution} Both `safelists->ip()` and `blocklists->ip()` respect the global IP resolver set on the Config object. This is important when your application runs behind a reverse proxy or load balancer. @@ -489,8 +501,15 @@ $customResolver = fn($req) => $req->getHeaderLine('CF-Connecting-IP') ?: null; $config->safelists->ip('cloudflare-office', '203.0.113.10', ipResolver: $customResolver); ``` -::: warning -Never trust `X-Forwarded-For` without configuring trusted proxies. An attacker can spoof this header to bypass IP-based rules. +### IPv6 canonicalization + +`IpMatcher` (which backs `safelists->ip()` and `blocklists->ip()`) canonicalizes addresses before matching, so you write each rule once: + +- An **IPv4-mapped IPv6** peer such as `::ffff:203.0.113.7` — the form dual-stack PHP-FPM pools often surface for IPv4 clients — collapses to its embedded IPv4 form. A rule written as `203.0.113.7` (or a CIDR like `203.0.113.0/24`) matches it, and an attacker cannot slip past an IPv4 blocklist entry by presenting the mapped form. +- **Alternate IPv6 spellings** — expanded `2001:0db8:0:0:0:0:0:1` vs compressed `2001:db8::1`, upper vs lower case — all resolve to one canonical identity, so a rule in any spelling matches all of them. + +::: danger +`KeyExtractors::ip()` reads raw `REMOTE_ADDR`; behind a proxy or CDN that is the proxy's address, so IP rules match the proxy rather than the client. Set a client-IP resolver (above) in that case. And never trust `X-Forwarded-For` without configuring trusted proxies — an attacker can otherwise spoof this header to bypass IP-based rules. See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies). ::: ## Evaluation Order @@ -504,7 +523,8 @@ The complete evaluation order within Phirewall is: | 3 | **Blocklist** | **Block** -- 403 Forbidden | | 4 | Fail2Ban | Block -- 403 Forbidden | | 5 | Throttle | Block -- 429 Too Many Requests | -| 6 | Pass | Request reaches your application | +| 6 | Allow2Ban | Block -- 403 Forbidden | +| 7 | Pass | Request reaches your application | ::: warning Rules within each layer are evaluated in the order they were added. Place more specific rules before general ones if ordering matters. diff --git a/docs/features/storage.md b/docs/features/storage.md index d4f4b24..3030e19 100644 --- a/docs/features/storage.md +++ b/docs/features/storage.md @@ -422,26 +422,60 @@ Need multi-server support? ## Cache Key Structure -Keys follow the format `{prefix}:{type}:{rule}:{normalized_key}`: +Keys follow the format `{prefix}.{type}.{rule}.{hashed_key}`. The final segment is the SHA-256 hex of the discriminator (IP, header value, …), so it is fixed-length and you cannot derive it from the plaintext value: ``` -phirewall:throttle:ip-limit:192.168.1.100 -phirewall:fail2ban:fail:login:192.168.1.100 -phirewall:fail2ban:ban:login:192.168.1.100 -phirewall:allow2ban:hit:high-volume:192.168.1.100 -phirewall:allow2ban:ban:high-volume:192.168.1.100 -phirewall:track:api-calls:user-123 +phirewall.throttle.ip-limit. +phirewall.fail2ban.fail.login. +phirewall.fail2ban.ban.login. +phirewall.allow2ban.hit.high-volume. +phirewall.allow2ban.ban.high-volume. +phirewall.track.api-calls. ``` Use `$config->setKeyPrefix('myapp')` to change the prefix and avoid collisions when sharing a cache instance. +::: warning Separator changed in 0.5.0 (`:` → `.`) +Before 0.5.0 the segments were joined with `:`. PSR-16 reserves `:` for the *cache implementation*, not its callers, so `CacheKeyGenerator` (and the trusted-bot rDNS cache) now join segments with `.` to keep Phirewall's own keys spec-compliant. The visible effect on upgrade: throttle counters, fail2ban/allow2ban counters, the ban registry, and the rDNS cache are keyed differently, so these **ephemeral, TTL-bound entries reset once** — in-flight throttle windows restart and existing temporary bans are forgotten on the first deploy. There is **no security impact** (all affected data is short-lived and self-healing) and no API change. `RedisCache`'s own namespace prefix (default `Phirewall:`) is unaffected — it is the backend's keyspace, applied *after* the public key. +::: + See [Discriminator Normalizer](/advanced/discriminator-normalizer) for details on how keys are sanitized. +## Cache Key Validation (PSR-16) + +All four bundled backends — `InMemoryCache`, `ApcuCache`, `RedisCache`, and `PdoCache` — validate every key passed to their PSR-16 surface (`get`, `set`, `has`, `delete`, `getMultiple`, `setMultiple`, `deleteMultiple`). An invalid key throws `Flowd\Phirewall\Store\InvalidCacheKeyException`, which implements `Psr\SimpleCache\InvalidArgumentException` so it can be caught through the standard PSR-16 interface. + +A key is rejected when it is: + +- an **empty string**; +- a string containing a **reserved character** — any of `{}()/\@:` (the set PSR-16 reserves for cache implementations); +- a string containing a **control or whitespace character**; or +- for the multi-key methods (`getMultiple` / `setMultiple` / `deleteMultiple`), a **non-string key** — previously these were silently cast to a string. + +```php +use Flowd\Phirewall\Store\InMemoryCache; +use Psr\SimpleCache\InvalidArgumentException; + +$cache = new InMemoryCache(); + +try { + $cache->get('user:42'); // ':' is reserved +} catch (InvalidArgumentException $exception) { + // Flowd\Phirewall\Store\InvalidCacheKeyException +} +``` + +Per PSR-16 there is **no upper length limit**: keys longer than the mandated 64-character minimum remain valid. The rules live in a shared `KeyValidationTrait`, so they are identical across every bundled backend. + +::: tip +Phirewall's own keys never trip this validation — the [key structure](#cache-key-structure) above uses only safe characters, and the [discriminator normalizer](/advanced/discriminator-normalizer) sanitizes the variable part of each key. Validation matters mainly when you reuse a bundled backend as a general-purpose PSR-16 cache in your own code. +::: + ## Monitoring ### Redis -Redis keys have two layers of prefixing: the RedisCache namespace (default `Phirewall:`) and the firewall key prefix (default `phirewall`). For example, a throttle counter key looks like `Phirewall:phirewall:throttle:ip-limit:192.168.1.100`. You can change the Redis namespace via `new RedisCache($redis, 'custom:')` and the key prefix via `$config->setKeyPrefix('custom')`. +Redis keys have two layers of prefixing: the RedisCache namespace (default `Phirewall:`) and the firewall key prefix (default `phirewall`). For example, a throttle counter key looks like `Phirewall:phirewall.throttle.ip-limit.` — the `Phirewall:` namespace (note the reserved `:`, which only the backend may use) followed by the `.`-joined public key, whose final segment is the SHA-256 hex of the discriminator. You can change the Redis namespace via `new RedisCache($redis, 'custom:')` and the key prefix via `$config->setKeyPrefix('custom')`. ```bash # Watch Phirewall keys in real-time @@ -453,11 +487,12 @@ redis-cli keys "Phirewall:*" | wc -l # Check memory usage redis-cli info memory -# Check a specific counter -redis-cli get "Phirewall:phirewall:throttle:ip-limit:192.168.1.100" -redis-cli ttl "Phirewall:phirewall:throttle:ip-limit:192.168.1.100" +# List all counters for a rule (use SCAN, not KEYS) +redis-cli --scan --pattern "Phirewall:phirewall.throttle.ip-limit.*" ``` +You cannot look up a specific client by plaintext IP — the discriminator segment is hashed. + ::: danger The `KEYS` command scans every key in Redis and blocks the server during execution. **Never use `KEYS` in production.** Use `SCAN` with a cursor instead: ```bash @@ -468,7 +503,7 @@ redis-cli --scan --pattern "Phirewall:*" | wc -l ### APCu ```php -$iterator = new APCuIterator('/^phirewall:/'); +$iterator = new APCuIterator('/^phirewall\./'); foreach ($iterator as $item) { printf("%s = %s (TTL: %ds)\n", $item['key'], diff --git a/docs/getting-started.md b/docs/getting-started.md index e6b5985..32d6c44 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -331,67 +331,96 @@ class PhirewallFactory } } -// 3. Register in config/services.yaml: +// 3. Register the middleware factory in config/services.yaml: // services: // Flowd\Phirewall\Middleware: // factory: ['@App\Factory\PhirewallFactory', 'create'] +// The listener below auto-registers via #[AsEventListener] + +// autoconfigure; the bridge factory interfaces autowire from the +// symfony/psr-http-message-bridge + nyholm/psr7 packages. // -// 4. Create src/EventSubscriber/PhirewallSubscriber.php: +// 4. Create src/EventListener/PhirewallListener.php +// A two-phase listener: it blocks on kernel.request, and re-attaches +// the X-RateLimit-* headers on kernel.response (a status-only +// subscriber would silently drop them on the allowed 200 path). +// NOTE: the bridge runs Phirewall with a pass-through handler, so the +// RequestContext attribute for app-recorded fail2ban/allow2ban signals +// is NOT visible to your controllers. Use pre-handler rule filters. -namespace App\EventSubscriber; +namespace App\EventListener; use Flowd\Phirewall\Middleware as PhirewallMiddleware; -use Nyholm\Psr7\Factory\Psr17Factory; -use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; -use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface; +use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; -class PhirewallSubscriber implements EventSubscriberInterface +final class PhirewallListener { + private const HEADERS_ATTRIBUTE = '_phirewall_headers'; + public function __construct( private readonly PhirewallMiddleware $middleware, + private readonly HttpMessageFactoryInterface $psrHttpFactory, + private readonly HttpFoundationFactoryInterface $httpFoundationFactory, + private readonly ResponseFactoryInterface $responseFactory, ) {} - public static function getSubscribedEvents(): array + #[AsEventListener(event: KernelEvents::REQUEST, priority: 256)] + public function onKernelRequest(RequestEvent $event): void { - return [KernelEvents::REQUEST => ['onKernelRequest', 256]]; + if (!$event->isMainRequest()) { + return; + } + $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); + $psrResponse = $this->middleware->process($psrRequest, $this->passThroughHandler()); + if ($psrResponse->getStatusCode() === 200) { + if ($psrResponse->getHeaders() !== []) { + $event->getRequest()->attributes->set(self::HEADERS_ATTRIBUTE, $psrResponse->getHeaders()); + } + return; + } + $event->setResponse($this->httpFoundationFactory->createResponse($psrResponse)); } - public function onKernelRequest(RequestEvent $event): void + #[AsEventListener(event: KernelEvents::RESPONSE)] + public function onKernelResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; } + /** @var array> $headers */ + $headers = $event->getRequest()->attributes->get(self::HEADERS_ATTRIBUTE, []); + foreach ($headers as $name => $values) { + $event->getResponse()->headers->set($name, $values); + } + } - $psr17 = new Psr17Factory(); - $psrFactory = new PsrHttpFactory($psr17, $psr17, $psr17, $psr17); - $httpFoundationFactory = new HttpFoundationFactory(); - - $psrRequest = $psrFactory->createRequest($event->getRequest()); - $psrResponse = $this->middleware->process( - $psrRequest, - new class ($psr17) implements \Psr\Http\Server\RequestHandlerInterface { - public function __construct(private readonly Psr17Factory $responseFactory) {} - public function handle( - \Psr\Http\Message\ServerRequestInterface $request, - ): \Psr\Http\Message\ResponseInterface { - return $this->responseFactory->createResponse(200); - } + private function passThroughHandler(): RequestHandlerInterface + { + return new class ($this->responseFactory) implements RequestHandlerInterface { + public function __construct(private readonly ResponseFactoryInterface $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->responseFactory->createResponse(200); } - ); - - if ($psrResponse->getStatusCode() !== 200) { - $event->setResponse( - $httpFoundationFactory->createResponse($psrResponse) - ); - } + }; } } ``` ```php [Laravel] +// Flowd\Phirewall\Middleware is a PSR-15 middleware (process(...)), NOT a +// Laravel middleware (handle($request, $next)) -- registering the class +// directly throws. A thin bridge middleware (step 3) adapts it. +// Install the bridge: composer require symfony/psr-http-message-bridge nyholm/psr7 +// // 1. Create app/Providers/PhirewallServiceProvider.php: namespace App\Providers; @@ -403,11 +432,20 @@ use Flowd\Phirewall\Store\ApcuCache; use Illuminate\Support\ServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ServerRequestInterface; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; class PhirewallServiceProvider extends ServiceProvider { public function register(): void { + // PSR-7/PSR-17 bridge factories used by the bridge middleware. + // HttpFoundationFactory autowires (no-arg constructor). + $this->app->singleton(Psr17Factory::class); + $this->app->singleton(PsrHttpFactory::class, fn ($app) => new PsrHttpFactory( + $app->make(Psr17Factory::class), $app->make(Psr17Factory::class), + $app->make(Psr17Factory::class), $app->make(Psr17Factory::class), + )); + $this->app->singleton(PhirewallMiddleware::class, function () { // ApcuCache requires ext-apcu (zero config, single-server) // For multi-server: use RedisCache with predis/predis @@ -455,14 +493,74 @@ class PhirewallServiceProvider extends ServiceProvider } } -// 2. Register in bootstrap/app.php (Laravel 11+): -// ->withMiddleware(function (Middleware $middleware) { -// $middleware->prepend(\Flowd\Phirewall\Middleware::class); +// 2. Register the provider in bootstrap/providers.php (Laravel 11+) +// or the providers array in config/app.php (Laravel 10). +// +// 3. Create app/Http/Middleware/Phirewall.php -- the bridge. +// Uses a probe handler so the real Laravel response is never +// round-tripped through PSR-7 (preserves StreamedResponse / +// BinaryFileResponse) and copies Phirewall's X-RateLimit-* headers +// onto the allowed response. +// NOTE: the bridge runs Phirewall with a probe handler, so the +// RequestContext attribute for app-recorded fail2ban/allow2ban signals +// is NOT visible to your controllers. Use pre-handler rule filters. + +namespace App\Http\Middleware; + +use Closure; +use Flowd\Phirewall\Middleware as PhirewallEngine; +use Illuminate\Http\Request; +use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Component\HttpFoundation\Response; + +final readonly class Phirewall +{ + public function __construct( + private PhirewallEngine $firewall, + private PsrHttpFactory $psrHttpFactory, + private HttpFoundationFactory $httpFoundationFactory, + private Psr17Factory $psr17, + ) {} + + public function handle(Request $request, Closure $next): Response + { + $psrRequest = $this->psrHttpFactory->createRequest($request); + $probe = new class($this->psr17) implements RequestHandlerInterface { + private bool $invoked = false; + public function __construct(private readonly Psr17Factory $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->invoked = true; + return $this->responseFactory->createResponse(); + } + public function wasInvoked(): bool { return $this->invoked; } + }; + $psrResponse = $this->firewall->process($psrRequest, $probe); + if (! $probe->wasInvoked()) { + return $this->httpFoundationFactory->createResponse($psrResponse); + } + $response = $next($request); + foreach ($psrResponse->getHeaders() as $name => $values) { + $response->headers->set($name, $values); + } + return $response; + } +} + +// 4. Register the bridge middleware (outermost): +// bootstrap/app.php (Laravel 11/12): +// ->withMiddleware(function (Middleware $middleware): void { +// $middleware->prepend(\App\Http\Middleware\Phirewall::class); // }) // -// Or in app/Http/Kernel.php (Laravel 10): +// Or app/Http/Kernel.php (Laravel 10 and earlier): // protected $middleware = [ -// \Flowd\Phirewall\Middleware::class, +// \App\Http\Middleware\Phirewall::class, // // ... // ]; ``` @@ -760,10 +858,27 @@ $config->throttles->add('api', limit: 100, period: 60, $config->setIpResolver(KeyExtractors::clientIp($resolver)); ``` -::: warning -Never trust `X-Forwarded-For` without configuring trusted proxies. An attacker can spoof this header to bypass rate limiting. +::: danger Set a resolver behind a proxy — or every client shares one key +`KeyExtractors::ip()` reads `REMOTE_ADDR` verbatim. Behind a CDN or load balancer that value is the *proxy's* address, so every client collapses onto a single throttle/ban key and your rate limits and bans become useless (or ban everyone at once). The same default applies to file-backed IP blocklists and infrastructure ban listeners. Whenever Phirewall runs behind a proxy, install a client-IP resolver — `$config->setIpResolver(KeyExtractors::clientIp(new TrustedProxyResolver([...])))` — so rules key on the originating client. And never trust `X-Forwarded-For` *without* configuring the trusted proxies: an attacker can otherwise spoof the header to forge any client IP. ::: +### Resolver behavior (0.5.0) + +`TrustedProxyResolver` walks the forwarded chain from right to left, skipping hops whose address is in your trusted-proxy list, and returns the first untrusted address as the client IP (falling back to `REMOTE_ADDR` when the chain yields nothing valid). A few details are worth knowing: + +```php +// Constructor: trusted proxies first, then the header(s) to consult, then a chain cap. +new TrustedProxyResolver( + trustedProxies: ['10.0.0.0/8', '172.16.0.0/12'], + allowedHeaders: ['X-Forwarded-For'], // default + maxChainEntries: 50, // default +); +``` + +- **The default header is a single header.** `allowedHeaders` now defaults to `['X-Forwarded-For']` only. If your stack emits the RFC 7239 `Forwarded` header instead, pass it explicitly — `new TrustedProxyResolver([...], ['Forwarded'])`, or `['Forwarded', 'X-Forwarded-For']` for both — so the header the resolver trusts is visible at the call site rather than inferred. +- **Only the last header instance is trusted.** If a request arrives with more than one `X-Forwarded-For` (or `Forwarded`) line, the resolver parses only the last instance — the one the closest proxy appended — and ignores any attacker-prepended duplicate line. +- **IPv6 is canonicalized.** An IPv4-mapped IPv6 peer (`::ffff:203.0.113.7`) collapses to its embedded IPv4 form, so a plain IPv4 rule or CIDR matches it and an attacker cannot bypass an IPv4 rule by presenting the mapped form. Alternate *genuine*-IPv6 spellings (expanded `2001:0db8::1` vs compressed `2001:db8::1`, mixed case) are also treated as one identity by `ip()` / CIDR **list** matching, which compares the raw binary address. Rate-limit and ban keys, however, use the address exactly as the resolver returns it, so they rely on your proxy emitting a consistent spelling per client. + ## First Test Verify your setup works by sending requests: