From 15854c908ec8abd6964bcff8c97668be796a3fb0 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Sun, 31 May 2026 07:01:46 +0200 Subject: [PATCH 01/17] Document 0.5.0: portable config, composition, presets + proxy/IP, PSR-16, RequestContext, and accuracy fixes --- docs/.vitepress/config.ts | 3 + docs/advanced/architecture.md | 2 +- docs/advanced/config-composition.md | 80 ++++++++ docs/advanced/discriminator-normalizer.md | 16 +- docs/advanced/infrastructure.md | 2 +- docs/advanced/observability.md | 4 + docs/advanced/portable-config.md | 206 +++++++++++++++++++++ docs/advanced/presets.md | 84 +++++++++ docs/advanced/request-context.md | 47 +++-- docs/advanced/track-notifications.md | 2 +- docs/common-attacks.md | 72 +++++--- docs/examples.md | 215 ++++++++++++++++------ docs/faq.md | 12 +- docs/features/bot-detection.md | 2 +- docs/features/fail2ban.md | 12 +- docs/features/owasp-crs.md | 6 +- docs/features/rate-limiting.md | 14 +- docs/features/safelists-blocklists.md | 28 ++- docs/features/storage.md | 59 ++++-- docs/getting-started.md | 193 +++++++++++++++---- 20 files changed, 885 insertions(+), 174 deletions(-) create mode 100644 docs/advanced/config-composition.md create mode 100644 docs/advanced/portable-config.md create mode 100644 docs/advanced/presets.md 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: From fdaec288e0468ed6aebc82ecfab049d7edb2c69c Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 1 Jun 2026 13:41:07 +0200 Subject: [PATCH 02/17] Document the IP-resolver composition caveat --- docs/advanced/config-composition.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/advanced/config-composition.md b/docs/advanced/config-composition.md index a54eb4c..7ad19ec 100644 --- a/docs/advanced/config-composition.md +++ b/docs/advanced/config-composition.md @@ -51,6 +51,10 @@ Because "default-valued" is read as "no opinion", an overlay **cannot turn a tog If you need a later layer to *force* a non-default option back to the default, do not rely on composition: build the final `Config` and set the option explicitly after composing, e.g. `Config::compose(...)->setFailOpen(true)`. +### Limitation: composing the IP resolver does not rewrite existing rules + +IP-aware matchers (`IpMatcher`, the file/snapshot IP blocklists, `TrustedBotMatcher`) capture their IP resolver **when the rule is constructed**. Because composition copies already-built rule objects, composing a layer with a different IP resolver only affects rules added *after* it — it does **not** retroactively change how earlier layers' IP rules resolve the client IP. Set the resolver on each source `Config` (via `setIpResolver()`) **before** adding its IP rules, rather than expecting a later layer to override it. + ## Example ```php From b9824286ed9f8fdb9d0063476fc1cf8c92474c31 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 1 Jun 2026 15:35:39 +0200 Subject: [PATCH 03/17] Update portable/composition/presets docs for Config::combine() materialization --- docs/advanced/config-composition.md | 22 ++++++++++-------- docs/advanced/portable-config.md | 13 ++++++----- docs/advanced/presets.md | 36 ++++++++++++----------------- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/docs/advanced/config-composition.md b/docs/advanced/config-composition.md index 7ad19ec..16b53be 100644 --- a/docs/advanced/config-composition.md +++ b/docs/advanced/config-composition.md @@ -11,15 +11,19 @@ Real deployments rarely have a single source of firewall rules. A vendor ships a ```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); +// Each layer is owned and versioned independently, usually as a PortableConfig. +// Materialize them onto your cache with Config::combine() — later layers win. +// The cache lives only on Config; the portable layers never carry one. +$effective = (new Config($cache))->combine( + $vendorPortable, // shared product defaults + $environmentPortable, // staging vs. production + $tenantPortable, // per-customer policy +); + +// Already holding Config instances? compose() / mergedWith() layer those directly +// (same precedence — later layers win): +$effective = $vendorConfig->mergedWith($environmentConfig, $tenantConfig); +$effective = Config::compose($vendorConfig, $environmentConfig, $tenantConfig); ``` `compose()` is static and reads as "base first, overlays after"; `mergedWith()` is the instance form for when you already hold the base. Both return a fresh `Config`; the base and every overlay are left untouched. diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md index f5f4345..fb8019d 100644 --- a/docs/advanced/portable-config.md +++ b/docs/advanced/portable-config.md @@ -11,13 +11,14 @@ outline: deep - **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)). +…and then materialize a live [`Config`](/getting-started) from it with [`Config::combine()`](/advanced/config-composition) — the schema is pure data and never carries a cache. Closures are never serialized, so the surface is intentionally a safe, declarative subset (see [Not portable by design](#not-portable-by-design)). ## Building and round-tripping -Build a ruleset fluently, export it with `toArray()` (or `json_encode()` the result), and rebuild it with `fromArray()` → `toConfig()`: +Build a ruleset fluently, export it with `toArray()` (or `json_encode()` the result), and rebuild it with `fromArray()`, then materialize it onto a `Config` with `Config::combine()`: ```php +use Flowd\Phirewall\Config; use Flowd\Phirewall\Http\Firewall; use Flowd\Phirewall\Pattern\PatternKind; use Flowd\Phirewall\Portable\PortableConfig; @@ -42,7 +43,7 @@ $portable = PortableConfig::create() $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); +$config = (new Config($cache))->combine(PortableConfig::fromArray(json_decode($json, true, 512, JSON_THROW_ON_ERROR))); $firewall = new Firewall($config); ``` @@ -152,7 +153,7 @@ $reload = static function () use (&$store, &$loadedVersion, &$firewall, $secret, } $portable = PortableConfig::loadSigned($row['blob'], $secret); - $firewall = new Firewall($portable->toConfig($cache)); + $firewall = new Firewall((new Config($cache))->combine($portable)); $loadedVersion = $row['version']; return true; @@ -184,7 +185,7 @@ See [`examples/28-portable-config-signing.php`](https://github.com/flowd/phirewa ## 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()`: +A few capabilities cannot be represented as pure data and are intentionally **excluded** from the schema. Configure these directly on the `Config` you build before (or after) combining the portable rules in: | Excluded | Why | |----------|-----| @@ -203,4 +204,4 @@ A few capabilities cannot be represented as pure data and are intentionally **ex - [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. +- [Storage Backends](/features/storage) — the PSR-16 cache a `Config` needs. diff --git a/docs/advanced/presets.md b/docs/advanced/presets.md index ccd6c97..aefbca5 100644 --- a/docs/advanced/presets.md +++ b/docs/advanced/presets.md @@ -4,12 +4,9 @@ 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: +Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a [`PortableConfig`](/advanced/portable-config) — plain, inspectable, serializable data you can diff, sign, or layer — returned by an accessor (e.g. `Presets::apiRateLimiting()`). -- 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. +Materialize one or several onto your own cache with [`Config::combine()`](/advanced/config-composition); presets are pure data and never receive a cache. Every rule is namespaced `preset..*`, so a later layer that redefines it by name overrides predictably. ## Usage @@ -17,25 +14,22 @@ Because presets ARE `Config`s, they layer with your own rules through [`Config:: 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); +// A preset on its own — combine it onto a Config you build with your cache: +$config = (new Config($cache))->combine(Presets::apiRateLimiting()); // 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, +$schema = Presets::apiRateLimiting()->toArray(); + +// Stack several presets, then your own rules last (later layers win by name): +$config = (new Config($cache))->combine( + Presets::scannerBlocking(), + Presets::sensitivePathBlocking(), + Presets::apiRateLimiting(), + $myPortable, ); ``` -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. +Preset rules emit the same [observability events](/advanced/observability) as hand-written ones — wire your PSR-14 dispatcher into the `Config` you combine onto (`new Config($cache, $dispatcher)`). ## Shipped presets @@ -46,13 +40,13 @@ Both factory forms accept an optional PSR-14 event dispatcher as a second argume | `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. +Resolve any preset by name with `Presets::get($name)` (a `PortableConfig`), passing 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. +- Override any rule by combining the preset with your own portable rules that redefine the rule by the same name (later layer wins), or by rebuilding the preset's schema. - 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. From 258ebb3192413d25d482061870dd02c80eac2edf Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 8 Jun 2026 17:22:03 +0200 Subject: [PATCH 04/17] Align 0.5.0 docs with the final API Document the now-optional rule key defaulting to the client IP across the throttle/fail2ban/allow2ban/track pages. Fix the evaluation order (Fail2Ban then Throttle then Allow2Ban) on the rate-limiting, bot-detection and fail2ban pages. Broaden the PortableConfig safelist header-filter rejection to the whole header_ family, correct the observability FirewallResult example, and tidy the Slim, Mezzio, TYPO3 and storage integration notes. --- docs/advanced/architecture.md | 2 +- docs/advanced/config-composition.md | 4 +- docs/advanced/dynamic-throttle.md | 8 +- docs/advanced/observability.md | 2 +- docs/advanced/portable-config.md | 4 +- docs/advanced/presets.md | 15 ++-- docs/advanced/request-context.md | 3 +- docs/advanced/track-notifications.md | 4 +- docs/common-attacks.md | 2 +- docs/examples.md | 123 +++++++++++++++++++++++++- docs/faq.md | 28 +++++- docs/features/bot-detection.md | 11 ++- docs/features/fail2ban.md | 50 +++++++++-- docs/features/owasp-crs.md | 26 ++++-- docs/features/rate-limiting.md | 18 ++-- docs/features/safelists-blocklists.md | 4 +- docs/features/storage.md | 11 ++- docs/index.md | 3 + 18 files changed, 267 insertions(+), 51 deletions(-) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index d8996c6..55928e8 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` 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. +The evaluator pipeline adds negligible overhead. 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 index 16b53be..ac5c21e 100644 --- a/docs/advanced/config-composition.md +++ b/docs/advanced/config-composition.md @@ -55,10 +55,12 @@ Because "default-valued" is read as "no opinion", an overlay **cannot turn a tog If you need a later layer to *force* a non-default option back to the default, do not rely on composition: build the final `Config` and set the option explicitly after composing, e.g. `Config::compose(...)->setFailOpen(true)`. -### Limitation: composing the IP resolver does not rewrite existing rules +### Limitation: composing the IP resolver does not rewrite IP-aware matchers IP-aware matchers (`IpMatcher`, the file/snapshot IP blocklists, `TrustedBotMatcher`) capture their IP resolver **when the rule is constructed**. Because composition copies already-built rule objects, composing a layer with a different IP resolver only affects rules added *after* it — it does **not** retroactively change how earlier layers' IP rules resolve the client IP. Set the resolver on each source `Config` (via `setIpResolver()`) **before** adding its IP rules, rather than expecting a later layer to override it. +This limitation does **not** apply to counter rules (throttle, fail2ban, allow2ban, track) added **without** an explicit `key`. Their default IP key is resolved per request against the `Config` they run under, so a composed `Config` correctly applies its own merged IP resolver to such rules no matter which layer defined them. + ## Example ```php diff --git a/docs/advanced/dynamic-throttle.md b/docs/advanced/dynamic-throttle.md index 8bfb86b..a9a2948 100644 --- a/docs/advanced/dynamic-throttle.md +++ b/docs/advanced/dynamic-throttle.md @@ -76,7 +76,7 @@ $config->throttles->add( string $name, int|Closure $limit, // Static int or Closure(ServerRequestInterface): int int|Closure $period, // Static int or Closure(ServerRequestInterface): int - Closure $key, // Closure(ServerRequestInterface): ?string + ?Closure $key = null, // Closure(ServerRequestInterface): ?string ): ThrottleSection ``` @@ -85,7 +85,7 @@ $config->throttles->add( | `$name` | `string` | Rule name (appears in headers and events) | | `$limit` | `int\|Closure` | Maximum requests in the period. Closure receives the request. | | `$period` | `int\|Closure` | Time window in seconds. Closure receives the request. | -| `$key` | `Closure` | Key extractor. Return `null` to skip this rule for the request. | +| `$key` | `?Closure` | Key extractor; return `null` to skip this rule. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | ## Sliding Window @@ -171,7 +171,7 @@ A request is blocked if it exceeds **any** window's limit. Windows are evaluated $config->throttles->multi( string $name, array $windowLimits, // array - Closure $key, + ?Closure $key = null, ): ThrottleSection ``` @@ -179,7 +179,7 @@ $config->throttles->multi( |-----------|------|-------------| | `$name` | `string` | Base name. Sub-rules are named `{name}:{period}s`. | | `$windowLimits` | `array` | Map of period (seconds) to limit (max requests). Must not be empty. | -| `$key` | `Closure` | Key extractor, shared across all sub-rules. | +| `$key` | `?Closure` | Key extractor, shared across all sub-rules. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | ### Naming Convention diff --git a/docs/advanced/observability.md b/docs/advanced/observability.md index ee96589..a4d458c 100644 --- a/docs/advanced/observability.md +++ b/docs/advanced/observability.md @@ -667,7 +667,7 @@ class FirewallEventsTest extends TestCase ``` ::: tip Use `Firewall` directly in tests -The `Firewall` class returns a `Decision` object with `isPass()` and `isBlock()` methods. This is faster than running through the full PSR-15 middleware pipeline and does not require a PSR-17 response factory. +The `Firewall` class returns a `FirewallResult` object with `isPass()` and `isBlocked()` methods. This is faster than running through the full PSR-15 middleware pipeline and does not require a PSR-17 response factory. ::: ## Performance Considerations diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md index fb8019d..5abcea5 100644 --- a/docs/advanced/portable-config.md +++ b/docs/advanced/portable-config.md @@ -85,12 +85,12 @@ Everything `PortableConfig` can express today. | `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` | +| `filterSuspiciousHeaders(requiredHeaders = 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. +`filterHeaderEquals`, `filterHeaderPresent`, and `filterHeaderRegex` are rejected on `safelist()` (and on `fromArray()` deserialize): a client-controlled header value would be a forgeable bypass token (anyone presenting it skips every downstream rule). They remain valid on blocklists, throttles, fail2ban, and track rules. ::: ### Key extractors diff --git a/docs/advanced/presets.md b/docs/advanced/presets.md index aefbca5..9334c2d 100644 --- a/docs/advanced/presets.md +++ b/docs/advanced/presets.md @@ -55,21 +55,22 @@ Resolve any preset by name with `Presets::get($name)` (a `PortableConfig`), pass `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`: +Phirewall ships **no** update-check mechanism and performs **no** network I/O. To surface "a newer ruleset is available", compare `Presets::VERSION` against a release feed you trust (Packagist, an internal config service, a versioned JSON document behind HTTPS, and so on) with `version_compare()`: ```php -interface PresetUpdateChecker -{ - public function latestVersion(string $preset): ?string; - public function isOutdated(string $preset, string $currentVersion): bool; +use Flowd\Phirewall\Preset\Presets; + +// $latestFromYourFeed comes from a source YOU control and trust. +if (version_compare(Presets::VERSION, $latestFromYourFeed, '<')) { + // A newer preset catalogue is available; review and upgrade phirewall. } ``` -**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`. +Fetching `$latestFromYourFeed` is the integrator's job; phirewall hardcodes no remote endpoint. ## 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. +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 comparing `Presets::VERSION` against your own release feed with `version_compare()`. ## Related pages diff --git a/docs/advanced/request-context.md b/docs/advanced/request-context.md index c263731..ba84537 100644 --- a/docs/advanced/request-context.md +++ b/docs/advanced/request-context.md @@ -358,6 +358,7 @@ Verify that failures recorded via `RequestContext` trigger bans: ```php use PHPUnit\Framework\TestCase; +use Flowd\Phirewall\BanType; use Flowd\Phirewall\Config; use Flowd\Phirewall\Context\RequestContext; use Flowd\Phirewall\Http\Firewall; @@ -403,7 +404,7 @@ class RequestContextTest extends TestCase } // Verify the IP is now banned - $this->assertTrue($firewall->isBanned('test-rule', $ip)); + $this->assertTrue($firewall->isBanned('test-rule', $ip, BanType::Fail2Ban)); } } ``` diff --git a/docs/advanced/track-notifications.md b/docs/advanced/track-notifications.md index e461eff..5d0b77f 100644 --- a/docs/advanced/track-notifications.md +++ b/docs/advanced/track-notifications.md @@ -33,7 +33,7 @@ $config->tracks->add( string $name, int $period, Closure $filter, - Closure $key, + ?Closure $key = null, ?int $limit = null // optional threshold ): TrackSection ``` @@ -43,7 +43,7 @@ $config->tracks->add( | `$name` | `string` | Unique rule identifier (must not be empty) | | `$period` | `int` | Time window for counting in seconds (must be >= 1) | | `$filter` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to count this request | -| `$key` | `Closure` | `fn(ServerRequestInterface): ?string` -- return the grouping key, or `null` to skip counting | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` -- return the grouping key, or `null` to skip counting. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | | `$limit` | `?int` | Optional threshold. When set, the `TrackHit` event includes a `thresholdReached` flag that becomes `true` once the counter reaches this value | ::: tip Return type diff --git a/docs/common-attacks.md b/docs/common-attacks.md index e86cb44..3695c4e 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -253,7 +253,7 @@ Block known attack tools (sqlmap, nikto, nuclei, etc.) with a single call: $config->blocklists->knownScanners(); ``` -The default list covers 24 tools. Extend or replace it: +The default list covers 24 tools (26 substring patterns). Extend or replace it: ```php use Flowd\Phirewall\Matchers\KnownScannerMatcher; diff --git a/docs/examples.md b/docs/examples.md index 677f967..767c79c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -49,7 +49,7 @@ php examples/01-basic-setup.php | 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 | +| 31 | [presets](https://github.com/flowd/phirewall/blob/main/examples/31-presets.php) | Ready-to-use rule presets and version comparison (compare `Presets::VERSION` against your own release feed) | --- @@ -626,7 +626,7 @@ protected $middleware = [ ### Slim -Native PSR-15 support. No external dependencies beyond `ext-apcu`. +Native PSR-15 support. Requires a PSR-17 factory (`slim/psr7` ships with the Slim skeleton, or install `nyholm/psr7`) plus `ext-apcu` for `ApcuCache`. Without a PSR-17 factory the middleware constructor throws, since it auto-detects a `ResponseFactory`. ```php setKeyPrefix('mezzio'); $config->enableRateLimitHeaders(); - $config->setFailOpen(true); + // failOpen defaults to true; call setFailOpen(false) to fail closed. // ── Trusted Proxies ────────────────────────────────────── $proxyResolver = new TrustedProxyResolver([ @@ -849,7 +849,15 @@ return [ **`config/pipeline.php`** ```php -// Phirewall must be the outermost middleware (piped first) +// ErrorHandler must be piped FIRST so it can catch exceptions thrown by +// downstream middleware and route handlers. Phirewall does not wrap the +// downstream handler in a try/catch, so a handler exception propagates +// through it; if Phirewall were piped above ErrorHandler, that exception +// would escape the error boundary and reach the emitter unhandled. +$app->pipe(\Laminas\Stratigility\Middleware\ErrorHandler::class); + +// Phirewall runs as early as possible AFTER the error boundary, so it +// blocks before routing/dispatch while still being covered by ErrorHandler. $app->pipe(\Flowd\Phirewall\Middleware::class); // ... other middleware @@ -859,6 +867,113 @@ $app->pipe(\Mezzio\Router\Middleware\DispatchMiddleware::class); --- +### TYPO3 + +**Use the official extension.** For TYPO3, install [`flowd/typo3-firewall`](https://extensions.typo3.org/extension/firewall) rather than wiring Phirewall by hand. It integrates Phirewall into TYPO3, registers the PSR-15 middleware in the frontend stack for you, and adds a backend module for managing block patterns. + +```bash +composer require flowd/typo3-firewall +``` + +Phirewall is then configured in TYPO3's core configuration file `config/system/phirewall.php` (the full [Phirewall configuration](/getting-started) applies there), and block patterns created in the backend module are stored in `config/system/phirewall.patterns.json` and take effect immediately. See the [extension documentation](https://docs.typo3.org/p/flowd/typo3-firewall/main/en-us/) for details. + +#### Manual integration (without the extension) + +If you cannot use the extension, TYPO3 (v12/v13) runs a PSR-15 middleware stack, so Phirewall can plug in through your own extension's `Configuration/RequestMiddlewares.php`. Two TYPO3 specifics matter: + +- **The middleware is resolved from the DI container**, so its service must be **public**. The `#[Autoconfigure(public: true)]` attribute below marks it public; it relies on your extension's standard `Configuration/Services.yaml` loading `../Classes/*` (the default extension skeleton already does). +- **TYPO3's caching framework is not PSR-16.** `CacheManager::getCache()` returns a `\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface`, not a `Psr\SimpleCache\CacheInterface`, so it cannot be passed to `Config` directly. Use one of Phirewall's bundled PSR-16 stores (`RedisCache`, `ApcuCache`, `PdoCache`); to reuse a TYPO3 cache you would need a PSR-16 adapter (e.g. `ssch/typo3-psr-cache-adapter`). + +Phirewall ships as a PSR-15 middleware that takes a `Config` (which needs a cache and a PSR-17 factory), so it is not autowirable as-is. Wrap it in a small middleware class your extension owns: + +**`Classes/Middleware/PhirewallMiddleware.php`** + +```php +setKeyPrefix('typo3'); + $config->enableRateLimitHeaders(); + + // TYPO3 sits behind a reverse proxy in most setups; resolve the + // real client IP so rules key on the visitor, not the proxy. + $config->setIpResolver( + KeyExtractors::clientIp(new TrustedProxyResolver(['10.0.0.0/8'])) + ); + + $config->safelists->add('health', + fn(ServerRequestInterface $req): bool => + $req->getUri()->getPath() === '/health' + ); + $config->blocklists->knownScanners(); + $config->throttles->add('burst', limit: 30, period: 5, key: KeyExtractors::ip()); + + $config->usePsr17Responses($psr17, $psr17); + + $this->phirewall = new Phirewall($config, $psr17); + } + + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + return $this->phirewall->process($request, $handler); + } +} +``` + +**`Configuration/RequestMiddlewares.php`** + +```php + [ + 'myvendor/myextension/phirewall' => [ + 'target' => \MyVendor\MyExtension\Middleware\PhirewallMiddleware::class, + // Run after normalized params (so the resolved client IP is + // available) and before site resolution, so a blocked request + // never triggers site/page lookup work. + 'after' => ['typo3/cms-core/normalized-params-attribute'], + 'before' => ['typo3/cms-frontend/site'], + ], + ], +]; +``` + +After changing the middleware order, verify the computed stack in the TYPO3 backend (System → Configuration, or the `lowlevel` configuration module). + +--- + ## Basic: Minimal Setup The smallest useful configuration. Protects against common scanners and rate-limits all traffic. diff --git a/docs/faq.md b/docs/faq.md index ef5ebea..7c292fe 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -16,7 +16,8 @@ Phirewall works with any PSR-15 (PHP Standard Recommendation for HTTP Server Mid - **Slim** (4.x+) - **Mezzio** (Laminas) -- **Laravel** (via PSR-15 bridge or `nyholm/psr7`) +- **TYPO3** (v12/v13, via an extension middleware) +- **Laravel** (via `symfony/psr-http-message-bridge` + `nyholm/psr7`) - **Symfony** (via `symfony/psr-http-message-bridge`) - **Spiral** - **Any custom PSR-15 middleware stack** @@ -206,6 +207,15 @@ $config->fail2ban->add('login', threshold: 5, period: 300, ban: 3600, filter: .. See the [Getting Started](/getting-started) guide for the full section API reference. +### What changed in 0.5.0 that affects an upgrade? + +A few behaviour changes can affect an existing deployment on upgrade: + +- **`isBanned()` now requires a `BanType`.** Both `Http\Firewall::isBanned()` and `BanManager::isBanned()` take a mandatory third argument `BanType $banType` (no default), because allow2ban and fail2ban bans live under distinct cache keys. Update any 2-argument call to pass `BanType::Fail2Ban` or `BanType::Allow2Ban`. +- **Cache-key separator changed from `:` to `.`.** Existing throttle/fail2ban counters and active bans are keyed with the old separator, so on the first deploy they are orphaned: counters reset and currently-banned clients are briefly un-banned. This is a one-time effect that self-heals as new keys are written; orphaned entries expire by TTL. Bump your `keyPrefix` or drain the cache on deploy if you want a clean cut. +- **`setKeyPrefix()` rejects reserved/control/whitespace characters.** A colon-namespaced prefix such as `app:prod` now throws `InvalidArgumentException`; use `app.prod` (see [Discriminator Normalizer](/advanced/discriminator-normalizer)). The cache backends likewise throw `InvalidCacheKeyException` on reserved, empty, or control keys. +- **`TrustedProxyResolver` defaults to a single header.** `allowedHeaders` now defaults to `['X-Forwarded-For']` only. If your upstream emits RFC 7239, pass `['Forwarded']` or `['Forwarded', 'X-Forwarded-For']` explicitly (see [How do I handle trusted proxies?](#how-do-i-handle-trusted-proxies)). + ## Rate Limiting ### What rate limiting algorithms does Phirewall support? @@ -277,6 +287,7 @@ See [Dynamic Throttle: Per-User Tier Limits](/advanced/dynamic-throttle#per-user | Testing / Development | `InMemoryCache` | No dependencies, resets each request | | Single server | `ApcuCache` | Sub-microsecond, shared across PHP-FPM workers | | Multiple servers | `RedisCache` | Shared state, atomic operations | +| Long-running workers (Swoole, RoadRunner, FrankenPHP, Octane) | `RedisCache` (or `PdoCache`) | Each worker is a separate process; avoid `InMemoryCache` and `ApcuCache`, whose state fragments across workers | | Kubernetes / Docker | `RedisCache` | Containers are ephemeral, need external state | | Serverless | `RedisCache` (external) | Function instances are short-lived | | No Redis available | `PdoCache` | MySQL, PostgreSQL, or SQLite | @@ -285,15 +296,18 @@ See [Storage Backends](/features/storage) for a detailed comparison. ### Can I use Symfony Cache or Laravel Cache? -Yes. Phirewall accepts any PSR-16 (PHP Standard Recommendation for Simple Caching) compatible implementation. However, generic PSR-16 caches may have non-atomic counter increments. For production, prefer the bundled `RedisCache` or `ApcuCache` for accuracy. +Yes, Phirewall accepts any PSR-16 (PHP Standard Recommendation for Simple Caching) compatible implementation, but note that most host-framework caches are **not** PSR-16 out of the box: Laravel's `Cache` repository (`Illuminate\Contracts\Cache\Repository`) and TYPO3's caching framework (`FrontendInterface`) are not `Psr\SimpleCache\CacheInterface`, so passing one directly to `Config` is a type error. Symfony Cache exposes a PSR-16 adapter (`Symfony\Component\Cache\Psr16Cache`) you can wrap a pool in. For production, prefer the bundled `RedisCache` or `ApcuCache`: they implement `CounterStoreInterface` for atomic increments, whereas a generic PSR-16 cache may have non-atomic counter increments. ### Why does InMemoryCache not work in production? In PHP-FPM (FastCGI Process Manager), each request starts a new process (or reuses one from the pool). The in-memory cache is empty at the start of each request, so counters always reset to zero. This means rate limits and ban counters never accumulate. +Under long-running worker runtimes (Swoole, RoadRunner, FrankenPHP worker mode, Octane) the failure is the opposite and easy to miss: the array survives across requests within a worker, so a single-worker demo looks like it "works", but each worker is a separate process with its own array. State is never shared across workers (counters and bans fragment, so the effective rate limit is roughly N times the configured value) and the array grows for the worker's lifetime. + Solutions: - **Single server**: use `ApcuCache` (shared memory across the FPM pool) - **Multiple servers**: use `RedisCache` (shared across all servers) +- **Long-running workers**: use `RedisCache` or `PdoCache` (not `ApcuCache`, whose memory is per process) - **Any server**: use `PdoCache` with a database ## OWASP Rules @@ -441,11 +455,21 @@ if ($result->isBlocked()) { Use the `Firewall` class: ```php +use Flowd\Phirewall\BanType; + $firewall = new Firewall($config); // Reset a specific throttle counter $firewall->resetThrottle('api', '192.168.1.100'); +// Lift a specific fail2ban ban (also clears its fail counter) +$firewall->resetFail2Ban('login-failures', '192.168.1.100'); + +// Check whether a key is currently banned (BanType is required as of 0.5.0) +$banned = $firewall->isBanned('login-failures', '192.168.1.100', BanType::Fail2Ban); + // Reset all counters and bans $firewall->resetAll(); ``` + +`BanType` is `enum BanType: string { case Allow2Ban = 'allow2ban'; case Fail2Ban = 'fail2ban'; }`. diff --git a/docs/features/bot-detection.md b/docs/features/bot-detection.md index 60b1baa..beb1df4 100644 --- a/docs/features/bot-detection.md +++ b/docs/features/bot-detection.md @@ -109,14 +109,14 @@ $config->blocklists->suspiciousHeaders(); ```php $config->blocklists->suspiciousHeaders( string $name = 'suspicious-headers', - array $requiredHeaders = [] + ?array $requiredHeaders = null ): BlocklistSection ``` | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Unique rule identifier (default: `'suspicious-headers'`) | -| `$requiredHeaders` | `list` | Headers that must be present. Empty array uses defaults. | +| `$requiredHeaders` | `?list` | Headers that must be present (case-sensitive header names). `null` uses the default set; a non-null list replaces the defaults. Passing `[]` requires nothing and never matches; do not use it for defaults. | ### Default Required Headers @@ -356,7 +356,7 @@ Blocklists (knownScanners, suspiciousHeaders) --> match? --> 403 BLOCK No match | v -Fail2Ban / Allow2Ban --> banned? --> 403 BLOCK +Fail2Ban --> banned? --> 403 BLOCK | Not banned | @@ -366,6 +366,11 @@ Throttles --> over limit? --> 429 TOO MANY REQUESTS Under limit | v +Allow2Ban --> over volume cap? --> 403 BLOCK + | + Under cap + | + v ALLOW (pass to handler) ``` diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index 73aff2a..b04a179 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -48,7 +48,7 @@ $config->fail2ban->add( int $period, int $ban, Closure $filter, - Closure $key + ?Closure $key = null ): Fail2BanSection ``` @@ -59,7 +59,7 @@ $config->fail2ban->add( | `$period` | `int` | Time window for counting matches in seconds (must be >= 1) | | `$ban` | `int` | Ban duration in seconds (must be >= 1) | | `$filter` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to count as a match | -| `$key` | `Closure` | `fn(ServerRequestInterface): ?string` -- return key to track, or `null` to skip | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` -- return key to track, or `null` to skip. When the whole argument is omitted, defaults to the client IP from the Config's IP resolver (`Config::setIpResolver()`, typically `KeyExtractors::clientIp($proxy)`), falling back to `KeyExtractors::ip()` (REMOTE_ADDR). The resolver is read per request, so it can be set before or after the rule. | ::: warning Fail2Ban filters evaluate the **incoming request** before the handler runs. The filter can only inspect request data (path, method, headers, query parameters). It cannot see the application's response. To ban based on application outcomes (like actual failed logins), use the [Request Context API](#post-handler-signaling-with-requestcontext) instead. @@ -302,7 +302,7 @@ Request --> Is key already banned? --> Yes --> 403 Forbidden Increment request counter | v - Counter >= threshold? --> No --> Continue to throttle rules + Counter >= threshold? --> No --> Allow (pass to handler) | Yes | @@ -320,7 +320,7 @@ $config->allow2ban->add( int $threshold, int $period, int $banSeconds, - Closure $key + ?Closure $key = null ): Allow2BanSection ``` @@ -330,7 +330,7 @@ $config->allow2ban->add( | `$threshold` | `int` | Number of requests that triggers the ban (must be >= 1). The Nth request is itself banned (matching rack-attack `maxretry` semantics). | | `$period` | `int` | Time window for counting requests in seconds (must be >= 1) | | `$banSeconds` | `int` | Ban duration in seconds (must be >= 1) | -| `$key` | `Closure` | `fn(ServerRequestInterface): ?string` -- return key to track, or `null` to skip | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` -- return key to track, or `null` to skip. When omitted, defaults to the client IP from the Config's IP resolver (see Fail2Ban's `$key` above). | ::: tip Note the parameter name difference: Fail2Ban uses `$ban`, Allow2Ban uses `$banSeconds`. Both accept duration in seconds. @@ -411,6 +411,46 @@ $config->allow2ban->add( | **Event** | `Fail2BanBanned` | `Allow2BanBanned` | | **Ban parameter** | `$ban` | `$banSeconds` | +## Managing Bans + +`Flowd\Phirewall\Http\Firewall` is the supported runtime-management entry point. Construct it with the same `Config` your middleware uses; all state lives in the `Config` cache, so every `Firewall` over the same `Config` shares bans and counters. + +```php +use Flowd\Phirewall\BanType; +use Flowd\Phirewall\Http\Firewall; + +$firewall = new Firewall($config); + +// Is a key currently banned? BanType is REQUIRED (no default). +$firewall->isBanned('login-failures', $ip, BanType::Fail2Ban); +$firewall->isBanned('high-volume-ban', $ip, BanType::Allow2Ban); + +// Lift a specific fail2ban ban (also clears its fail counter). +$firewall->resetFail2Ban('login-failures', $ip); + +// Clear a throttle counter. +$firewall->resetThrottle('api', $ip); + +// Clear the whole cache instance (counters, bans, tracking). +$firewall->resetAll(); +``` + +`isBanned()` requires an explicit `BanType` because allow2ban and fail2ban store their bans under distinct cache keys, so an implicit default would silently answer for the wrong category: + +```php +enum BanType: string +{ + case Allow2Ban = 'allow2ban'; + case Fail2Ban = 'fail2ban'; +} +``` + +Notes: + +- For `multi()` throttle sub-rules, reset each window individually (for example `'api:1s'` and `'api:60s'`); for dynamic-period rules, pass the `:p{period}` suffix. +- `resetAll()` calls `cache->clear()` and wipes the entire cache instance, so give phirewall a dedicated cache (or key-prefixed namespace) if you share Redis/APCu with your application. +- All keys are normalized through the discriminator normalizer, so lookups match regardless of input casing. + ## Events When a key is banned, an event is dispatched through your PSR-14 event dispatcher. Fail2Ban and Allow2Ban each dispatch their own event type. diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index c23f34b..89d1987 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -87,11 +87,27 @@ $skipped = $report['skipped']; // int - Lines that were skipped | Method | Parameters | Description | |--------|-----------|-------------| -| `fromString()` | `string $rulesText, ?string $contextFolder` | Parse rules from a string | -| `fromFile()` | `string $filePath` | Load rules from a single file | -| `fromFiles()` | `list $paths` | Load and merge multiple files | -| `fromDirectory()` | `string $dir, ?callable $filter` | Load all files in a directory | -| `fromStringWithReport()` | `string $rulesText` | Parse with statistics | +| `fromString()` | `string $rulesText, ?string $contextFolder = null, ?int $maxValuesPerCrsVariable = null` | Parse rules from a string | +| `fromFile()` | `string $filePath, ?int $maxValuesPerCrsVariable = null` | Load rules from a single file | +| `fromFiles()` | `list $paths, ?int $maxValuesPerCrsVariable = null` | Load and merge multiple files | +| `fromDirectory()` | `string $dir, ?callable $filter = null, ?int $maxValuesPerCrsVariable = null` | Load all files in a directory | +| `fromStringWithReport()` | `string $rulesText, ?int $maxValuesPerCrsVariable = null` | Parse with statistics | + +## Per-Variable Value Cap (CPU-DoS Guard) + +Every `SecRuleLoader` factory accepts an optional trailing `?int $maxValuesPerCrsVariable`. It bounds how many collected values a single CRS variable (such as `ARGS`) may contribute to evaluation, so an attacker cannot drive up per-request WAF cost by submitting thousands of parameters, headers, or cookies. The cap is **per variable**, not aggregate. + +```php +use Flowd\Phirewall\Owasp\SecRuleLoader; + +// Cap each CRS variable at 5000 collected values. +$rules = SecRuleLoader::fromFile('/etc/phirewall/owasp.conf', maxValuesPerCrsVariable: 5000); +$rules = SecRuleLoader::fromString($rulesText, maxValuesPerCrsVariable: 5000); +``` + +- **Default (`null`):** twice PHP's `max_input_vars`, falling back to `2000` (`RequestVariableValues::DEFAULT_MAX_VALUES_PER_CRS_VARIABLE`) when `max_input_vars` is unset or non-positive. Doubling `max_input_vars` sizes the budget to the parameter count the runtime actually accepts (variables such as `ARGS` emit both a name and a value per parameter), so a request PHP can fully parse is never falsely truncated. +- **Fail-closed:** when a variable's values are truncated at the cap, the affected deny rules fail **closed** (the request is blocked) rather than evaluating a partial value set. An attacker therefore cannot pad a payload past the cap to slip past a deny rule. +- **Explicit non-positive value throws:** passing an explicit cap below `1` raises `\InvalidArgumentException`, because a non-positive cap would fail every deny rule closed and block all traffic. ## Supported SecRule Syntax diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index e502f32..e340462 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -4,7 +4,7 @@ outline: deep # Rate Limiting -Phirewall provides rate limiting (throttling) that returns `429 Too Many Requests` when limits are exceeded. Throttle rules are evaluated after safelists, blocklists, and Fail2Ban -- making them the last check before a request reaches your application. +Phirewall provides rate limiting (throttling) that returns `429 Too Many Requests` when limits are exceeded. Throttle rules are evaluated after safelists, blocklists, and Fail2Ban, and before Allow2Ban. Three throttle strategies are available: @@ -18,6 +18,10 @@ Three throttle strategies are available: 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. ::: +::: tip Default key +The `key` argument on `add()`, `sliding()`, and `multi()` is optional. When omitted, the throttle keys on the client IP resolved by the Config's IP resolver (set via `Config::setIpResolver(KeyExtractors::clientIp($trustedProxyResolver))` behind a proxy), falling back to `KeyExtractors::ip()` (REMOTE_ADDR) when none is set. The resolver is read per request, so it can be set before or after adding rules. The examples below pass `key:` explicitly to make the keying visible. +::: + ## 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. @@ -27,7 +31,7 @@ $config->throttles->add( string $name, int|Closure $limit, int|Closure $period, - Closure $key + ?Closure $key = null ): ThrottleSection ``` @@ -36,7 +40,7 @@ $config->throttles->add( | `$name` | `string` | Unique rule identifier | | `$limit` | `int\|Closure` | Max requests per window, or a [dynamic closure](#dynamic-limits) | | `$period` | `int\|Closure` | Window size in seconds, or a [dynamic closure](#dynamic-limits) | -| `$key` | `Closure` | `fn(ServerRequestInterface): ?string` -- return a key to group by, or `null` to skip | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` -- return a key to group by, or `null` to skip. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | ```php use Flowd\Phirewall\KeyExtractors; @@ -65,7 +69,7 @@ $config->throttles->sliding( string $name, int|Closure $limit, int|Closure $period, - Closure $key + ?Closure $key = null ): ThrottleSection ``` @@ -108,7 +112,7 @@ The `multi()` method registers multiple throttle windows under a single logical $config->throttles->multi( string $name, array $windowLimits, - Closure $key + ?Closure $key = null ): ThrottleSection ``` @@ -116,7 +120,7 @@ $config->throttles->multi( |-----------|------|-------------| | `$name` | `string` | Logical name prefix | | `$windowLimits` | `array` | Map of period (seconds) => limit (max requests) | -| `$key` | `Closure` | Key extractor closure | +| `$key` | `?Closure` | Key extractor closure (shared across all windows). Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | Each entry creates a sub-rule named `{$name}:{$period}s`. Windows are evaluated shortest-first (burst before sustained). @@ -159,7 +163,7 @@ $config->throttles->add( string $name, int|Closure(ServerRequestInterface): int $limit, int|Closure(ServerRequestInterface): int $period, - Closure $key + ?Closure $key = null ): ThrottleSection ``` diff --git a/docs/features/safelists-blocklists.md b/docs/features/safelists-blocklists.md index af75769..e336161 100644 --- a/docs/features/safelists-blocklists.md +++ b/docs/features/safelists-blocklists.md @@ -248,14 +248,14 @@ Block requests missing standard HTTP headers that real browsers typically send. ```php $config->blocklists->suspiciousHeaders( string $name = 'suspicious-headers', - array $requiredHeaders = [] + ?array $requiredHeaders = null ): BlocklistSection ``` | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Rule identifier (default: `'suspicious-headers'`) | -| `$requiredHeaders` | `list` | Headers that must be present. Empty uses defaults | +| `$requiredHeaders` | `?list` | Headers that must be present. `null` uses the default set (`Accept`, `Accept-Language`, `Accept-Encoding`). A non-null list replaces the defaults entirely; do NOT pass `[]` expecting defaults (it requires zero headers and never matches) | Default required headers: `Accept`, `Accept-Language`, `Accept-Encoding`. diff --git a/docs/features/storage.md b/docs/features/storage.md index 3030e19..60a6a9a 100644 --- a/docs/features/storage.md +++ b/docs/features/storage.md @@ -34,7 +34,7 @@ $config = new Config($cache); - Zero external dependencies - Data resets on every request in PHP-FPM (each request is a new process) -- Data persists for the lifetime of a long-running process (CLI, Swoole, RoadRunner) +- Under long-running worker runtimes (Swoole, RoadRunner, FrankenPHP worker mode, Octane) the array lives for the worker's lifetime: state is **not** shared across workers (each worker is a separate process, so counters and bans fragment) and the array only evicts already-expired entries, so it is not a memory cap. This makes it unsafe as a firewall store there; see the warning below. - Implements both `CacheInterface` (PSR-16) and `CounterStoreInterface` - Automatic expired entry purging every 1000 operations @@ -67,15 +67,20 @@ $clock->advance(60); // Move forward 60 seconds - Unit tests and integration tests - Development and prototyping - Single-script CLI tools -- Long-running processes where per-process state is acceptable ### When NOT to Use - PHP-FPM production (counters reset each request) - Multi-server deployments (no shared state) +- Long-running worker runtimes (Swoole, RoadRunner, FrankenPHP worker mode, Octane): state fragments across workers and grows unbounded within a worker ::: warning -In coroutine-based servers (Swoole), `InMemoryCache` may experience race conditions under high concurrency because it uses plain PHP arrays with no locking. Use `RedisCache` or `ApcuCache` for production Swoole deployments. +Two distinct problems make `InMemoryCache` unsuitable for the firewall under long-running worker runtimes (Swoole, RoadRunner, FrankenPHP worker mode, Octane): + +1. **No shared state across workers.** Each worker is a separate OS process with its own array, so a counter or ban set in one worker is invisible to the others. The effective rate limit becomes roughly N times the configured value (N workers), and a client banned on one worker is not banned on the rest. +2. **Coroutine races within a worker.** In coroutine servers like Swoole, the plain PHP arrays have no locking, so concurrent coroutines in the same worker can race. + +Use a shared store under these runtimes: `RedisCache` (preferred) or `PdoCache`. Note that `ApcuCache` does **not** solve problem 1: APCu memory is per process, so counters and bans still fragment across workers. ::: ## ApcuCache diff --git a/docs/index.md b/docs/index.md index fd91d7c..9803c52 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,9 @@ features: - icon: "\uD83D\uDD04" title: Fail2Ban & Allow2Ban details: Ban clients after threshold violations. Signal failures from your application handler via RequestContext. + - icon: "\uD83E\uDDE9" + title: Presets & Portable Config + details: Ship ready-made rule bundles (API rate limiting, login protection, scanner blocking, sensitive-path blocking) as serializable PortableConfig data. Layer them with Config::combine() and Config::compose(), and override any rule by name. - icon: "\uD83D\uDCE6" title: PSR-15 Compatible details: Works with any PSR-15 framework — Laravel, Symfony, Slim, TYPO3, Mezzio, and more. From ac9af2fa694c3d9ceedbdf15bac6c79e972b2d24 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 8 Jun 2026 17:55:56 +0200 Subject: [PATCH 05/17] Balance the home feature grid and tighten the Presets card Shorten the Presets & Portable Config blurb to match the others and add a Flexible Storage card so the grid is an even 4+4 instead of 4+3. --- docs/index.md | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 9803c52..d9f574b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,10 +32,13 @@ features: details: Ban clients after threshold violations. Signal failures from your application handler via RequestContext. - icon: "\uD83E\uDDE9" title: Presets & Portable Config - details: Ship ready-made rule bundles (API rate limiting, login protection, scanner blocking, sensitive-path blocking) as serializable PortableConfig data. Layer them with Config::combine() and Config::compose(), and override any rule by name. + details: Ready-made, serializable rule bundles you can layer, compose, and override by name. - icon: "\uD83D\uDCE6" title: PSR-15 Compatible details: Works with any PSR-15 framework — Laravel, Symfony, Slim, TYPO3, Mezzio, and more. + - icon: "💾" + title: Flexible Storage + details: "PSR-16 cache backends included: in-memory, APCu, Redis, and PDO." ---
diff --git a/package.json b/package.json index 06420b9..7c11391 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,6 @@ "preview": "vitepress preview docs" }, "devDependencies": { - "vitepress": "^1.6.0" + "vitepress": "^1.6.4" } } From 245d12f3210d45348f3d0c80eb71267b1a870be7 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 8 Jun 2026 22:28:41 +0200 Subject: [PATCH 06/17] Drop redundant key: KeyExtractors::ip() from rule examples The rule key now defaults to the client IP, so examples that passed key: KeyExtractors::ip() (the REMOTE_ADDR default) just carried boilerplate. Omit it across the throttle/fail2ban/allow2ban/track examples. The complete setups that configure a trusted-proxy resolver now correctly key on the resolved client IP instead of forcing REMOTE_ADDR, and their comments are corrected accordingly. Explicit keys are kept only where the key is not the client IP (header, username, path-scoped closures). --- docs/advanced/dynamic-throttle.md | 10 ++----- docs/advanced/infrastructure.md | 2 -- docs/advanced/psr17.md | 3 +- docs/advanced/request-context.md | 4 --- docs/advanced/track-notifications.md | 2 -- docs/common-attacks.md | 15 +++------- docs/examples.md | 45 +++++++--------------------- docs/faq.md | 7 ++--- docs/features/bot-detection.md | 2 -- docs/features/fail2ban.md | 8 ----- docs/features/owasp-crs.md | 1 - docs/features/rate-limiting.md | 10 +++---- docs/getting-started.md | 26 +++------------- 13 files changed, 29 insertions(+), 106 deletions(-) diff --git a/docs/advanced/dynamic-throttle.md b/docs/advanced/dynamic-throttle.md index a9a2948..47272df 100644 --- a/docs/advanced/dynamic-throttle.md +++ b/docs/advanced/dynamic-throttle.md @@ -30,7 +30,6 @@ $config->throttles->add('role-based', limit: fn(ServerRequestInterface $request): int => $request->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, period: 60, - key: KeyExtractors::ip(), ); ``` @@ -46,7 +45,6 @@ $config->throttles->add('endpoint-adaptive', limit: 100, period: fn(ServerRequestInterface $request): int => str_starts_with($request->getUri()->getPath(), '/api/export') ? 3600 : 60, - key: KeyExtractors::ip(), ); ``` @@ -65,7 +63,6 @@ $config->throttles->add('fully-dynamic', $request->getHeaderLine('X-Plan') === 'enterprise' ? 10000 : 100, period: fn(ServerRequestInterface $request): int => $request->getHeaderLine('X-Plan') === 'enterprise' ? 3600 : 60, - key: KeyExtractors::ip(), ); ``` @@ -130,7 +127,6 @@ As time progresses within the current window, the previous window's contribution $config->throttles->sliding('api-sliding', limit: 100, period: 60, - key: KeyExtractors::ip(), ); ``` @@ -160,7 +156,7 @@ Register multiple time windows under a single logical name with `multi()`. This $config->throttles->multi('api', [ 1 => 3, // 3 requests per second (burst protection) 60 => 100, // 100 requests per minute (sustained limit) -], KeyExtractors::ip()); +]); ``` A request is blocked if it exceeds **any** window's limit. Windows are evaluated from shortest to longest period. @@ -186,7 +182,7 @@ $config->throttles->multi( Sub-rules follow the pattern `{name}:{period}s`: ```php -$config->throttles->multi('api', [1 => 5, 60 => 100, 3600 => 2000], KeyExtractors::ip()); +$config->throttles->multi('api', [1 => 5, 60 => 100, 3600 => 2000]); // Creates three rules: // - "api:1s" -> 5 req/s @@ -205,7 +201,7 @@ $config->throttles->multi('public-api', [ 1 => 10, // 10 req/s burst cap 60 => 300, // 300 req/min sustained 3600 => 5000, // 5000 req/hour daily budget -], KeyExtractors::ip()); +]); ``` ## Per-User Tier Limits diff --git a/docs/advanced/infrastructure.md b/docs/advanced/infrastructure.md index cc1cc7f..18fe1be 100644 --- a/docs/advanced/infrastructure.md +++ b/docs/advanced/infrastructure.md @@ -424,13 +424,11 @@ $config->fail2ban->add('login-abuse', threshold: 5, period: 300, ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Standard rate limiting $config->throttles->add('global', limit: 100, period: 60, - key: KeyExtractors::ip() ); $middleware = new Middleware($config); diff --git a/docs/advanced/psr17.md b/docs/advanced/psr17.md index 0b7a91c..742f0aa 100644 --- a/docs/advanced/psr17.md +++ b/docs/advanced/psr17.md @@ -340,7 +340,7 @@ $config->throttledResponseFactory = new Psr17ThrottledResponseFactory( $config->blocklists->add('admin', fn($req) => str_starts_with($req->getUri()->getPath(), '/admin') ); -$config->throttles->add('ip', 100, 60, KeyExtractors::ip()); +$config->throttles->add('ip', 100, 60); // Create middleware (also pass PSR-17 factory for base responses) $middleware = new Middleware($config, $psr17); @@ -438,7 +438,6 @@ $this->app->singleton(Middleware::class, function ($app) { ); $config->throttles->add('global', limit: 100, period: 60, - key: KeyExtractors::ip() ); return new Middleware($config, $psr17); diff --git a/docs/advanced/request-context.md b/docs/advanced/request-context.md index ba84537..d262f50 100644 --- a/docs/advanced/request-context.md +++ b/docs/advanced/request-context.md @@ -16,7 +16,6 @@ $config->fail2ban->add('login', threshold: 5, period: 300, ban: 3600, filter: fn($request) => $request->getMethod() === 'POST' && $request->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), ); ``` @@ -66,7 +65,6 @@ $config->fail2ban->add('login-failures', period: 300, ban: 3600, filter: fn(ServerRequestInterface $request): bool => false, - key: KeyExtractors::ip(), ); $middleware = new Middleware($config); @@ -265,7 +263,6 @@ $config->fail2ban->add('login-failures', period: 300, ban: 3600, filter: fn(ServerRequestInterface $request): bool => false, - key: KeyExtractors::ip(), ); $middleware = new Middleware($config, new Psr17Factory()); @@ -379,7 +376,6 @@ class RequestContextTest extends TestCase $config->fail2ban->add('test-rule', threshold: 2, period: 300, ban: 3600, filter: fn($request): bool => false, - key: KeyExtractors::ip(), ); $middleware = new Middleware($config, new Psr17Factory()); diff --git a/docs/advanced/track-notifications.md b/docs/advanced/track-notifications.md index 5d0b77f..36a4636 100644 --- a/docs/advanced/track-notifications.md +++ b/docs/advanced/track-notifications.md @@ -68,7 +68,6 @@ $config->tracks->add('login-attempts', period: 3600, filter: fn($request) => $request->getMethod() === 'POST' && $request->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), ); ``` @@ -96,7 +95,6 @@ use Flowd\Phirewall\KeyExtractors; $config->tracks->add('admin-access', period: 600, filter: fn($request) => str_starts_with($request->getUri()->getPath(), '/admin/'), - key: KeyExtractors::ip(), ); ``` diff --git a/docs/common-attacks.md b/docs/common-attacks.md index 3695c4e..72fde90 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -28,14 +28,13 @@ $config = new Config(new RedisCache($redis)); $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. + // recordFailure's key is optional; with none, the rule resolves the + // discriminator itself (here the client IP). $context?->recordFailure('login-failures'); } ``` @@ -63,7 +62,6 @@ $config->fail2ban->add('login-brute-force', $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login' && $req->getHeaderLine('X-Login-Failed') === '1', - key: KeyExtractors::ip(), ); ``` @@ -317,7 +315,7 @@ Catch both bursts and sustained abuse with multiple time windows: $config->throttles->multi('api', [ 1 => 3, // Burst protection 60 => 100, // Sustained limit -], KeyExtractors::ip()); +]); ``` ### Sliding Window @@ -328,7 +326,6 @@ Prevent the "double burst" problem at fixed-window boundaries: $config->throttles->sliding('api', limit: 100, period: 60, - key: KeyExtractors::ip(), ); ``` @@ -375,7 +372,6 @@ $config->allow2ban->add('volume-ban', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip(), ); ``` @@ -423,7 +419,6 @@ Monitor request patterns without blocking, alerting when thresholds are exceeded $config->tracks->add('sensitive-endpoints', period: 300, filter: fn($req): bool => str_starts_with($req->getUri()->getPath(), '/api/admin'), - key: KeyExtractors::ip(), limit: 50, ); ``` @@ -485,11 +480,10 @@ $config->blocklists->owasp('owasp', $rules); $config->fail2ban->add('login-brute-force', threshold: 5, period: 300, ban: 3600, filter: fn($req): bool => $req->getHeaderLine('X-Login-Failed') === '1', - key: KeyExtractors::ip(), ); // ── Layer 5: Throttling ─────────────────────────────────────────────── -$config->throttles->multi('api', [1 => 5, 60 => 200], KeyExtractors::ip()); +$config->throttles->multi('api', [1 => 5, 60 => 200]); $config->throttles->add('login', limit: 10, period: 60, key: function ($req): ?string { return $req->getUri()->getPath() === '/login' ? ($req->getServerParams()['REMOTE_ADDR'] ?? null) @@ -499,7 +493,6 @@ $config->throttles->add('login', limit: 10, period: 60, key: function ($req): ?s // ── Layer 6: Allow2Ban ──────────────────────────────────────────────── $config->allow2ban->add('volume-ban', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip(), ); // ── PSR-17 Responses ────────────────────────────────────────────────── diff --git a/docs/examples.md b/docs/examples.md index 767c79c..e7fd730 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -262,8 +262,8 @@ class PhirewallFactory $config->blocklists->owasp('owasp', $owaspRules); // ── Fail2Ban ───────────────────────────────────────────── - // KeyExtractors::ip() uses the global resolver set by - // setIpResolver() above — no need to repeat clientIp(). + // No key: these rules default to the client IP from the + // global resolver set above. $config->fail2ban->add('login-abuse', threshold: 5, period: 300, @@ -271,23 +271,19 @@ class PhirewallFactory filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // ── Rate Limiting ──────────────────────────────────────── $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); // ── Allow2Ban ──────────────────────────────────────────── $config->allow2ban->add('flood-protection', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); // ── PSR-17 Response Bodies ─────────────────────────────── @@ -486,8 +482,8 @@ class PhirewallServiceProvider extends ServiceProvider $config->blocklists->owasp('owasp', $owaspRules); // ── Fail2Ban ───────────────────────────────────────── - // KeyExtractors::ip() uses the global resolver set by - // setIpResolver() above — no need to repeat clientIp(). + // No key: these rules default to the client IP from the + // global resolver set above. $config->fail2ban->add('login-abuse', threshold: 5, period: 300, @@ -495,29 +491,24 @@ class PhirewallServiceProvider extends ServiceProvider filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // ── Rate Limiting ──────────────────────────────────── $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); $config->throttles->add('api', limit: fn(ServerRequestInterface $req): int => $req->getHeaderLine('X-Role') === 'admin' ? 5000 : 200, period: 60, - key: KeyExtractors::ip() ); // ── Allow2Ban ──────────────────────────────────────── $config->allow2ban->add('flood-protection', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); // ── PSR-17 Response Bodies ─────────────────────────── @@ -792,8 +783,8 @@ class PhirewallMiddlewareFactory $config->blocklists->owasp('owasp', $owaspRules); // ── Fail2Ban ───────────────────────────────────────────── - // KeyExtractors::ip() uses the global resolver set by - // setIpResolver() above — no need to repeat clientIp(). + // No key: these rules default to the client IP from the + // global resolver set above. $config->fail2ban->add('login-abuse', threshold: 5, period: 300, @@ -801,23 +792,19 @@ class PhirewallMiddlewareFactory filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // ── Rate Limiting ──────────────────────────────────────── $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); // ── Allow2Ban ──────────────────────────────────────────── $config->allow2ban->add('flood-protection', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); // ── PSR-17 Response Bodies ─────────────────────────────── @@ -935,7 +922,7 @@ final class PhirewallMiddleware implements MiddlewareInterface $req->getUri()->getPath() === '/health' ); $config->blocklists->knownScanners(); - $config->throttles->add('burst', limit: 30, period: 5, key: KeyExtractors::ip()); + $config->throttles->add('burst', limit: 30, period: 5); $config->usePsr17Responses($psr17, $psr17); @@ -1002,7 +989,6 @@ $config->blocklists->knownScanners(); // Rate limit: 100 requests per minute per IP $config->throttles->add('api', limit: 100, period: 60, - key: KeyExtractors::ip() ); $middleware = new Middleware($config); @@ -1083,7 +1069,6 @@ $config = new Config(new InMemoryCache()); $config->throttles->sliding('api-sliding', limit: 100, period: 60, - key: KeyExtractors::ip() ); ``` @@ -1107,7 +1092,7 @@ $config = new Config(new InMemoryCache()); $config->throttles->multi('api', [ 1 => 3, // 3 req/s burst limit 60 => 60, // 60 req/min sustained limit -], KeyExtractors::ip()); +]); ``` --- @@ -1130,7 +1115,6 @@ $config->throttles->add('role-based', limit: fn(ServerRequestInterface $req): int => $req->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, period: 60, - key: KeyExtractors::ip() ); ``` @@ -1156,7 +1140,6 @@ $config->tracks->add('login-attempts', period: 3600, filter: fn($req) => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST', - key: KeyExtractors::ip() ); // Track login attempts by username for alerting @@ -1204,7 +1187,6 @@ $config->fail2ban->add('login-brute-force', ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Per-username throttle: 5 attempts per 5 minutes per username @@ -1240,7 +1222,6 @@ $config->fail2ban->add('login-failures', period: 300, ban: 3600, filter: fn(ServerRequestInterface $req): bool => false, - key: KeyExtractors::ip() ); // In your login handler: @@ -1272,7 +1253,6 @@ $config->allow2ban->add('high-volume-ban', threshold: 100, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); // Ban by API key for authenticated routes. hashedHeader() stores a sha256 @@ -1398,14 +1378,12 @@ $config = new Config(new InMemoryCache()); $config->tracks->add('every-login-attempt', period: 60, filter: fn($req) => $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Track with threshold: thresholdReached=true at 5+ hits $config->tracks->add('suspicious-login-burst', period: 60, filter: fn($req) => $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), limit: 5, ); ``` @@ -1429,7 +1407,7 @@ $pdo->exec('PRAGMA journal_mode=WAL'); $cache = new PdoCache($pdo); $config = new Config($cache); -$config->throttles->add('api', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('api', limit: 100, period: 60); // MySQL (shared across multiple app servers) // $pdo = new PDO('mysql:host=db.example.com;dbname=myapp', getenv('DB_USER'), getenv('DB_PASSWORD')); @@ -1762,11 +1740,10 @@ $dispatcher = new class ($logger) implements EventDispatcherInterface { }; $config = new Config(new RedisCache($redis), $dispatcher); -$config->throttles->add('api', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('api', limit: 100, period: 60); $config->fail2ban->add('login', threshold: 5, period: 300, ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); ``` @@ -1808,12 +1785,10 @@ $config->blocklists->ip('known-bad', ['198.51.100.0/24', '203.0.113.0/24']); $config->fail2ban->add('persistent-scanner', threshold: 10, period: 60, ban: 86400, filter: fn($req) => true, - key: KeyExtractors::ip() ); // Global rate limit as backstop $config->throttles->add('global', limit: 100, period: 60, - key: KeyExtractors::ip() ); ``` diff --git a/docs/faq.md b/docs/faq.md index 7c292fe..8fba774 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -85,7 +85,7 @@ $proxy = new TrustedProxyResolver([ '192.168.0.0/16', // Private ranges ]); -// Apply globally to all rules that use KeyExtractors::ip() +// Default client-IP resolution for every rule added without an explicit key $config->setIpResolver(KeyExtractors::clientIp($proxy)); ``` @@ -233,7 +233,7 @@ See [Dynamic Throttle](/advanced/dynamic-throttle) for details. Use sliding window throttling: ```php -$config->throttles->sliding('api', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->sliding('api', limit: 100, period: 60); ``` Or combine multiple fixed windows with `multi()`: @@ -242,7 +242,7 @@ Or combine multiple fixed windows with `multi()`: $config->throttles->multi('api', [ 1 => 3, // 3 req/s burst protection 60 => 100, // 100 req/min sustained limit -], KeyExtractors::ip()); +]); ``` ### What happens when a throttle key returns `null`? @@ -377,7 +377,6 @@ The optional `limit` parameter adds a threshold to your track rule. When set, th $config->tracks->add('suspicious-burst', period: 60, filter: fn($request) => $request->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), limit: 10, // thresholdReached=true at 10+ hits ); ``` diff --git a/docs/features/bot-detection.md b/docs/features/bot-detection.md index beb1df4..eae47ee 100644 --- a/docs/features/bot-detection.md +++ b/docs/features/bot-detection.md @@ -322,13 +322,11 @@ $config->blocklists->add('scanner-paths', function ($req): bool { $config->fail2ban->add('persistent-scanner', threshold: 5, period: 60, ban: 86400, filter: fn($req) => true, - key: KeyExtractors::ip() ); // 6. Rate limit everything else $config->throttles->add('global', limit: 60, period: 60, - key: KeyExtractors::ip() ); ``` diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index b04a179..97644a1 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -79,7 +79,6 @@ $config->fail2ban->add('login-brute-force', ban: 3600, // 1 hour ban filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); ``` @@ -105,7 +104,6 @@ $config->fail2ban->add('credential-stuffing-ip', ban: 7200, // 2 hour ban filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Per-username throttle: 5 attempts per 5 minutes per username @@ -170,7 +168,6 @@ $config->fail2ban->add('persistent-scanner', period: 60, // in 1 minute ban: 86400, // 24 hour ban filter: fn($req) => true, - key: KeyExtractors::ip() ); ``` @@ -227,7 +224,6 @@ $config->fail2ban->add( period: 300, // 5 minute window ban: 3600, // 1 hour ban filter: fn(ServerRequestInterface $req): bool => false, - key: KeyExtractors::ip(), ); ``` @@ -349,7 +345,6 @@ $config->allow2ban->add( threshold: 100, period: 60, banSeconds: 3600, - key: KeyExtractors::ip(), ); ``` @@ -547,19 +542,16 @@ $config->fail2ban->add('login', threshold: 5, period: 300, ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Layer 4: Allow2Ban for volume abuse $config->allow2ban->add('volume-abuse', threshold: 200, period: 60, banSeconds: 1800, - key: KeyExtractors::ip() ); // Layer 5: Rate limiting as backstop $config->throttles->add('global', limit: 100, period: 60, - key: KeyExtractors::ip() ); ``` diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index 89d1987..c49c072 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -501,7 +501,6 @@ Use `@pm` for simple keyword matching and `@rx` for complex patterns. `@pm` is s $config->fail2ban->add('persistent-attacker', threshold: 5, period: 60, ban: 86400, filter: fn($req) => true, - key: KeyExtractors::ip() ); ``` diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index e340462..b9ba34c 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -19,7 +19,7 @@ For a ready-made per-client API rate limit (burst + sustained, scoped to `/api`) ::: ::: tip Default key -The `key` argument on `add()`, `sliding()`, and `multi()` is optional. When omitted, the throttle keys on the client IP resolved by the Config's IP resolver (set via `Config::setIpResolver(KeyExtractors::clientIp($trustedProxyResolver))` behind a proxy), falling back to `KeyExtractors::ip()` (REMOTE_ADDR) when none is set. The resolver is read per request, so it can be set before or after adding rules. The examples below pass `key:` explicitly to make the keying visible. +The `key` argument on `add()`, `sliding()`, and `multi()` is optional. When omitted, the throttle keys on the client IP resolved by the Config's IP resolver (set via `Config::setIpResolver(KeyExtractors::clientIp($trustedProxyResolver))` behind a proxy), falling back to `KeyExtractors::ip()` (REMOTE_ADDR) when none is set. The resolver is read per request, so it can be set before or after adding rules. The examples below omit `key:` to use this default; pass an explicit `key:` only to key on something other than the client IP (a header, a username, and so on). ::: ## Fixed Window Throttle @@ -46,7 +46,7 @@ $config->throttles->add( use Flowd\Phirewall\KeyExtractors; // 100 requests per minute per IP -$config->throttles->add('ip-limit', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('ip-limit', limit: 100, period: 60); ``` When the key closure returns `null`, the rule is skipped for that request. This lets you apply throttles conditionally -- only to certain paths, methods, or user types. @@ -81,7 +81,6 @@ $config->throttles->sliding( name: 'api-sliding', limit: 10, period: 60, - key: KeyExtractors::ip(), ); ``` @@ -129,7 +128,7 @@ Each entry creates a sub-rule named `{$name}:{$period}s`. Windows are evaluated $config->throttles->multi('api', [ 1 => 3, // "api:1s" -- burst protection 60 => 60, // "api:60s" -- sustained throughput -], KeyExtractors::ip()); +]); ``` A request is blocked if it exceeds **any** of the windows. This catches both rapid-fire bursts and slow-and-steady abuse. @@ -142,7 +141,7 @@ $config->throttles->multi('public-api', [ 1 => 5, // 5 req/s burst 60 => 200, // 200 req/min sustained 3600 => 5000, // 5000 req/hour daily budget -], KeyExtractors::ip()); +]); // Login endpoint with tight controls $config->throttles->multi('login', [ @@ -208,7 +207,6 @@ $config->throttles->add( 100, fn(ServerRequestInterface $req): int => (int) date('G') >= 9 && (int) date('G') < 17 ? 30 : 60, - KeyExtractors::ip() ); ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 32d6c44..c80f2da 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -148,22 +148,21 @@ Throttled requests receive `429 Too Many Requests` with a `Retry-After` header. ```php // 100 requests per minute per IP -$config->throttles->add('ip-minute', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('ip-minute', limit: 100, period: 60); // Sliding window (prevents double-burst at window boundaries) -$config->throttles->sliding('api-sliding', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->sliding('api-sliding', limit: 100, period: 60); // Multi-window (burst + sustained limits in a single call) $config->throttles->multi('api', [ 1 => 5, // 5 req/s burst limit 60 => 100, // 100 req/min sustained limit -], KeyExtractors::ip()); +]); // Dynamic limits based on request properties $config->throttles->add('role-based', limit: fn($req) => $req->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, period: 60, - key: KeyExtractors::ip() ); // Enable standard rate limit headers @@ -184,7 +183,6 @@ $config->fail2ban->add('login-abuse', ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); ``` @@ -200,7 +198,6 @@ $config->allow2ban->add('high-volume', threshold: 1000, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); ``` @@ -214,14 +211,12 @@ Track rules count requests passively without blocking. Use them for dashboards, $config->tracks->add('login-attempts', period: 3600, filter: fn($req) => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST', - key: KeyExtractors::ip() ); // Track with a threshold for alerting $config->tracks->add('suspicious-burst', period: 60, filter: fn($req) => $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), limit: 10, // TrackHit event includes thresholdReached flag at 10+ hits ); ``` @@ -311,17 +306,14 @@ class PhirewallFactory filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Rate limiting $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); $psr17 = new Psr17Factory(); @@ -472,17 +464,14 @@ class PhirewallServiceProvider extends ServiceProvider filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Rate limiting $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); $psr17 = new Psr17Factory(); @@ -601,17 +590,14 @@ $config->fail2ban->add('login-abuse', filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Rate limiting $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); // Add Phirewall LAST (Slim LIFO = executes first) @@ -674,17 +660,14 @@ class PhirewallMiddlewareFactory filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Rate limiting $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); $psr17 = new Psr17Factory(); @@ -743,7 +726,7 @@ $config->blocklists->add('scanner-probe', fn($req) => str_starts_with($req->getU $config->blocklists->knownScanners(); // Rate limit: 10 requests per minute per IP -$config->throttles->add('ip-limit', limit: 10, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('ip-limit', limit: 10, period: 60); // Fail2Ban: Ban IPs that POST to /login more than 3 times in 2 minutes $config->fail2ban->add('login', @@ -752,7 +735,6 @@ $config->fail2ban->add('login', ban: 300, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // 3. Create middleware From 00770c68d8938123b5eabeea402d6f6e01156d3d Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 8 Jun 2026 22:33:45 +0200 Subject: [PATCH 07/17] Drop the manual TYPO3 integration; point to the flowd/typo3-firewall extension The hand-rolled middleware wiring was redundant and easy to get wrong. The official flowd/typo3-firewall extension registers Phirewall's PSR-15 middleware in the frontend stack and adds a backend module, so the TYPO3 section now just recommends it. --- docs/examples.md | 95 ------------------------------------------------ 1 file changed, 95 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index e7fd730..b6c9ac1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -864,101 +864,6 @@ composer require flowd/typo3-firewall Phirewall is then configured in TYPO3's core configuration file `config/system/phirewall.php` (the full [Phirewall configuration](/getting-started) applies there), and block patterns created in the backend module are stored in `config/system/phirewall.patterns.json` and take effect immediately. See the [extension documentation](https://docs.typo3.org/p/flowd/typo3-firewall/main/en-us/) for details. -#### Manual integration (without the extension) - -If you cannot use the extension, TYPO3 (v12/v13) runs a PSR-15 middleware stack, so Phirewall can plug in through your own extension's `Configuration/RequestMiddlewares.php`. Two TYPO3 specifics matter: - -- **The middleware is resolved from the DI container**, so its service must be **public**. The `#[Autoconfigure(public: true)]` attribute below marks it public; it relies on your extension's standard `Configuration/Services.yaml` loading `../Classes/*` (the default extension skeleton already does). -- **TYPO3's caching framework is not PSR-16.** `CacheManager::getCache()` returns a `\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface`, not a `Psr\SimpleCache\CacheInterface`, so it cannot be passed to `Config` directly. Use one of Phirewall's bundled PSR-16 stores (`RedisCache`, `ApcuCache`, `PdoCache`); to reuse a TYPO3 cache you would need a PSR-16 adapter (e.g. `ssch/typo3-psr-cache-adapter`). - -Phirewall ships as a PSR-15 middleware that takes a `Config` (which needs a cache and a PSR-17 factory), so it is not autowirable as-is. Wrap it in a small middleware class your extension owns: - -**`Classes/Middleware/PhirewallMiddleware.php`** - -```php -setKeyPrefix('typo3'); - $config->enableRateLimitHeaders(); - - // TYPO3 sits behind a reverse proxy in most setups; resolve the - // real client IP so rules key on the visitor, not the proxy. - $config->setIpResolver( - KeyExtractors::clientIp(new TrustedProxyResolver(['10.0.0.0/8'])) - ); - - $config->safelists->add('health', - fn(ServerRequestInterface $req): bool => - $req->getUri()->getPath() === '/health' - ); - $config->blocklists->knownScanners(); - $config->throttles->add('burst', limit: 30, period: 5); - - $config->usePsr17Responses($psr17, $psr17); - - $this->phirewall = new Phirewall($config, $psr17); - } - - public function process( - ServerRequestInterface $request, - RequestHandlerInterface $handler, - ): ResponseInterface { - return $this->phirewall->process($request, $handler); - } -} -``` - -**`Configuration/RequestMiddlewares.php`** - -```php - [ - 'myvendor/myextension/phirewall' => [ - 'target' => \MyVendor\MyExtension\Middleware\PhirewallMiddleware::class, - // Run after normalized params (so the resolved client IP is - // available) and before site resolution, so a blocked request - // never triggers site/page lookup work. - 'after' => ['typo3/cms-core/normalized-params-attribute'], - 'before' => ['typo3/cms-frontend/site'], - ], - ], -]; -``` - -After changing the middleware order, verify the computed stack in the TYPO3 backend (System → Configuration, or the `lowlevel` configuration module). - --- ## Basic: Minimal Setup From 9c0baea25d9c3839e4443d974c2e46d58c41d229 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 8 Jun 2026 22:54:18 +0200 Subject: [PATCH 08/17] Switch the docs site to pnpm and upgrade VitePress Replace the npm package-lock.json with pnpm-lock.yaml and pnpm-workspace.yaml, and bump vitepress to 2.0.0-alpha.17. --- package-lock.json | 2511 ------------------------------------------- package.json | 2 +- pnpm-lock.yaml | 1401 ++++++++++++++++++++++++ pnpm-workspace.yaml | 4 + 4 files changed, 1406 insertions(+), 2512 deletions(-) delete mode 100644 package-lock.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index cfdc79a..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2511 +0,0 @@ -{ - "name": "phirewall-docs", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "phirewall-docs", - "devDependencies": { - "vitepress": "^1.6.0" - } - }, - "node_modules/@algolia/abtesting": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.15.2.tgz", - "integrity": "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", - "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", - "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", - "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", - "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.49.2.tgz", - "integrity": "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.49.2.tgz", - "integrity": "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.49.2.tgz", - "integrity": "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.49.2.tgz", - "integrity": "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.49.2.tgz", - "integrity": "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.49.2.tgz", - "integrity": "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.49.2.tgz", - "integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.49.2", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.49.2.tgz", - "integrity": "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.49.2", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.49.2.tgz", - "integrity": "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.49.2.tgz", - "integrity": "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.2.tgz", - "integrity": "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.49.2.tgz", - "integrity": "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.49.2.tgz", - "integrity": "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@docsearch/css": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", - "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@docsearch/js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", - "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/react": "3.8.2", - "preact": "^10.0.0" - } - }, - "node_modules/@docsearch/react": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", - "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-core": "1.17.7", - "@algolia/autocomplete-preset-algolia": "1.17.7", - "@docsearch/css": "3.8.2", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 19.0.0", - "react": ">= 16.8.0 < 19.0.0", - "react-dom": ">= 16.8.0 < 19.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@iconify-json/simple-icons": { - "version": "1.2.74", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.74.tgz", - "integrity": "sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA==", - "dev": true, - "license": "CC0-1.0", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@shikijs/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", - "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", - "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^3.1.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", - "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", - "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/themes": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", - "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/transformers": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", - "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", - "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", - "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", - "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", - "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.30", - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.8", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", - "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", - "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", - "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", - "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/runtime-core": "3.5.30", - "@vue/shared": "3.5.30", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", - "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "vue": "3.5.30" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", - "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vueuse/core": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/integrations": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", - "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vueuse/core": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "async-validator": "^4", - "axios": "^1", - "change-case": "^5", - "drauu": "^0.4", - "focus-trap": "^7", - "fuse.js": "^7", - "idb-keyval": "^6", - "jwt-decode": "^4", - "nprogress": "^0.2", - "qrcode": "^1.5", - "sortablejs": "^1", - "universal-cookie": "^7" - }, - "peerDependenciesMeta": { - "async-validator": { - "optional": true - }, - "axios": { - "optional": true - }, - "change-case": { - "optional": true - }, - "drauu": { - "optional": true - }, - "focus-trap": { - "optional": true - }, - "fuse.js": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "jwt-decode": { - "optional": true - }, - "nprogress": { - "optional": true - }, - "qrcode": { - "optional": true - }, - "sortablejs": { - "optional": true - }, - "universal-cookie": { - "optional": true - } - } - }, - "node_modules/@vueuse/metadata": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", - "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/algoliasearch": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.49.2.tgz", - "integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.15.2", - "@algolia/client-abtesting": "5.49.2", - "@algolia/client-analytics": "5.49.2", - "@algolia/client-common": "5.49.2", - "@algolia/client-insights": "5.49.2", - "@algolia/client-personalization": "5.49.2", - "@algolia/client-query-suggestions": "5.49.2", - "@algolia/client-search": "5.49.2", - "@algolia/ingestion": "1.49.2", - "@algolia/monitoring": "1.49.2", - "@algolia/recommend": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/focus-trap": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", - "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tabbable": "^6.4.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mark.js": { - "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/minisearch": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", - "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", - "dev": true, - "license": "MIT" - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/oniguruma-to-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", - "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/preact": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", - "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/shiki": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", - "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/langs": "2.5.0", - "@shikijs/themes": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "dev": true, - "license": "MIT" - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" - }, - "bin": { - "vitepress": "bin/vitepress.js" - }, - "peerDependencies": { - "markdown-it-mathjax3": "^4", - "postcss": "^8" - }, - "peerDependenciesMeta": { - "markdown-it-mathjax3": { - "optional": true - }, - "postcss": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", - "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-sfc": "3.5.30", - "@vue/runtime-dom": "3.5.30", - "@vue/server-renderer": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/package.json b/package.json index 7c11391..6c3c759 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,6 @@ "preview": "vitepress preview docs" }, "devDependencies": { - "vitepress": "^1.6.4" + "vitepress": "2.0.0-alpha.17" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..6ac89d9 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1401 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + vitepress: + specifier: 2.0.0-alpha.17 + version: 2.0.0-alpha.17(postcss@8.5.15) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@docsearch/css@4.6.3': + resolution: {integrity: sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==} + + '@docsearch/js@4.6.3': + resolution: {integrity: sha512-qUIX2b4Apew3tv4F0qhmgShsl/Lfw4m6mqv/5/5dWNxwTcDdLMp2s3YwZ+NMGh3IKCg0pBaXm7Q5VdyU5Rj+cQ==} + + '@docsearch/sidepanel-js@4.6.3': + resolution: {integrity: sha512-grGSmvXzG0if+mrzdIKykvpIAuEQ9u0sEJ2eLRRCaQfJvsWqh2C2/aY04bIzWvDh7myi5rvl8D+tUNsVrjYQ3A==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@iconify-json/simple-icons@1.2.85': + resolution: {integrity: sha512-Hp5LXvd3LRk+e+1558wtonA7c1Z0/Phmi7xCqpgtb8bs8cuyGnP34GDbt5uhhUXxKlzacnnhAcXgcDxe9bUa1w==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + cpu: [x64] + os: [win32] + + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@3.23.0': + resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@vitejs/plugin-vue@6.0.7': + resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.35': + resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==} + + '@vue/compiler-dom@3.5.35': + resolution: {integrity: sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==} + + '@vue/compiler-sfc@3.5.35': + resolution: {integrity: sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==} + + '@vue/compiler-ssr@3.5.35': + resolution: {integrity: sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==} + + '@vue/devtools-api@8.1.2': + resolution: {integrity: sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==} + + '@vue/devtools-kit@8.1.2': + resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==} + + '@vue/devtools-shared@8.1.2': + resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==} + + '@vue/reactivity@3.5.35': + resolution: {integrity: sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==} + + '@vue/runtime-core@3.5.35': + resolution: {integrity: sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==} + + '@vue/runtime-dom@3.5.35': + resolution: {integrity: sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==} + + '@vue/server-renderer@3.5.35': + resolution: {integrity: sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==} + peerDependencies: + vue: 3.5.35 + + '@vue/shared@3.5.35': + resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==} + + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/integrations@14.3.0': + resolution: {integrity: sha512-76I5FT2ESvCmCaSwapI+a/u/CFtNXmzl9f9lNp1hRtx8vKB8hfiokJr8IvQqcQG5ckGXElyXK516b54ozV3MvA==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 || ^8 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 || ^8 + vue: ^3.5.0 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + focus-trap@8.2.1: + resolution: {integrity: sha512-6CxwrrFRquH7pDXb1mWxudkU9LSfYBMRZutpgddb2o6iwCk7cIRrBhyY3c8SGKcmIKdeMTrGSNg4Bedh2RSF/w==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitepress@2.0.0-alpha.17: + resolution: {integrity: sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + oxc-minify: '*' + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + oxc-minify: + optional: true + postcss: + optional: true + + vue@3.5.35: + resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@docsearch/css@4.6.3': {} + + '@docsearch/js@4.6.3': {} + + '@docsearch/sidepanel-js@4.6.3': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@iconify-json/simple-icons@1.2.85': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.61.1': + optional: true + + '@rollup/rollup-android-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-x64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.1': + optional: true + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.3.1': {} + + '@vitejs/plugin-vue@6.0.7(vite@7.3.5)(vue@3.5.35)': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 7.3.5 + vue: 3.5.35 + + '@vue/compiler-core@3.5.35': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.35 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.35': + dependencies: + '@vue/compiler-core': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/compiler-sfc@3.5.35': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.35 + '@vue/compiler-dom': 3.5.35 + '@vue/compiler-ssr': 3.5.35 + '@vue/shared': 3.5.35 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.35': + dependencies: + '@vue/compiler-dom': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/devtools-api@8.1.2': + dependencies: + '@vue/devtools-kit': 8.1.2 + + '@vue/devtools-kit@8.1.2': + dependencies: + '@vue/devtools-shared': 8.1.2 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.1.2': {} + + '@vue/reactivity@3.5.35': + dependencies: + '@vue/shared': 3.5.35 + + '@vue/runtime-core@3.5.35': + dependencies: + '@vue/reactivity': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/runtime-dom@3.5.35': + dependencies: + '@vue/reactivity': 3.5.35 + '@vue/runtime-core': 3.5.35 + '@vue/shared': 3.5.35 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.35(vue@3.5.35)': + dependencies: + '@vue/compiler-ssr': 3.5.35 + '@vue/shared': 3.5.35 + vue: 3.5.35 + + '@vue/shared@3.5.35': {} + + '@vueuse/core@14.3.0(vue@3.5.35)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.35) + vue: 3.5.35 + + '@vueuse/integrations@14.3.0(focus-trap@8.2.1)(vue@3.5.35)': + dependencies: + '@vueuse/core': 14.3.0(vue@3.5.35) + '@vueuse/shared': 14.3.0(vue@3.5.35) + vue: 3.5.35 + optionalDependencies: + focus-trap: 8.2.1 + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@14.3.0(vue@3.5.35)': + dependencies: + vue: 3.5.35 + + birpc@2.9.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + comma-separated-tokens@2.0.3: {} + + csstype@3.2.3: {} + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + entities@7.0.1: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + focus-trap@8.2.1: + dependencies: + tabbable: 6.4.0 + + fsevents@2.3.3: + optional: true + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + minisearch@7.2.0: {} + + nanoid@3.3.12: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.2.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rollup@4.61.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 + fsevents: 2.3.3 + + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + tabbable@6.4.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + trim-lines@3.0.1: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.5: + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitepress@2.0.0-alpha.17(postcss@8.5.15): + dependencies: + '@docsearch/css': 4.6.3 + '@docsearch/js': 4.6.3 + '@docsearch/sidepanel-js': 4.6.3 + '@iconify-json/simple-icons': 1.2.85 + '@shikijs/core': 3.23.0 + '@shikijs/transformers': 3.23.0 + '@shikijs/types': 3.23.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 6.0.7(vite@7.3.5)(vue@3.5.35) + '@vue/devtools-api': 8.1.2 + '@vue/shared': 3.5.35 + '@vueuse/core': 14.3.0(vue@3.5.35) + '@vueuse/integrations': 14.3.0(focus-trap@8.2.1)(vue@3.5.35) + focus-trap: 8.2.1 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 3.23.0 + vite: 7.3.5 + vue: 3.5.35 + optionalDependencies: + postcss: 8.5.15 + transitivePeerDependencies: + - '@types/node' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jiti + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - sass + - sass-embedded + - sortablejs + - stylus + - sugarss + - terser + - tsx + - typescript + - universal-cookie + - yaml + + vue@3.5.35: + dependencies: + '@vue/compiler-dom': 3.5.35 + '@vue/compiler-sfc': 3.5.35 + '@vue/runtime-dom': 3.5.35 + '@vue/server-renderer': 3.5.35(vue@3.5.35) + '@vue/shared': 3.5.35 + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..da5823f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true +minimumReleaseAgeExclude: + - pnpm@11.5.0 From dc5f2101fa2bd04ffaada9b9ee26bfedd77a39b3 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 8 Jun 2026 23:04:17 +0200 Subject: [PATCH 09/17] Document the TYPO3 phirewall.php config-file format config/system/phirewall.php must return a closure that receives the PSR-14 EventDispatcherInterface and returns a built Config (cache first, dispatcher second). Add a minimal example. --- docs/examples.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/examples.md b/docs/examples.md index b6c9ac1..1fb6716 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -862,7 +862,26 @@ $app->pipe(\Mezzio\Router\Middleware\DispatchMiddleware::class); composer require flowd/typo3-firewall ``` -Phirewall is then configured in TYPO3's core configuration file `config/system/phirewall.php` (the full [Phirewall configuration](/getting-started) applies there), and block patterns created in the backend module are stored in `config/system/phirewall.patterns.json` and take effect immediately. See the [extension documentation](https://docs.typo3.org/p/flowd/typo3-firewall/main/en-us/) for details. +Phirewall is then configured in TYPO3's core configuration file `config/system/phirewall.php`. That file must **return a closure** that receives the PSR-14 `EventDispatcherInterface` and returns a built `Config` (the cache is the first constructor argument, the dispatcher the second). The full [Phirewall configuration](/getting-started) applies inside the closure; use one of Phirewall's bundled PSR-16 stores (`RedisCache`, `ApcuCache`, `PdoCache`), since TYPO3's own caches are not PSR-16. + +```php +blocklists->knownScanners(); + $config->throttles->add('burst', limit: 30, period: 5); + + return $config; +}; +``` + +Block patterns created in the backend module are stored in `config/system/phirewall.patterns.json` and take effect immediately. See the [extension documentation](https://docs.typo3.org/p/flowd/typo3-firewall/main/en-us/) for details. --- From 2bc43d57d39065137f347d6a71dc5a5bf268e22c Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 8 Jun 2026 23:27:39 +0200 Subject: [PATCH 10/17] Stop keying rate-limit examples on client-controlled headers Rate-limit, fail2ban and allow2ban examples now key on the client IP (the default) instead of forgeable request headers like X-User-Id / X-Api-Key, which a caller can rotate or drop to evade the limit. The KeyExtractors reference table and the "header keys are client-controlled" warnings stay. --- docs/advanced/dynamic-throttle.md | 6 ++---- docs/advanced/track-notifications.md | 5 +---- docs/common-attacks.md | 7 +++---- docs/examples.md | 27 +-------------------------- docs/faq.md | 3 +-- docs/features/fail2ban.md | 9 ++------- docs/features/rate-limiting.md | 25 +------------------------ 7 files changed, 11 insertions(+), 71 deletions(-) diff --git a/docs/advanced/dynamic-throttle.md b/docs/advanced/dynamic-throttle.md index 47272df..52c7e38 100644 --- a/docs/advanced/dynamic-throttle.md +++ b/docs/advanced/dynamic-throttle.md @@ -221,8 +221,7 @@ $config->throttles->add('api', default => 50, }, period: 60, - key: fn($request): ?string => $request->getHeaderLine('X-User-Id') - ?: $request->getServerParams()['REMOTE_ADDR'] ?? null, + key: fn($request): ?string => $request->getServerParams()['REMOTE_ADDR'] ?? null, ); ``` @@ -342,8 +341,7 @@ $config->throttles->add('db-tiered', default => 50, }, period: 60, - key: fn($request): ?string => $request->getHeaderLine('X-User-Id') - ?: $request->getServerParams()['REMOTE_ADDR'] ?? null, + key: fn($request): ?string => $request->getServerParams()['REMOTE_ADDR'] ?? null, ); ``` diff --git a/docs/advanced/track-notifications.md b/docs/advanced/track-notifications.md index 36a4636..5eb8976 100644 --- a/docs/advanced/track-notifications.md +++ b/docs/advanced/track-notifications.md @@ -73,15 +73,12 @@ $config->tracks->add('login-attempts', ### Track API Usage by User -Monitor per-user API consumption using a custom header: +Monitor API consumption per client (keyed on the client IP by default): ```php -use Flowd\Phirewall\KeyExtractors; - $config->tracks->add('api-usage', period: 3600, filter: fn($request) => str_starts_with($request->getUri()->getPath(), '/api/'), - key: KeyExtractors::header('X-User-Id'), ); ``` diff --git a/docs/common-attacks.md b/docs/common-attacks.md index 72fde90..9708c65 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -377,15 +377,14 @@ $config->allow2ban->add('volume-ban', ## API Abuse -### API Key Throttling +### API Endpoint Throttling -Rate-limit by API key for authenticated endpoints. `hashedHeader()` stores a sha256 fingerprint of the key in the cache backend rather than the raw value: +Rate-limit API traffic per client IP, the value a caller cannot forge (behind a proxy, resolve it with `KeyExtractors::clientIp()` and a `TrustedProxyResolver`): ```php -$config->throttles->add('api-key', +$config->throttles->add('api', limit: 1000, period: 60, - key: KeyExtractors::hashedHeader('X-Api-Key'), ); ``` diff --git a/docs/examples.md b/docs/examples.md index 1fb6716..55a1fa2 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -922,7 +922,7 @@ $middleware = new Middleware($config); ## Basic: API Rate Limiting -Tiered rate limits for an API with authenticated and anonymous users. +Tiered per-client-IP rate limits for an API, with a tighter cap on an expensive endpoint. ```php use Flowd\Phirewall\Config; @@ -949,21 +949,6 @@ $config->throttles->add('global', key: KeyExtractors::clientIp($proxyResolver) ); -// Authenticated user limits (higher) -$config->throttles->add('user', - limit: 5000, period: 60, - key: KeyExtractors::header('X-User-Id') -); - -// Anonymous limits (lower, skip if authenticated) -$config->throttles->add('anon', - limit: 100, period: 60, - key: function ($req) use ($proxyResolver): ?string { - if ($req->getHeaderLine('X-User-Id') !== '') return null; - return $proxyResolver->resolve($req); - } -); - // Expensive endpoint limit $config->throttles->add('search', limit: 20, period: 60, @@ -1167,7 +1152,6 @@ Allow2Ban is the inverse of Fail2Ban: it counts every request for a key and bans ```php use Flowd\Phirewall\Config; -use Flowd\Phirewall\KeyExtractors; use Flowd\Phirewall\Store\InMemoryCache; $config = new Config(new InMemoryCache()); @@ -1178,15 +1162,6 @@ $config->allow2ban->add('high-volume-ban', period: 60, banSeconds: 3600, ); - -// Ban by API key for authenticated routes. hashedHeader() stores a sha256 -// fingerprint of the key in the ban registry instead of the raw credential. -$config->allow2ban->add('api-key-ban', - threshold: 1000, - period: 60, - banSeconds: 300, - key: KeyExtractors::hashedHeader('X-Api-Key') -); ``` See [Fail2Ban & Allow2Ban](/features/fail2ban) for details. diff --git a/docs/faq.md b/docs/faq.md index 8fba774..5378e64 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -271,8 +271,7 @@ $config->throttles->add('api', default => 100, }, period: 60, - key: fn($request): ?string => $request->getHeaderLine('X-User-Id') - ?: $request->getServerParams()['REMOTE_ADDR'] ?? null, + key: fn($request): ?string => $request->getServerParams()['REMOTE_ADDR'] ?? null, ); ``` diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index 97644a1..745ec6b 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -353,17 +353,12 @@ $config->allow2ban->add( Ban API keys that exceed expected usage. Unlike rate limiting (which returns 429 and lets the client retry), Allow2Ban **bans** the key entirely -- a stronger response for abuse: ```php -use Flowd\Phirewall\KeyExtractors; - -// Ban any API key that makes more than 1000 requests in 60 seconds. -// hashedHeader() stores the sha256 fingerprint instead of the raw key so the -// ban registry doesn't carry the credential verbatim. +// Ban any client IP that makes more than 1000 requests in 60 seconds. $config->allow2ban->add( - name: 'api-key-abuse', + name: 'api-volume-abuse', threshold: 1000, period: 60, banSeconds: 300, // 5 minute ban - key: KeyExtractors::hashedHeader('X-Api-Key'), ); ``` diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index b9ba34c..04460b6 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -301,30 +301,7 @@ $config->throttles->add('search-endpoint', ## Per-User Limits -Differentiate between authenticated and anonymous traffic: - -```php -// Authenticated user limits (higher) -$config->throttles->add('api-user', - limit: 1000, period: 3600, - key: KeyExtractors::header('X-User-Id') -); - -// Anonymous limits (lower, keyed by IP) -$config->throttles->add('api-anon', - limit: 100, period: 3600, - key: function ($req) use ($proxyResolver): ?string { - if ($req->getHeaderLine('X-User-Id') !== '') { - return null; // Skip authenticated requests - } - return $proxyResolver->resolve($req); - } -); -``` - -::: tip -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. -::: +Enforce rate limits at the firewall on the client IP, which a caller cannot forge (behind a proxy, resolve it with `KeyExtractors::clientIp()` and a `TrustedProxyResolver`). Do not key a limit on a client-supplied header such as `X-User-Id` or `X-Api-Key`: a caller can rotate or drop it to land in a fresh counter on every request and never reach the limit. For genuine per-authenticated-user limits, enforce them behind your application's auth layer, where the user identity has been verified, rather than on a raw request header at the edge. ::: 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. From 9d81932bb0cf8c49cff23dde1e77f8eac0b31739 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Mon, 8 Jun 2026 23:53:09 +0200 Subject: [PATCH 11/17] Adjust github workflow to use pnpm over npm --- .github/workflows/deploy.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6d1260f..248e070 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,12 +11,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@v6 + name: Install pnpm with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm run build + version: 11 + cache: true + - run: pnpm ci + - run: pnpm run build - uses: actions/upload-artifact@v4 with: name: docs-dist From 9e55757e67931b75e52d0fc6c61d26ff4fc95dc1 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Tue, 9 Jun 2026 07:50:49 +0200 Subject: [PATCH 12/17] Review and refine the 0.5.0 documentation Apply a specialist review pass over the docs site: - Correct the trusted-proxy resolver description (flattened chain plus trusted-peer gate, not the last instance) on getting-started, faq, and rate-limiting. - Key the credential-stuffing and API-abuse examples on non-forgeable values and caveat the tier and identity header examples. - Fix the Laravel trusted-proxy source, the Symfony services config and env notes and listener probe, and the Mezzio ErrorHandler ordering. - Add a plain-PHP front controller, repair the First Test step, and introduce the key and request concepts for newcomers. - Drop per-version framing so the docs read as the current 0.5.0 state. - Normalize dash style throughout. --- docs/advanced/architecture.md | 20 ++-- docs/advanced/config-composition.md | 16 +-- docs/advanced/discriminator-normalizer.md | 32 +++--- docs/advanced/dynamic-throttle.md | 16 +-- docs/advanced/infrastructure.md | 16 +-- docs/advanced/observability.md | 18 +-- docs/advanced/portable-config.md | 42 +++---- docs/advanced/presets.md | 18 +-- docs/advanced/psr17.md | 8 +- docs/advanced/request-context.md | 30 ++--- docs/advanced/track-notifications.md | 20 ++-- docs/common-attacks.md | 38 ++++--- docs/examples.md | 90 ++++++++++----- docs/faq.md | 65 ++++------- docs/features/bot-detection.md | 16 +-- docs/features/fail2ban.md | 46 ++++---- docs/features/owasp-crs.md | 6 +- docs/features/rate-limiting.md | 18 +-- docs/features/safelists-blocklists.md | 34 +++--- docs/features/storage.md | 24 ++-- docs/getting-started.md | 131 ++++++++++++++-------- docs/index.md | 8 +- docs/services.md | 14 +-- 23 files changed, 390 insertions(+), 336 deletions(-) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 55928e8..1faf816 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -4,7 +4,7 @@ outline: deep # Architecture -Phirewall's core decision engine uses an **evaluator pipeline** -- a sequential chain of single-responsibility evaluator classes, each handling one type of firewall rule. The pipeline processes every request and short-circuits on the first decisive result. +Phirewall's core decision engine uses an **evaluator pipeline**, a sequential chain of single-responsibility evaluator classes, each handling one type of firewall rule. The pipeline processes every request and short-circuits on the first decisive result. ## Evaluator Pipeline @@ -14,7 +14,7 @@ When `Firewall::decide()` is called, it creates an `EvaluationContext` and passe Request | v -TrackEvaluator (passive counting -- always continues) +TrackEvaluator (passive counting, always continues) | v SafelistEvaluator (match? --> allow, skip remaining) @@ -74,8 +74,8 @@ The `EvaluationContext` is a mutable transport object that carries shared config The context also provides helper methods: -- `dispatch(object $event): void` -- dispatches a PSR-14 event if a dispatcher is configured -- `responseHeaders(string $type, string $rule): array` -- builds `X-Phirewall` response headers when enabled +- `dispatch(object $event): void` - dispatches a PSR-14 event if a dispatcher is configured +- `responseHeaders(string $type, string $rule): array` - builds `X-Phirewall` response headers when enabled ## Evaluators @@ -95,7 +95,7 @@ Checks blocklist rules. On the first match, dispatches `BlocklistMatched`, sets For each fail2ban rule: -1. Checks if the key is already banned -- if so, returns a blocked result immediately +1. Checks if the key is already banned - if so, returns a blocked result immediately 2. If the filter matches, increments the failure counter and bans if the threshold is reached Both the pre-handler path (during `decide()`) and the post-handler path (via `processRecordedSignal()`) use the same `count >= threshold` comparison: the Nth matching request triggers the ban and is itself blocked. This matches rack-attack's `maxretry` semantics and is consistent with Allow2Ban. @@ -136,13 +136,13 @@ The order is optimized so cheap checks run before expensive ones, and passive tr ## Performance -The evaluator pipeline adds negligible overhead. 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. +The evaluator pipeline adds negligible overhead. Each evaluator is a lightweight object; the `Fail2BanEvaluator` and `Allow2BanEvaluator` are additionally retained on the firewall so post-handler signal processing can reuse them. The pipeline iterates a fixed-size array with early exit on the first decisive result. Performance timing for every `decide()` call is captured in the `PerformanceMeasured` event, which includes the `DecisionPath` and `durationMicros`. See [Observability](/advanced/observability#performancemeasured) for details. ## Related Pages -- [Observability](/advanced/observability) -- PSR-14 events, diagnostics counters, performance monitoring -- [Request Context](/advanced/request-context) -- post-handler failure signaling for Fail2Ban -- [Rate Limiting](/features/rate-limiting) -- throttle rules, sliding windows, and multi-throttle -- [Fail2Ban & Allow2Ban](/features/fail2ban) -- automatic banning configuration +- [Observability](/advanced/observability) - PSR-14 events, diagnostics counters, performance monitoring +- [Request Context](/advanced/request-context) - post-handler failure signaling for Fail2Ban +- [Rate Limiting](/features/rate-limiting) - throttle rules, sliding windows, and multi-throttle +- [Fail2Ban & Allow2Ban](/features/fail2ban) - automatic banning configuration diff --git a/docs/advanced/config-composition.md b/docs/advanced/config-composition.md index ac5c21e..2421f3d 100644 --- a/docs/advanced/config-composition.md +++ b/docs/advanced/config-composition.md @@ -4,7 +4,7 @@ outline: deep # Config Composition -Real deployments rarely have a single source of firewall rules. A vendor ships a baseline, an environment (staging vs. production) adds its own rules, a tenant overrides a few, and a single deployment applies a last-minute tweak. `Config::compose()` (and the fluent `Config::mergedWith()`) merges these layers into one effective `Config` — **without mutating any input** — so each layer can be owned, versioned, and shipped independently, often as a [`PortableConfig`](/advanced/portable-config). +Real deployments rarely have a single source of firewall rules. A vendor ships a baseline, an environment (staging vs. production) adds its own rules, a tenant overrides a few, and a single deployment applies a last-minute tweak. `Config::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 @@ -12,7 +12,7 @@ Real deployments rarely have a single source of firewall rules. A vendor ships a use Flowd\Phirewall\Config; // Each layer is owned and versioned independently, usually as a PortableConfig. -// Materialize them onto your cache with Config::combine() — later layers win. +// Materialize them onto your cache with Config::combine(); later layers win. // The cache lives only on Config; the portable layers never carry one. $effective = (new Config($cache))->combine( $vendorPortable, // shared product defaults @@ -21,7 +21,7 @@ $effective = (new Config($cache))->combine( ); // Already holding Config instances? compose() / mergedWith() layer those directly -// (same precedence — later layers win): +// (same precedence; later layers win): $effective = $vendorConfig->mergedWith($environmentConfig, $tenantConfig); $effective = Config::compose($vendorConfig, $environmentConfig, $tenantConfig); ``` @@ -51,13 +51,13 @@ A `Config` does not track which options were *set* versus *left at their default ### 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.) +Because "default-valued" is read as "no opinion", an overlay **cannot turn a toggle back off** once an earlier layer turned it on. If the vendor baseline calls `enableResponseHeaders()` (changing the toggle from its `false` default to `true`), a tenant overlay that leaves the toggle at `false` will *not* switch it back off; its `false` is indistinguishable from "unspecified", so the baseline's explicit `true` wins. The same applies to `failOpen` and the other boolean toggles. (`enabled` is the deliberate exception: as its row above notes, it uses last-layer-wins, so a later layer *can* re-assert it.) If you need a later layer to *force* a non-default option back to the default, do not rely on composition: build the final `Config` and set the option explicitly after composing, e.g. `Config::compose(...)->setFailOpen(true)`. ### Limitation: composing the IP resolver does not rewrite IP-aware matchers -IP-aware matchers (`IpMatcher`, the file/snapshot IP blocklists, `TrustedBotMatcher`) capture their IP resolver **when the rule is constructed**. Because composition copies already-built rule objects, composing a layer with a different IP resolver only affects rules added *after* it — it does **not** retroactively change how earlier layers' IP rules resolve the client IP. Set the resolver on each source `Config` (via `setIpResolver()`) **before** adding its IP rules, rather than expecting a later layer to override it. +IP-aware matchers (`IpMatcher`, the file/snapshot IP blocklists, `TrustedBotMatcher`) capture their IP resolver **when the rule is constructed**. Because composition copies already-built rule objects, composing a layer with a different IP resolver only affects rules added *after* it; it does **not** retroactively change how earlier layers' IP rules resolve the client IP. Set the resolver on each source `Config` (via `setIpResolver()`) **before** adding its IP rules, rather than expecting a later layer to override it. This limitation does **not** apply to counter rules (throttle, fail2ban, allow2ban, track) added **without** an explicit `key`. Their default IP key is resolved per request against the `Config` they run under, so a composed `Config` correctly applies its own merged IP resolver to such rules no matter which layer defined them. @@ -85,6 +85,6 @@ See [`examples/30-config-composition.php`](https://github.com/flowd/phirewall/bl ## 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. +- [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 ee215ef..fe1966d 100644 --- a/docs/advanced/discriminator-normalizer.md +++ b/docs/advanced/discriminator-normalizer.md @@ -10,8 +10,8 @@ Phirewall provides two layers of key normalization to ensure consistent counting When a request is evaluated, the key goes through two normalization stages: -1. **Discriminator Normalizer** (optional, user-configured) -- transforms the raw key before it reaches the cache key generator. Use this for domain-specific normalization like case-insensitive matching. -2. **Cache Key Generator** (automatic) -- rule names are sanitized to safe characters; user-extracted keys are SHA-256 hashed for collision-free, fixed-length cache keys. +1. **Discriminator Normalizer** (optional, user-configured): transforms the raw key before it reaches the cache key generator. Use this for domain-specific normalization like case-insensitive matching. +2. **Cache Key Generator** (automatic): rule names are sanitized to safe characters; user-extracted keys are SHA-256 hashed for collision-free, fixed-length cache keys. ## The Bypass Problem @@ -60,11 +60,11 @@ 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 `_` -3. **Deduplicated** -- consecutive underscores collapsed to one -4. **Truncated** -- names longer than 120 characters are shortened with a SHA-1 suffix -5. **Empty-safe** -- empty strings are replaced with `empty` +1. **Trimmed** - leading and trailing whitespace removed +2. **Sanitized** - only `A-Za-z0-9._-` characters are kept; all others replaced with `_` +3. **Deduplicated** - consecutive underscores collapsed to one +4. **Truncated** - names longer than 120 characters are shortened with a SHA-1 suffix +5. **Empty-safe** - empty strings are replaced with `empty` Rule names are memoized internally for performance. @@ -77,10 +77,10 @@ User-extracted keys (IP addresses, usernames, API keys, etc.) are hashed with SH ``` This ensures: -- **Fixed length** -- regardless of input length, the cache key is always the same size -- **No special characters** -- hex output is always safe for any cache backend -- **Collision-free** -- SHA-256 has negligible collision probability -- **No memory leak** -- unlike memoized normalization, hashing is stateless and safe for long-running processes +- **Fixed length** - regardless of input length, the cache key is always the same size +- **No special characters** - hex output is always safe for any cache backend +- **Collision-free** - SHA-256 has negligible collision probability +- **No memory leak** - unlike memoized normalization, hashing is stateless and safe for long-running processes ### Examples @@ -171,15 +171,15 @@ Different cache backends have different key constraints: | File-based | OS path limit (~260 chars) | `/`, `\`, `NUL` | | Memcached | 250 bytes | Spaces, control characters | -SHA-256 hashing ensures user keys are always exactly 64 hex characters, safe across all backends. The Memcached and File-based rows are general PSR-16 examples — they are not bundled backends (Phirewall ships `InMemoryCache`, `ApcuCache`, `RedisCache`, and `PdoCache`). +SHA-256 hashing ensures user keys are always exactly 64 hex characters, safe across all backends. The Memcached and File-based rows are general PSR-16 examples; they are not bundled backends (Phirewall ships `InMemoryCache`, `ApcuCache`, `RedisCache`, and `PdoCache`). ### Security Without normalization, user-supplied values (like User-Agent strings, API keys, or email addresses) could: -- **Enable bypass attacks** -- padding, encoding, or case variations create distinct keys for the same identity -- **Exhaust cache memory** -- long user-agents or query strings create bloated keys -- **Leak sensitive data** -- raw keys may appear in cache monitoring tools; hashed keys are opaque +- **Enable bypass attacks** - padding, encoding, or case variations create distinct keys for the same identity +- **Exhaust cache memory** - long user-agents or query strings create bloated keys +- **Leak sensitive data** - raw keys may appear in cache monitoring tools; hashed keys are opaque ## Discriminator Normalizer vs. Key Extractor Normalization @@ -202,7 +202,7 @@ $config->setKeyPrefix('myapp'); // Keys become: myapp.throttle..., myapp.fail2ban..., etc. ``` -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. +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/dynamic-throttle.md b/docs/advanced/dynamic-throttle.md index 52c7e38..08f552c 100644 --- a/docs/advanced/dynamic-throttle.md +++ b/docs/advanced/dynamic-throttle.md @@ -130,7 +130,7 @@ $config->throttles->sliding('api-sliding', ); ``` -The method signature is identical to `add()` -- the only difference is the internal algorithm. Sliding windows also support dynamic `limit` and `period` closures. +The method signature is identical to `add()`; the only difference is the internal algorithm. Sliding windows also support dynamic `limit` and `period` closures. ### Fixed vs. Sliding Comparison @@ -142,7 +142,7 @@ The method signature is identical to `add()` -- the only difference is the inter | Best for | Simple rate limiting, internal APIs | Public APIs, strict limit enforcement | ::: tip -The sliding window algorithm is not atomic under high concurrency -- a small number of requests may slip through at the exact moment the threshold is crossed. This is acceptable for rate limiting, which is a fairness mechanism, not a security boundary. For hard security limits, use [Fail2Ban](/features/fail2ban) or [Allow2Ban](/features/fail2ban#allow2ban). +The sliding window algorithm is not atomic under high concurrency; a small number of requests may slip through at the exact moment the threshold is crossed. This is acceptable for rate limiting, which is a fairness mechanism, not a security boundary. For hard security limits, use [Fail2Ban](/features/fail2ban) or [Allow2Ban](/features/fail2ban#allow2ban). ::: ## Multi-Window Throttling @@ -258,8 +258,8 @@ $config->throttles->add('anonymous', ); ``` -::: tip -Your authentication middleware should set `X-User-Id` and `X-Plan` headers on the PSR-7 request before it reaches the Phirewall middleware. This keeps rate limiting configuration clean and decoupled from authentication logic. +::: warning Tier and identity headers must come from your auth layer +`X-User-Id` and `X-Plan` are read straight from the request. A client can send `X-Plan: enterprise` to self-grant the highest limit, or rotate `X-User-Id` to dodge a per-user limit. Set these headers in your authentication middleware **after** it has verified the principal and before the request reaches Phirewall, and strip or overwrite any inbound copy at the trusted edge. Looking the tier up from a verified identity, rather than trusting a request header, avoids this entirely. ::: ## Per-Endpoint Cost @@ -390,7 +390,7 @@ $firewall->resetAll(); ## Related Pages -- [Rate Limiting](/features/rate-limiting) -- basic throttle setup and rate limit headers -- [Observability](/advanced/observability) -- `ThrottleExceeded` events and metrics -- [Safelists & Blocklists](/features/safelists-blocklists) -- bypass all rules for trusted traffic -- [Track & Notifications](/advanced/track-notifications) -- passive counting for monitoring +- [Rate Limiting](/features/rate-limiting) - basic throttle setup and rate limit headers +- [Observability](/advanced/observability) - `ThrottleExceeded` events and metrics +- [Safelists & Blocklists](/features/safelists-blocklists) - bypass all rules for trusted traffic +- [Track & Notifications](/advanced/track-notifications) - passive counting for monitoring diff --git a/docs/advanced/infrastructure.md b/docs/advanced/infrastructure.md index 18fe1be..dddc566 100644 --- a/docs/advanced/infrastructure.md +++ b/docs/advanced/infrastructure.md @@ -8,7 +8,7 @@ Phirewall can mirror application-level blocks to web server infrastructure, prov ## Why Infrastructure-Level Blocking? -Application-level firewalls run inside PHP, which means every blocked request still consumes PHP-FPM resources. By mirroring bans to the web server layer (Apache, Nginx), subsequent requests from banned IPs are rejected before reaching PHP -- saving CPU, memory, and reducing attack surface. +Application-level firewalls run inside PHP, which means every blocked request still consumes PHP-FPM resources. By mirroring bans to the web server layer (Apache, Nginx), subsequent requests from banned IPs are rejected before reaching PHP, saving CPU, memory, and reducing attack surface. ```text Request --> Web Server (.htaccess) --> PHP-FPM --> Phirewall Middleware @@ -113,11 +113,11 @@ Options -Indexes ### Safety Features -- **Atomic writes** -- writes to a temporary file first, then renames it (POSIX atomic operation), preserving permissions -- **IP validation** -- all IPs are validated with `filter_var()` before writing; IPv6 addresses are normalized to canonical form to prevent duplicates -- **Content preservation** -- only the managed section between markers is modified; all other `.htaccess` content is untouched -- **Idempotent operations** -- blocking an already-blocked IP is a no-op; duplicates in batch operations are deduplicated -- **All-or-nothing semantics** -- in `blockMany()`/`unblockMany()`, all IPs are validated before any file modification; if one IP is invalid, the entire operation is rejected +- **Atomic writes** - writes to a temporary file first, then renames it (POSIX atomic operation), preserving permissions +- **IP validation** - all IPs are validated with `filter_var()` before writing; IPv6 addresses are normalized to canonical form to prevent duplicates +- **Content preservation** - only the managed section between markers is modified; all other `.htaccess` content is untouched +- **Idempotent operations** - blocking an already-blocked IP is a no-op; duplicates in batch operations are deduplicated +- **All-or-nothing semantics** - in `blockMany()`/`unblockMany()`, all IPs are validated before any file modification; if one IP is invalid, the entire operation is rejected ## Automatic Event Integration @@ -138,8 +138,8 @@ new InfrastructureBanListener( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `$infrastructureBlocker` | `InfrastructureBlockerInterface` | -- | The adapter to push blocks to | -| `$nonBlockingRunner` | `NonBlockingRunnerInterface` | -- | How to execute the adapter call | +| `$infrastructureBlocker` | `InfrastructureBlockerInterface` | - | The adapter to push blocks to | +| `$nonBlockingRunner` | `NonBlockingRunnerInterface` | - | How to execute the adapter call | | `$blockOnFail2Ban` | `bool` | `true` | Mirror Fail2Ban bans | | `$blockOnBlocklist` | `bool` | `false` | Mirror blocklist hits (request IP) | | `$keyToIp` | `?callable` | identity | Map a Fail2Ban key to an IP (default: assumes key is an IP) | diff --git a/docs/advanced/observability.md b/docs/advanced/observability.md index a4d458c..d4c9e62 100644 --- a/docs/advanced/observability.md +++ b/docs/advanced/observability.md @@ -19,7 +19,7 @@ $dispatcher = /* your PSR-14 event dispatcher */; $config = new Config(new InMemoryCache(), $dispatcher); ``` -If no dispatcher is provided, events are silently skipped with zero overhead. This means observability is entirely opt-in -- your firewall runs at full speed with no event overhead until you plug in a dispatcher. +If no dispatcher is provided, events are silently skipped with zero overhead. This means observability is entirely opt-in: your firewall runs at full speed with no event overhead until you plug in a dispatcher. ## PSR-14 Events @@ -112,7 +112,7 @@ $event->serverRequest; // ServerRequestInterface ### TrackHit -Track events fire on **every** matching request -- they never block. When a `limit` is configured on the track rule, the `thresholdReached` flag becomes `true` once the counter reaches the threshold. This makes track rules ideal for alerting without blocking. +Track events fire on **every** matching request; they never block. When a `limit` is configured on the track rule, the `thresholdReached` flag becomes `true` once the counter reaches the threshold. This makes track rules ideal for alerting without blocking. ```php use Flowd\Phirewall\Events\TrackHit; @@ -177,7 +177,7 @@ Since this event fires on every request, expensive operations in its handler dir ## Diagnostics Counters -Phirewall provides a built-in `DiagnosticsCounters` class that collects lightweight, in-memory counters for every decision category — perfect for health endpoints, dashboards, and quick debugging. +Phirewall provides a built-in `DiagnosticsCounters` class that collects lightweight, in-memory counters for every decision category, perfect for health endpoints, dashboards, and quick debugging. `DiagnosticsCounters` is an observer, not a dispatcher. To use it, wrap it with `DiagnosticsDispatcher`: @@ -702,7 +702,7 @@ public function dispatch(object $event): object ``` ::: tip -Always process blocking events (`BlocklistMatched`, `Fail2BanBanned`, `ThrottleExceeded`, `Allow2BanBanned`, `FirewallError`) at full volume -- these are low-frequency, high-signal events that you do not want to miss. +Always process blocking events (`BlocklistMatched`, `Fail2BanBanned`, `ThrottleExceeded`, `Allow2BanBanned`, `FirewallError`) at full volume; these are low-frequency, high-signal events that you do not want to miss. ::: ### Protect Sensitive Data @@ -715,12 +715,12 @@ $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. +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 -- [Request Context](/advanced/request-context) -- post-handler failure signaling for Fail2Ban -- [Rate Limiting](/features/rate-limiting) -- throttle rules and rate limit headers -- [Fail2Ban & Allow2Ban](/features/fail2ban) -- automatic banning +- [Track & Notifications](/advanced/track-notifications) - track rules, thresholds, and notification patterns +- [Request Context](/advanced/request-context) - post-handler failure signaling for Fail2Ban +- [Rate Limiting](/features/rate-limiting) - throttle rules and rate limit headers +- [Fail2Ban & Allow2Ban](/features/fail2ban) - automatic banning diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md index 5abcea5..a830c6f 100644 --- a/docs/advanced/portable-config.md +++ b/docs/advanced/portable-config.md @@ -11,7 +11,7 @@ outline: deep - **diff and review it in git**, or - **share one ruleset across many apps, processes, or languages** -…and then materialize a live [`Config`](/getting-started) from it with [`Config::combine()`](/advanced/config-composition) — the schema is pure data and never carries a cache. Closures are never serialized, so the surface is intentionally a safe, declarative subset (see [Not portable by design](#not-portable-by-design)). +…and then materialize a live [`Config`](/getting-started) from it with [`Config::combine()`](/advanced/config-composition); the schema is pure data and never carries a cache. Closures are never serialized, so the surface is intentionally a safe, declarative subset (see [Not portable by design](#not-portable-by-design)). ## Building and round-tripping @@ -49,7 +49,7 @@ $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). +`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 @@ -63,7 +63,7 @@ Everything `PortableConfig` can express today. | `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 | +| `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 | @@ -74,7 +74,7 @@ Everything `PortableConfig` can express today. | 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`) | +| `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) | @@ -83,9 +83,9 @@ Everything `PortableConfig` can express today. | `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(requiredHeaders = null)` | a required browser header is missing; `null` uses the default set — backed by `SuspiciousHeadersMatcher` | +| `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(requiredHeaders = 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. @@ -101,10 +101,10 @@ Everything `PortableConfig` can express today. | `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 | +| `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). +`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()`) @@ -122,7 +122,7 @@ Pattern backends carry a list of entries; each entry has a `PatternKind`: | `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). +`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 @@ -136,7 +136,7 @@ Pattern backends carry a list of entries; each entry has a `PatternKind`: ## 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: +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; @@ -149,7 +149,7 @@ $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 + return false; // already current; no rebuild } $portable = PortableConfig::loadSigned($row['blob'], $secret); @@ -164,7 +164,7 @@ When an operator publishes a new ruleset (and bumps the version), the next `$rel ## 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. +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: @@ -174,11 +174,11 @@ $restored = PortableConfig::loadSigned($signed, $secretKey); // verifies before ``` - 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. +- 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. +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. @@ -192,16 +192,16 @@ A few capabilities cannot be represented as pure data and are intentionally **ex | 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) | +| Closure-driven dynamic throttle limits/periods, `$config->throttles->multi()` | limits/periods can be arbitrary PHP closures and cannot be serialized (express the multi-window case as several `throttles->add()` entries; `sliding` is supported) | | Response factories, `ipResolver`, `discriminatorNormalizer` | these are closures / objects, not declarative data | ## 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. +- [`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 a `Config` needs. +- [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 a `Config` needs. diff --git a/docs/advanced/presets.md b/docs/advanced/presets.md index 9334c2d..d38fd13 100644 --- a/docs/advanced/presets.md +++ b/docs/advanced/presets.md @@ -4,7 +4,7 @@ outline: deep # Presets -Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a [`PortableConfig`](/advanced/portable-config) — plain, inspectable, serializable data you can diff, sign, or layer — returned by an accessor (e.g. `Presets::apiRateLimiting()`). +Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a [`PortableConfig`](/advanced/portable-config) returned by an accessor (e.g. `Presets::apiRateLimiting()`): plain, inspectable, serializable data you can diff, sign, or layer. Materialize one or several onto your own cache with [`Config::combine()`](/advanced/config-composition); presets are pure data and never receive a cache. Every rule is namespaced `preset..*`, so a later layer that redefines it by name overrides predictably. @@ -14,7 +14,7 @@ Materialize one or several onto your own cache with [`Config::combine()`](/advan use Flowd\Phirewall\Config; use Flowd\Phirewall\Preset\Presets; -// A preset on its own — combine it onto a Config you build with your cache: +// A preset on its own; combine it onto a Config you build with your cache: $config = (new Config($cache))->combine(Presets::apiRateLimiting()); // Inspect / serialize the underlying portable schema: @@ -29,7 +29,7 @@ $config = (new Config($cache))->combine( ); ``` -Preset rules emit the same [observability events](/advanced/observability) as hand-written ones — wire your PSR-14 dispatcher into the `Config` you combine onto (`new Config($cache, $dispatcher)`). +Preset rules emit the same [observability events](/advanced/observability) as hand-written ones; wire your PSR-14 dispatcher into the `Config` you combine onto (`new Config($cache, $dispatcher)`). ## Shipped presets @@ -38,18 +38,18 @@ Preset rules emit the same [observability events](/advanced/observability) as ha | `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`. | +| `sensitivePathBlocking()` | `preset.sensitive-path.probes`: pattern blocklist for `/.git`, `/.svn`, `/.hg`, `/.env*`, `/.aws/credentials`, `/.htpasswd`, `/.htaccess`, `/.DS_Store`. | Resolve any preset by name with `Presets::get($name)` (a `PortableConfig`), passing 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). +- 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 combining the preset with your own portable rules that redefine the rule by the same name (later layer wins), or by rebuilding the preset's schema. - 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. +> **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 @@ -74,6 +74,6 @@ See [`examples/31-presets.php`](https://github.com/flowd/phirewall/blob/main/exa ## 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()`. +- [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/psr17.md b/docs/advanced/psr17.md index 742f0aa..0d877b1 100644 --- a/docs/advanced/psr17.md +++ b/docs/advanced/psr17.md @@ -8,8 +8,8 @@ Phirewall generates HTTP responses (`403 Forbidden`, `429 Too Many Requests`) wh There are two layers of response customization: -1. **Base response factory** -- the `ResponseFactoryInterface` used by the `Middleware` to create bare responses (status code + headers). Auto-detected or injected explicitly. -2. **Custom response factories** -- optional `BlocklistedResponseFactoryInterface` and `ThrottledResponseFactoryInterface` on `Config` that produce complete responses with body text, content negotiation, etc. +1. **Base response factory** - the `ResponseFactoryInterface` used by the `Middleware` to create bare responses (status code + headers). Auto-detected or injected explicitly. +2. **Custom response factories** - optional `BlocklistedResponseFactoryInterface` and `ThrottledResponseFactoryInterface` on `Config` that produce complete responses with body text, content negotiation, etc. ## Auto-Detection @@ -328,7 +328,7 @@ $config->usePsr17Responses($psr17, $psr17); $config->blocklistedResponseFactory = new Psr17BlocklistedResponseFactory( $psr17, $psr17, - 'Access Denied -- your request has been blocked.', + 'Access Denied - your request has been blocked.', ); $config->throttledResponseFactory = new Psr17ThrottledResponseFactory( $psr17, @@ -474,7 +474,7 @@ $psr17 = new Psr17Factory(); $config->usePsr17Responses($psr17, $psr17); // ... configure rules ... -// Mezzio uses PSR-15 natively -- pipe first +// Mezzio uses PSR-15 natively - pipe first $app->pipe(new Middleware($config, $psr17)); ``` diff --git a/docs/advanced/request-context.md b/docs/advanced/request-context.md index d262f50..5926bee 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 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 `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 @@ -47,7 +47,7 @@ Here is what happens step by step: ## Setup -Configure a fail2ban rule with a filter that **always returns `false`**. This means the firewall never counts failures automatically -- your handler does it instead: +Configure a fail2ban rule with a filter that **always returns `false`**. This means the firewall never counts failures automatically; your handler does it instead: ```php use Flowd\Phirewall\Config; @@ -58,7 +58,7 @@ use Psr\Http\Message\ServerRequestInterface; $config = new Config(new InMemoryCache()); -// The filter returns false -- no request is counted automatically. +// The filter returns false; no request is counted automatically. // Failures are recorded programmatically via RequestContext in your handler. $config->fail2ban->add('login-failures', threshold: 3, @@ -71,12 +71,12 @@ $middleware = new Middleware($config); ``` ::: tip Why `filter: fn() => false`? -The filter still exists because the fail2ban rule requires one. Setting it to always return `false` means the pre-handler phase never counts any request as a failure -- all failure counting is deferred to your handler via `RequestContext`. +The filter still exists because the fail2ban rule requires one. Setting it to always return `false` means the pre-handler phase never counts any request as a failure; all failure counting is deferred to your handler via `RequestContext`. ::: ## Recording Failures in Your Handler -Retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional -- when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to know whether the rule keys on IP, header, or anything else: +Retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional: when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to know whether the rule keys on IP, header, or anything else: ```php use Flowd\Phirewall\Context\RequestContext; @@ -96,7 +96,7 @@ class LoginHandler implements RequestHandlerInterface /** @var RequestContext|null $context */ $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); - // Signal the failure -- the firewall derives the key from the + // Signal the failure; the firewall derives the key from the // rule's own keyExtractor. Use the null-safe operator for safety. $context?->recordFailure('login-failures'); @@ -120,9 +120,9 @@ The first parameter to `recordFailure()` must **exactly** match the `name` you u ## Recording allow2ban Hits -`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. +`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()`: +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; @@ -147,7 +147,7 @@ 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()`: +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. @@ -185,7 +185,7 @@ Both methods take the same parameters: | Parameter | Type | Description | |-----------|------|-------------| | `$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. | +| `$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 @@ -231,7 +231,7 @@ $context?->recordFailure('login-failures'); $context?->recordHit('expensive-endpoint'); ``` -If the middleware is not present, `$context` is `null` and the calls are silently skipped -- no errors, no side effects. This makes your handler safe to use with or without Phirewall. +If the middleware is not present, `$context` is `null` and the calls are silently skipped: no errors, no side effects. This makes your handler safe to use with or without Phirewall. ## Complete Example @@ -407,7 +407,7 @@ class RequestContextTest extends TestCase ## Related Pages -- [Fail2Ban & Allow2Ban](/features/fail2ban) -- fail2ban rule configuration and filter predicates -- [Track & Notifications](/advanced/track-notifications) -- passive counting without blocking -- [Observability](/advanced/observability) -- events and diagnostics -- [Getting Started](/getting-started) -- full setup walkthrough +- [Fail2Ban & Allow2Ban](/features/fail2ban) - fail2ban rule configuration and filter predicates +- [Track & Notifications](/advanced/track-notifications) - passive counting without blocking +- [Observability](/advanced/observability) - events and diagnostics +- [Getting Started](/getting-started) - full setup walkthrough diff --git a/docs/advanced/track-notifications.md b/docs/advanced/track-notifications.md index 5eb8976..223e2dc 100644 --- a/docs/advanced/track-notifications.md +++ b/docs/advanced/track-notifications.md @@ -4,11 +4,11 @@ outline: deep # Track & Notifications -Track rules provide **passive counting without blocking**. They are ideal for observability, alerting thresholds, and feeding data into dashboards -- all without affecting request processing. +Track rules provide **passive counting without blocking**. They are ideal for observability, alerting thresholds, and feeding data into dashboards, all without affecting request processing. ## How Tracking Works -Track rules are evaluated **first** in the pipeline, before safelists and blocklists. They always run -- even for requests that will be safelisted. This makes them reliable for comprehensive monitoring. +Track rules are evaluated **first** in the pipeline, before safelists and blocklists. They always run, even for requests that will be safelisted. This makes them reliable for comprehensive monitoring. ```text Request --> Track (passive) --> Safelist --> Blocklist --> Fail2Ban --> Throttle --> Allow2Ban --> Pass @@ -24,7 +24,7 @@ Here is what happens step by step: 3. If the filter returns `true`, the **key** closure extracts a grouping key (for example, the client IP) 4. The counter for that key is incremented in the cache, scoped to the rule's **period** (time window) 5. A `TrackHit` event is dispatched via the PSR-14 (PHP Standard Recommendation for Event Dispatching) event dispatcher -6. The request continues to the remaining pipeline stages -- track rules **never** block +6. The request continues to the remaining pipeline stages; track rules **never** block ## API Reference @@ -42,8 +42,8 @@ $config->tracks->add( |-----------|------|-------------| | `$name` | `string` | Unique rule identifier (must not be empty) | | `$period` | `int` | Time window for counting in seconds (must be >= 1) | -| `$filter` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to count this request | -| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` -- return the grouping key, or `null` to skip counting. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | +| `$filter` | `Closure` | `fn(ServerRequestInterface): bool` - return `true` to count this request | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` - return the grouping key, or `null` to skip counting. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | | `$limit` | `?int` | Optional threshold. When set, the `TrackHit` event includes a `thresholdReached` flag that becomes `true` once the counter reaches this value | ::: tip Return type @@ -406,7 +406,7 @@ Use track rules during an initial monitoring phase. Once you are confident in yo 3. **Set appropriate periods.** The period determines how long counters accumulate. Use shorter periods (60-300s) for real-time alerting and longer periods (3600s) for trend analysis. -4. **Use the `$limit` parameter for alert thresholds.** Instead of checking `$event->count >= N` in your event handler, configure `limit: N` on the track rule and check `$event->thresholdReached` -- it is more readable and keeps the threshold visible in your configuration. +4. **Use the `$limit` parameter for alert thresholds.** Instead of checking `$event->count >= N` in your event handler, configure `limit: N` on the track rule and check `$event->thresholdReached`, which is more readable and keeps the threshold visible in your configuration. 5. **Alert on specific counts, not ranges.** When sending notifications, compare `$event->count === $event->limit` (exact match) rather than `$event->count >= $event->limit` to avoid flooding your notification channel with duplicate alerts. @@ -416,7 +416,7 @@ Use track rules during an initial monitoring phase. Once you are confident in yo ## Related Pages -- [Observability](/advanced/observability) -- logging, OpenTelemetry, and monitoring integration -- [Fail2Ban & Allow2Ban](/features/fail2ban) -- automatic banning based on thresholds -- [Request Context](/advanced/request-context) -- post-handler failure signaling for Fail2Ban -- [Rate Limiting](/features/rate-limiting) -- throttling and rate limit headers +- [Observability](/advanced/observability) - logging, OpenTelemetry, and monitoring integration +- [Fail2Ban & Allow2Ban](/features/fail2ban) - automatic banning based on thresholds +- [Request Context](/advanced/request-context) - post-handler failure signaling for Fail2Ban +- [Rate Limiting](/features/rate-limiting) - throttling and rate limit headers diff --git a/docs/common-attacks.md b/docs/common-attacks.md index 9708c65..3a6f9d2 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -4,7 +4,7 @@ outline: deep # Common Attacks -Ready-to-use Phirewall configurations for defending against common web application attacks. Each recipe is self-contained -- copy what you need and adapt it to your application. +Ready-to-use Phirewall configurations for defending against common web application attacks. Each recipe is self-contained: copy what you need and adapt it to your application. ## Brute Force Login @@ -12,19 +12,18 @@ Protect login endpoints with layered rate limiting and fail2ban. ### Post-Handler Failure Signaling (recommended) -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). +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 pattern is demonstrated 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 3 verified failures in 5 minutes for 1 hour. -// The filter never matches — failures are signaled by the handler. +// 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, @@ -43,7 +42,7 @@ Only genuine failures are counted, so a user who logs in correctly on the first ### 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: +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; @@ -65,10 +64,10 @@ $config->fail2ban->add('login-brute-force', ); ``` -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. +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. ::: 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. +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 @@ -97,11 +96,15 @@ $config->throttles->add('account-throttle', limit: 5, period: 60, key: function (ServerRequestInterface $req): ?string { - if ($req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST') { - $username = $req->getHeaderLine('X-Username'); - return $username !== '' ? $username : null; + if ($req->getMethod() !== 'POST' || $req->getUri()->getPath() !== '/login') { + return null; } - return null; + // Key on the submitted credential read from the request body, not a + // client-settable header: an attacker could rotate or omit X-Username + // to dodge the per-account limit entirely. + $body = (array) $req->getParsedBody(); + $username = $body['username'] ?? $body['email'] ?? null; + return $username !== null ? 'user:' . strtolower(trim((string) $username)) : null; }, ); ``` @@ -347,6 +350,10 @@ $config->throttles->add('api', ); ``` +::: warning Tier and identity headers must come from your auth layer +`X-Plan` and `X-User-Id` are read straight from the request here. A client can send `X-Plan: enterprise` to self-grant the highest limit, or rotate `X-User-Id` to dodge a per-user limit. Set these headers in your authentication layer **after** it verifies the principal, and strip or overwrite any inbound copy at the trusted edge (see the **Header keys are client-controlled** warning later on this page). +::: + ### Write Operation Limits Apply stricter limits to mutating operations: @@ -389,7 +396,7 @@ $config->throttles->add('api', ``` ::: 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. +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 @@ -478,6 +485,9 @@ $config->blocklists->owasp('owasp', $rules); // ── Layer 4: Fail2Ban ───────────────────────────────────────────────── $config->fail2ban->add('login-brute-force', threshold: 5, period: 300, ban: 3600, + // X-Login-Failed must be set by trusted middleware and any inbound copy + // stripped at the edge (see Brute Force Login above); otherwise prefer the + // post-handler RequestContext::recordFailure() pattern. filter: fn($req): bool => $req->getHeaderLine('X-Login-Failed') === '1', ); @@ -511,7 +521,7 @@ Track → Safelist → Blocklist → Fail2Ban → Throttle → Allow2Ban → Pas | Layer | Purpose | Response | |-------|---------|----------| -| Track | Observe and count (never blocks) | -- | +| Track | Observe and count (never blocks) | - | | Safelist | Bypass all remaining checks | 200 (pass-through) | | Blocklist | IP lists, OWASP rules, patterns | 403 | | Fail2Ban | Ban after repeated filtered failures | 403 | @@ -525,7 +535,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 — raw `KeyExtractors::ip()` would collapse every client onto the proxy's address. See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies). +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 55a1fa2..bb1a1d3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -55,11 +55,11 @@ php examples/01-basic-setup.php ## Framework Integration -Production-ready integration examples for popular PHP frameworks. Each example includes storage, safelists, blocklists, rate limiting, brute-force protection, OWASP rules, and observability -- copy, paste, adapt. +Production-ready integration examples for popular PHP frameworks. Each example includes storage, safelists, blocklists, rate limiting, brute-force protection, OWASP rules, and observability. Copy, paste, adapt. ### PSR-15 (Generic / Plain PHP) -Works with any PSR-15 compatible stack (Mezzio, custom dispatchers, etc.). Requires `nyholm/psr7`. +Works with any PSR-15 compatible stack (custom dispatchers, runtimes, etc.; Mezzio has its own section below). Requires `nyholm/psr7`. ```php 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 — the bridge factories (`HttpMessageFactoryInterface`, `HttpFoundationFactoryInterface`) and the `nyholm/psr7` PSR-17 factory then autowire into the listener below. +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. + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` ::: 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. @@ -298,6 +302,11 @@ class PhirewallFactory **`config/services.yaml`** ```yaml +# Add these under the `services:` key in Symfony's default config/services.yaml. +# Keep the stock `_defaults: { autowire: true, autoconfigure: true }` and `App\:` +# resource block: `autoconfigure` is what turns the #[AsEventListener] below into a +# registered listener, and the `App\` loader registers the factory and listener as +# services. Drop them and the listener never runs, so Phirewall silently does nothing. services: App\Factory\PhirewallFactory: arguments: @@ -307,7 +316,14 @@ services: factory: ['@App\Factory\PhirewallFactory', 'create'] ``` -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. +Define `PHIREWALL_TRUSTED_PROXIES` in your environment even if empty: an undefined `%env()%` reference fails container compilation, whereas an empty value cleanly disables proxy resolution (the factory's `array_filter` drops it). + +```dotenv +# .env (empty disables Phirewall's proxy resolution) +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`** @@ -350,14 +366,26 @@ final class PhirewallListener 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()); + $probe = new class ($this->responseFactory) implements RequestHandlerInterface { + private bool $invoked = false; + public function __construct(private readonly ResponseFactoryInterface $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->invoked = true; + return $this->responseFactory->createResponse(200); } + public function wasInvoked(): bool { return $this->invoked; } + }; + $psrResponse = $this->middleware->process($psrRequest, $probe); + if (!$probe->wasInvoked()) { + // The handler never ran: Phirewall produced a block/throttle response. + $event->setResponse($this->httpFoundationFactory->createResponse($psrResponse)); return; } - $event->setResponse($this->httpFoundationFactory->createResponse($psrResponse)); + // Allowed: carry Phirewall's rate-limit headers onto the real response. + if ($psrResponse->getHeaders() !== []) { + $event->getRequest()->attributes->set(self::HEADERS_ATTRIBUTE, $psrResponse->getHeaders()); + } } #[AsEventListener(event: KernelEvents::RESPONSE)] @@ -373,16 +401,6 @@ final class PhirewallListener } } - 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); - } - }; - } } ``` @@ -390,7 +408,7 @@ final class PhirewallListener ### 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 adapts it. Install the bridge: +`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 @@ -446,8 +464,12 @@ class PhirewallServiceProvider extends ServiceProvider $config->setFailOpen(true); // ── Trusted Proxies ────────────────────────────────── - $trustedProxies = config('trustedproxy.proxies', []); - if (is_array($trustedProxies) && $trustedProxies !== []) { + // Phirewall resolves the client IP from its OWN trusted-proxy list, + // independent of Laravel's TrustProxies middleware. List your load + // balancer / CDN ranges here (e.g. via a TRUSTED_PROXIES env var). + // Leave empty only for a direct-to-PHP deployment. + $trustedProxies = array_filter(explode(',', (string) env('TRUSTED_PROXIES', ''))); + if ($trustedProxies !== []) { $proxyResolver = new TrustedProxyResolver($trustedProxies); $config->setIpResolver( KeyExtractors::clientIp($proxyResolver) @@ -512,6 +534,8 @@ class PhirewallServiceProvider extends ServiceProvider ); // ── PSR-17 Response Bodies ─────────────────────────── + // usePsr17Responses() sets the block/throttle response bodies; the + // constructor argument is only the fallback ResponseFactory. $psr17 = new Psr17Factory(); $config->usePsr17Responses($psr17, $psr17); @@ -523,7 +547,7 @@ class PhirewallServiceProvider extends ServiceProvider **`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. +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 fail2ban->add('login-failures', threshold: 3, period: 300, @@ -1137,7 +1169,7 @@ $config->fail2ban->add('login-failures', // $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); // if ($loginFailed) { // // The firewall derives the key from the rule's own keyExtractor -// // -- no need to repeat the IP/header/etc. extraction here. +// // No need to repeat the IP/header/etc. extraction here. // $context?->recordFailure('login-failures'); // } ``` @@ -1355,7 +1387,7 @@ $psr17Factory = new Psr17Factory(); $config->blocklistedResponseFactory = new Psr17BlocklistedResponseFactory( $psr17Factory, $psr17Factory, - 'Access Denied -- your request has been blocked.', + 'Access Denied. Your request has been blocked.', ); $config->throttledResponseFactory = new Psr17ThrottledResponseFactory( diff --git a/docs/faq.md b/docs/faq.md index 5378e64..1569c55 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -63,13 +63,13 @@ Phirewall is dual licensed under LGPL-3.0-or-later and a proprietary license. Se Phirewall evaluates rules in a strict, deterministic order. The first match wins: -1. **Track** -- passive counting, never blocks -2. **Safelist** -- if matched, bypass all other checks (returns 200) -3. **Blocklist** -- if matched, returns 403 Forbidden -4. **Fail2Ban** -- if already banned, 403; if filter matches, increment failure counter -5. **Throttle** -- if counter exceeds limit, returns 429 Too Many Requests -6. **Allow2Ban** -- if threshold exceeded, returns 403 -7. **Pass** -- request reaches your application +1. **Track**: passive counting, never blocks +2. **Safelist**: if matched, bypass all other checks (returns 200) +3. **Blocklist**: if matched, returns 403 Forbidden +4. **Fail2Ban**: if already banned, 403; if filter matches, increment the failure counter +5. **Throttle**: if counter exceeds limit, returns 429 Too Many Requests +6. **Allow2Ban**: if threshold exceeded, returns 403 +7. **Pass**: request reaches your application ### How do I handle trusted proxies? @@ -97,16 +97,16 @@ $config->throttles->add('api', limit: 100, period: 60, ); ``` -A few 0.5.0 specifics: +A few details worth knowing: - **`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). +- **All `X-Forwarded-For` / `Forwarded` instances are folded into one chain**, which the resolver walks right to left, returning the first hop not in your trusted-proxy list. The protection is this trusted-hop walk (and reading proxy headers only when the direct peer is itself trusted), not discarding duplicate lines. +- **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 -`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. +`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? @@ -185,36 +185,9 @@ $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); $context?->recordFailure('login-failures'); ``` -The second argument to `recordFailure()` is optional -- when omitted, the firewall extracts the discriminator key from the rule's own `keyExtractor`. The matching Fail2Ban rule should use `filter: fn($request): bool => false` so it only counts failures signaled programmatically. +The second argument to `recordFailure()` is optional; when omitted, the firewall extracts the discriminator key from the rule's own `keyExtractor`. The matching Fail2Ban rule should use `filter: fn($request): bool => false` so it only counts failures signaled programmatically. -For allow2ban rules, use `$context->recordHit('rule-name')` -- same shape, routed through the allow2ban evaluator instead. - -### The old fluent API methods are gone — what do I use now? - -The deprecated convenience methods (`$config->safelist()`, `$config->throttle()`, etc.) have been removed. Use the section API instead: - -```php -// Old (removed) -$config->safelist('health', fn($request) => ...); -$config->throttle('ip', 100, 60, fn($request) => ...); -$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, ban: 3600, filter: ..., key: ...); -``` - -See the [Getting Started](/getting-started) guide for the full section API reference. - -### What changed in 0.5.0 that affects an upgrade? - -A few behaviour changes can affect an existing deployment on upgrade: - -- **`isBanned()` now requires a `BanType`.** Both `Http\Firewall::isBanned()` and `BanManager::isBanned()` take a mandatory third argument `BanType $banType` (no default), because allow2ban and fail2ban bans live under distinct cache keys. Update any 2-argument call to pass `BanType::Fail2Ban` or `BanType::Allow2Ban`. -- **Cache-key separator changed from `:` to `.`.** Existing throttle/fail2ban counters and active bans are keyed with the old separator, so on the first deploy they are orphaned: counters reset and currently-banned clients are briefly un-banned. This is a one-time effect that self-heals as new keys are written; orphaned entries expire by TTL. Bump your `keyPrefix` or drain the cache on deploy if you want a clean cut. -- **`setKeyPrefix()` rejects reserved/control/whitespace characters.** A colon-namespaced prefix such as `app:prod` now throws `InvalidArgumentException`; use `app.prod` (see [Discriminator Normalizer](/advanced/discriminator-normalizer)). The cache backends likewise throw `InvalidCacheKeyException` on reserved, empty, or control keys. -- **`TrustedProxyResolver` defaults to a single header.** `allowedHeaders` now defaults to `['X-Forwarded-For']` only. If your upstream emits RFC 7239, pass `['Forwarded']` or `['Forwarded', 'X-Forwarded-For']` explicitly (see [How do I handle trusted proxies?](#how-do-i-handle-trusted-proxies)). +For allow2ban rules, use `$context->recordHit('rule-name')`, same shape, routed through the allow2ban evaluator instead. ## Rate Limiting @@ -222,9 +195,9 @@ A few behaviour changes can affect an existing deployment on upgrade: Phirewall supports three throttling strategies: -- **Fixed window** (`add()`) -- time is divided into fixed intervals. Simple and fast, but allows double bursts at period boundaries. -- **Sliding window** (`sliding()`) -- uses a weighted average of current and previous window to provide smooth rate enforcement. Prevents the "double burst" problem. -- **Multi-window** (`multi()`) -- registers multiple time windows in a single call. Useful for setting both burst limits (short window) and sustained limits (long window). +- **Fixed window** (`add()`): time is divided into fixed intervals. Simple and fast, but allows double bursts at period boundaries. +- **Sliding window** (`sliding()`): uses a weighted average of current and previous window to provide smooth rate enforcement. Prevents the "double burst" problem. +- **Multi-window** (`multi()`): registers multiple time windows in a single call. Useful for setting both burst limits (short window) and sustained limits (long window). See [Dynamic Throttle](/advanced/dynamic-throttle) for details. @@ -247,7 +220,7 @@ $config->throttles->multi('api', [ ### What happens when a throttle key returns `null`? -The rule is **skipped entirely** for that request -- as if the rule did not exist. This is the primary mechanism for conditional rate limits. For example, return `null` for admin users to exempt them from rate limiting. +The rule is **skipped entirely** for that request, as if the rule did not exist. This is the primary mechanism for conditional rate limits. For example, return `null` for admin users to exempt them from rate limiting. ### Are rate limit counters atomic? @@ -275,6 +248,8 @@ $config->throttles->add('api', ); ``` +Set `X-Plan` in your authentication layer after verifying the principal, and strip any inbound copy at the edge; otherwise a client can send `X-Plan: enterprise` to grant itself the top limit. + See [Dynamic Throttle: Per-User Tier Limits](/advanced/dynamic-throttle#per-user-tier-limits) for more patterns. ## Storage @@ -463,7 +438,7 @@ $firewall->resetThrottle('api', '192.168.1.100'); // Lift a specific fail2ban ban (also clears its fail counter) $firewall->resetFail2Ban('login-failures', '192.168.1.100'); -// Check whether a key is currently banned (BanType is required as of 0.5.0) +// Check whether a key is currently banned (BanType is required) $banned = $firewall->isBanned('login-failures', '192.168.1.100', BanType::Fail2Ban); // Reset all counters and bans diff --git a/docs/features/bot-detection.md b/docs/features/bot-detection.md index eae47ee..b331683 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 24 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 tools (26 substring patterns, since Burp Suite and Metasploit each have two spellings). ### Quick Setup @@ -116,15 +116,15 @@ $config->blocklists->suspiciousHeaders( | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Unique rule identifier (default: `'suspicious-headers'`) | -| `$requiredHeaders` | `?list` | Headers that must be present (case-sensitive header names). `null` uses the default set; a non-null list replaces the defaults. Passing `[]` requires nothing and never matches; do not use it for defaults. | +| `$requiredHeaders` | `?list` | Headers that must be present (matched case-insensitively per PSR-7). `null` uses the default set; a non-null list replaces the defaults. Passing `[]` requires nothing and never matches; do not use it for defaults. | ### Default Required Headers When no custom headers are specified, the following are required: -- `Accept` -- specifies acceptable response content types -- `Accept-Language` -- specifies acceptable languages -- `Accept-Encoding` -- specifies acceptable compression +- `Accept` - specifies acceptable response content types +- `Accept-Language` - specifies acceptable languages +- `Accept-Encoding` - specifies acceptable compression Every modern browser sends all three. Their absence strongly suggests an automated tool. @@ -156,7 +156,7 @@ Some legitimate clients may not send all standard headers: API clients, embedded ## Trusted Bot Verification (rDNS) -The `trustedBots()` method safelists verified search engine bots using **reverse DNS (rDNS) verification**. This prevents fake bots -- anyone can send `Googlebot` as a User-Agent, but only Google's real crawlers have IPs that resolve to `*.googlebot.com`. +The `trustedBots()` method safelists verified search engine bots using **reverse DNS (rDNS) verification**. This prevents fake bots: anyone can send `Googlebot` as a User-Agent, but only Google's real crawlers have IPs that resolve to `*.googlebot.com`. ### Quick Setup @@ -233,7 +233,7 @@ $config->safelists->trustedBots('custom-bots', [ ``` ::: danger -The hostname suffix **must** start with a dot (e.g., `.googlebot.com`, not `googlebot.com`). This prevents subdomain spoofing -- without the leading dot, an attacker controlling `evil-googlebot.com` could pass verification. +The hostname suffix **must** start with a dot (e.g., `.googlebot.com`, not `googlebot.com`). This prevents subdomain spoofing: without the leading dot, an attacker controlling `evil-googlebot.com` could pass verification. ::: ### Caching DNS Results @@ -386,6 +386,6 @@ ALLOW (pass to handler) 6. **Safelist your API clients.** If your application serves both browser and API traffic, safelist API paths or known client IPs before applying `suspiciousHeaders()`, since API clients typically don't send browser headers. -7. **Monitor false positives.** Use [events and logging](/advanced/observability) to track which rules are triggering and watch for false positives -- especially with `suspiciousHeaders()`, which may catch some legitimate clients. +7. **Monitor false positives.** Use [events and logging](/advanced/observability) to track which rules are triggering and watch for false positives, especially with `suspiciousHeaders()`, which may catch some legitimate clients. 8. **Combine with OWASP CRS.** For deep packet inspection beyond User-Agent matching, enable the [OWASP Core Rule Set](/features/owasp-crs) to detect SQL injection, XSS, and other attacks in request payloads. diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index 745ec6b..2966cdb 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -4,7 +4,7 @@ outline: deep # Fail2Ban & Allow2Ban -Fail2Ban and Allow2Ban are Phirewall's automatic banning mechanisms. They monitor request patterns and temporarily ban clients that exceed configurable thresholds -- the primary defense against brute force attacks, credential stuffing, and persistent scanners. +Fail2Ban and Allow2Ban are Phirewall's automatic banning mechanisms. They monitor request patterns and temporarily ban clients that exceed configurable thresholds, the primary defense against brute force attacks, credential stuffing, and persistent scanners. ## Fail2Ban @@ -36,7 +36,7 @@ Request --> Is key already banned? --> Yes --> 403 Forbidden 1. A **filter** closure checks each incoming request for a condition (e.g., a POST to `/login`) 2. Matches are counted per **key** (e.g., IP address) within a time **period** -3. When the count **reaches** the **threshold**, the key is **banned** for a configurable duration (e.g., `threshold: 5` bans on the 5th matching request — the same request that brings the counter to 5 is itself blocked) +3. When the count **reaches** the **threshold**, the key is **banned** for a configurable duration (e.g., `threshold: 5` bans on the 5th matching request; the same request that brings the counter to 5 is itself blocked) 4. Banned keys receive `403 Forbidden` immediately, without further rule evaluation ### Configuration @@ -58,8 +58,8 @@ $config->fail2ban->add( | `$threshold` | `int` | Number of filter matches that triggers the ban (must be >= 1). The Nth matching request is itself banned (matching rack-attack `maxretry` semantics). | | `$period` | `int` | Time window for counting matches in seconds (must be >= 1) | | `$ban` | `int` | Ban duration in seconds (must be >= 1) | -| `$filter` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to count as a match | -| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` -- return key to track, or `null` to skip. When the whole argument is omitted, defaults to the client IP from the Config's IP resolver (`Config::setIpResolver()`, typically `KeyExtractors::clientIp($proxy)`), falling back to `KeyExtractors::ip()` (REMOTE_ADDR). The resolver is read per request, so it can be set before or after the rule. | +| `$filter` | `Closure` | `fn(ServerRequestInterface): bool`, return `true` to count as a match | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string`, return key to track, or `null` to skip. When the whole argument is omitted, defaults to the client IP from the Config's IP resolver (`Config::setIpResolver()`, typically `KeyExtractors::clientIp($proxy)`), falling back to `KeyExtractors::ip()` (REMOTE_ADDR). The resolver is read per request, so it can be set before or after the rule. | ::: warning Fail2Ban filters evaluate the **incoming request** before the handler runs. The filter can only inspect request data (path, method, headers, query parameters). It cannot see the application's response. To ban based on application outcomes (like actual failed logins), use the [Request Context API](#post-handler-signaling-with-requestcontext) instead. @@ -150,8 +150,12 @@ $config->fail2ban->add('api-abuse', ban: 900, // 15 minute ban filter: fn($req) => $req->getHeaderLine('X-Signature-Invalid') === '1', key: function ($req): ?string { - return $req->getHeaderLine('X-API-Key') - ?: $req->getServerParams()['REMOTE_ADDR']; + // Hash the key so the raw secret never reaches the counter, ban + // registry, or event payloads; fall back to the client IP when absent. + $apiKey = $req->getHeaderLine('X-API-Key'); + return $apiKey !== '' + ? 'key:' . hash('sha256', $apiKey) + : ($req->getServerParams()['REMOTE_ADDR'] ?? null); } ); ``` @@ -172,12 +176,12 @@ $config->fail2ban->add('persistent-scanner', ``` ::: warning -The filter `fn($req) => true` counts every request that reaches the Fail2Ban layer. Because safelisted and blocklisted requests never reach Fail2Ban, this effectively counts requests that passed safelists and blocklists but are still suspicious. Use with care -- this is a broad filter. +The filter `fn($req) => true` counts every request that reaches the Fail2Ban layer. Because safelisted and blocklisted requests never reach Fail2Ban, this effectively counts requests that passed safelists and blocklists but are still suspicious. Use with care: this is a broad filter. ::: ## Post-Handler Signaling with RequestContext {#post-handler-signaling-with-requestcontext} -Standard Fail2Ban filters run **before** your application handler, so they can only inspect the incoming request. The **RequestContext API** solves this by letting your handler signal failures **after** it has processed the request -- for example, after verifying credentials against a database. +Standard Fail2Ban filters run **before** your application handler, so they can only inspect the incoming request. The **RequestContext API** solves this by letting your handler signal failures **after** it has processed the request, for example after verifying credentials against a database. ### How It Works @@ -208,7 +212,7 @@ Response ### Setup -Configure a fail2ban rule with a filter that always returns `false`. The filter will never match pre-handler -- all counting happens via `recordFailure()`: +Configure a fail2ban rule with a filter that always returns `false`. The filter will never match pre-handler; all counting happens via `recordFailure()`: ```php use Flowd\Phirewall\Config; @@ -229,7 +233,7 @@ $config->fail2ban->add( ### Recording Failures in Your Handler -Inside your request handler, retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional -- when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to repeat the IP/header/etc. extraction: +Inside your request handler, retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional; when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to repeat the IP/header/etc. extraction: ```php use Flowd\Phirewall\Context\RequestContext; @@ -242,7 +246,7 @@ class LoginController $password = $request->getParsedBody()['password'] ?? ''; if (!$this->auth->verify($username, $password)) { - // Signal the failure -- the firewall extracts the key from + // Signal the failure; the firewall extracts the key from // the rule's own keyExtractor against this request. $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); $context?->recordFailure('login-failures'); @@ -263,14 +267,14 @@ $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 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->recordFailure(string $ruleName, ?string $key = null)` | Record a fail2ban failure signal. `$ruleName` must match a configured fail2ban rule name. `$key` is **optional**; when omitted, the rule's own key extractor resolves the discriminator from the current request, so the handler does not need 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-allow2ban-hits). | | `$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 (renamed from `getRecordedFailures()` / `RecordedFailure` in 0.5.0) | +| `$context->getRecordedSignals()` | Returns all recorded `RecordedSignal` objects | ::: 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. +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. ::: ### Why Use RequestContext? @@ -285,7 +289,7 @@ RequestContext is the most accurate approach because it only increments the fail ## Allow2Ban {#allow2ban} -Allow2Ban is a **dedicated section** (`$config->allow2ban`) with its own API. It is the inverse of Fail2Ban: instead of counting only filtered "bad" requests, it counts **every request** for a given key and bans once the count reaches the threshold. Think of it as "n requests allowed, then you're out" — with `threshold: n`, the nth request itself is the one that triggers and is blocked. +Allow2Ban is a **dedicated section** (`$config->allow2ban`) with its own API. It is the inverse of Fail2Ban: instead of counting only filtered "bad" requests, it counts **every request** for a given key and bans once the count reaches the threshold. Think of it as "n requests allowed, then you're out": with `threshold: n`, the nth request itself is the one that triggers and is blocked. ### How It Works @@ -306,7 +310,7 @@ Request --> Is key already banned? --> Yes --> 403 Forbidden BAN key for configured duration --> 403 Forbidden ``` -There is no filter -- every request matching the key extractor is counted. +There is no filter; every request matching the key extractor is counted. ### Configuration @@ -326,7 +330,7 @@ $config->allow2ban->add( | `$threshold` | `int` | Number of requests that triggers the ban (must be >= 1). The Nth request is itself banned (matching rack-attack `maxretry` semantics). | | `$period` | `int` | Time window for counting requests in seconds (must be >= 1) | | `$banSeconds` | `int` | Ban duration in seconds (must be >= 1) | -| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` -- return key to track, or `null` to skip. When omitted, defaults to the client IP from the Config's IP resolver (see Fail2Ban's `$key` above). | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string`, return key to track, or `null` to skip. When omitted, defaults to the client IP from the Config's IP resolver (see Fail2Ban's `$key` above). | ::: tip Note the parameter name difference: Fail2Ban uses `$ban`, Allow2Ban uses `$banSeconds`. Both accept duration in seconds. @@ -350,7 +354,7 @@ $config->allow2ban->add( ### API Key Abuse Protection -Ban API keys that exceed expected usage. Unlike rate limiting (which returns 429 and lets the client retry), Allow2Ban **bans** the key entirely -- a stronger response for abuse: +Ban API keys that exceed expected usage. Unlike rate limiting (which returns 429 and lets the client retry), Allow2Ban **bans** the key entirely, a stronger response for abuse: ```php // Ban any client IP that makes more than 1000 requests in 60 seconds. @@ -363,7 +367,7 @@ $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. +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 @@ -395,7 +399,7 @@ $config->allow2ban->add( | Aspect | Fail2Ban | Allow2Ban | |--------|----------|-----------| | **Section** | `$config->fail2ban` | `$config->allow2ban` | -| **Filter** | Required -- only matching requests are counted | No filter -- all requests for the key are counted | +| **Filter** | Required: only matching requests are counted | No filter: all requests for the key are counted | | **Trigger** | Repeated "bad" requests matching the filter | Exceeding a total request volume | | **Use case** | Brute force, credential stuffing, scanner blocking | Volume abuse, DDoS mitigation, API abuse | | **Event** | `Fail2BanBanned` | `Allow2BanBanned` | diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index c49c072..4e9862a 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -148,7 +148,7 @@ Phirewall supports a subset of the ModSecurity SecRule language: | `id:N` | Rule ID (required, must be unique) | | `phase:N` | Processing phase (currently informational) | | `deny` | Block the request (required for the rule to trigger blocking) | -| `block` | Alias for `deny` -- both trigger blocking | +| `block` | Alias for `deny`, both trigger blocking | | `msg:'text'` | Human-readable description for logging | ### Line Continuation @@ -358,7 +358,7 @@ insert into ``` ::: warning -`@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. +`@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 @@ -412,7 +412,7 @@ Each CRS operator maps to an `OperatorEvaluatorInterface` implementation: Unsupported operators resolve to `UnsupportedOperatorEvaluator`, which never matches (safe no-op). ::: warning ReDoS protection: 8 KiB length guard on `@rx` -`RegexEvaluator` skips any value whose byte length exceeds 8,192 bytes — the value is treated as non-matching. This is an intentional trade-off: running PCRE on unbounded attacker-controlled input risks catastrophic backtracking that can freeze the PHP process (ReDoS). Skipping overlength values mirrors the behavior of standard WAFs such as ModSecurity's `SecRequestBodyLimit`. +`RegexEvaluator` skips any value whose byte length exceeds 8,192 bytes; the value is treated as non-matching. This is an intentional trade-off: running PCRE on unbounded attacker-controlled input risks catastrophic backtracking that can freeze the PHP process (ReDoS). Skipping overlength values mirrors the behavior of standard WAFs such as ModSecurity's `SecRequestBodyLimit`. In practice, legitimate request values (query parameters, header values, cookie values) are rarely larger than a few kilobytes. If you are matching multi-megabyte request bodies via `@rx`, consider pre-processing them before passing to the firewall. ::: diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index 04460b6..80aabe8 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -40,7 +40,7 @@ $config->throttles->add( | `$name` | `string` | Unique rule identifier | | `$limit` | `int\|Closure` | Max requests per window, or a [dynamic closure](#dynamic-limits) | | `$period` | `int\|Closure` | Window size in seconds, or a [dynamic closure](#dynamic-limits) | -| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` -- return a key to group by, or `null` to skip. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string`, return a key to group by, or `null` to skip. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | ```php use Flowd\Phirewall\KeyExtractors; @@ -49,7 +49,7 @@ use Flowd\Phirewall\KeyExtractors; $config->throttles->add('ip-limit', limit: 100, period: 60); ``` -When the key closure returns `null`, the rule is skipped for that request. This lets you apply throttles conditionally -- only to certain paths, methods, or user types. +When the key closure returns `null`, the rule is skipped for that request. This lets you apply throttles conditionally, only to certain paths, methods, or user types. ```text Window 1 (00:00-00:59) Window 2 (01:00-01:59) Window 3 (02:00-02:59) @@ -126,8 +126,8 @@ Each entry creates a sub-rule named `{$name}:{$period}s`. Windows are evaluated ```php // 3 req/s burst + 60 req/min sustained $config->throttles->multi('api', [ - 1 => 3, // "api:1s" -- burst protection - 60 => 60, // "api:60s" -- sustained throughput + 1 => 3, // "api:1s" - burst protection + 60 => 60, // "api:60s" - sustained throughput ]); ``` @@ -171,7 +171,7 @@ $config->throttles->add( ```php use Psr\Http\Message\ServerRequestInterface; -// Single rule handles all plans -- no need for separate rules per tier +// Single rule handles all plans; no need for separate rules per tier $config->throttles->add( 'api', fn(ServerRequestInterface $req): int => match ($req->getHeaderLine('X-Plan')) { @@ -258,7 +258,7 @@ $config->throttles->add('per-endpoint', limit: 50, period: 60, ## Tiered Rate Limits -Define multiple throttle rules with different limits for different use cases. All rules are evaluated independently -- a request must satisfy all of them. +Define multiple throttle rules with different limits for different use cases. All rules are evaluated independently; a request must satisfy all of them. ```php use Flowd\Phirewall\Http\TrustedProxyResolver; @@ -304,7 +304,7 @@ $config->throttles->add('search-endpoint', Enforce rate limits at the firewall on the client IP, which a caller cannot forge (behind a proxy, resolve it with `KeyExtractors::clientIp()` and a `TrustedProxyResolver`). Do not key a limit on a client-supplied header such as `X-User-Id` or `X-Api-Key`: a caller can rotate or drop it to land in a fresh counter on every request and never reach the limit. For genuine per-authenticated-user limits, enforce them behind your application's auth layer, where the user identity has been verified, rather than on a raw request header at the edge. ::: 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. +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 @@ -391,10 +391,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. +The resolver's `allowedHeaders` argument defaults to `['X-Forwarded-For']` (a single header); pass `['Forwarded']` explicitly if your stack emits the RFC 7239 header. All forwarded-header instances are folded into one chain and walked right to left, returning the first hop not in your trusted-proxy list (so the trusted-proxy ranges, not the number of header lines, are what prevent spoofing), 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 behavior. ::: danger -`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. +`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 e336161..3ee9c3d 100644 --- a/docs/features/safelists-blocklists.md +++ b/docs/features/safelists-blocklists.md @@ -31,7 +31,7 @@ $config->safelists->add(string $name, Closure $callback): SafelistSection | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Unique rule identifier | -| `$callback` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to safelist | +| `$callback` | `Closure` | `fn(ServerRequestInterface): bool` - return `true` to safelist | ```php // Health check endpoint @@ -107,7 +107,7 @@ $config->safelists->trustedBots( ``` ```php -// Safelist Google, Bing, Baidu, DuckDuckGo, Yandex, and Apple bots +// Safelist Google, Bing, Yahoo, Baidu, DuckDuckGo, Yandex, and Apple bots $config->safelists->trustedBots(); // Add custom bots on top of the built-in list @@ -151,7 +151,7 @@ $config->blocklists->add(string $name, Closure $callback): BlocklistSection | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Unique rule identifier | -| `$callback` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to block | +| `$callback` | `Closure` | `fn(ServerRequestInterface): bool` - return `true` to block | ```php // Block admin panel probes @@ -222,10 +222,10 @@ $config->blocklists->knownScanners( | `$name` | `string` | Rule identifier (default: `'known-scanners'`) | | `$patterns` | `?list` | UA substrings to block. `null` uses the built-in list | -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. +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 (26 substring patterns in total; Burp Suite and Metasploit are each matched under two spellings). ```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 @@ -317,7 +317,7 @@ The file format is one entry per line, with optional expiry and timestamp fields ``` ::: 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. +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 @@ -339,7 +339,7 @@ See [OWASP CRS](/features/owasp-crs) for full details on loading rules from file ## Pattern Backends -For dynamic, data-driven blocklists, use pattern backends instead of hardcoded closures. Pattern backends support IP addresses, CIDR ranges, path patterns, header patterns, and regex matching -- all with optional expiration. +For dynamic, data-driven blocklists, use pattern backends instead of hardcoded closures. Pattern backends support IP addresses, CIDR ranges, path patterns, header patterns, and regex matching, all with optional expiration. ### In-Memory Pattern Blocklist @@ -382,7 +382,7 @@ $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. +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 @@ -473,7 +473,7 @@ $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). +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} @@ -505,11 +505,11 @@ $config->safelists->ip('cloudflare-office', '203.0.113.10', ipResolver: $customR `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. +- 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). +`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 @@ -519,11 +519,11 @@ The complete evaluation order within Phirewall is: | Order | Layer | Action on Match | |-------|-------|-----------------| | 1 | Track | Count (passive, never blocks) | -| 2 | **Safelist** | **Allow** -- bypass all remaining checks | -| 3 | **Blocklist** | **Block** -- 403 Forbidden | -| 4 | Fail2Ban | Block -- 403 Forbidden | -| 5 | Throttle | Block -- 429 Too Many Requests | -| 6 | Allow2Ban | Block -- 403 Forbidden | +| 2 | **Safelist** | **Allow** - bypass all remaining checks | +| 3 | **Blocklist** | **Block** - 403 Forbidden | +| 4 | Fail2Ban | Block - 403 Forbidden | +| 5 | Throttle | Block - 429 Too Many Requests | +| 6 | Allow2Ban | Block - 403 Forbidden | | 7 | Pass | Request reaches your application | ::: warning diff --git a/docs/features/storage.md b/docs/features/storage.md index 60a6a9a..c19826d 100644 --- a/docs/features/storage.md +++ b/docs/features/storage.md @@ -218,9 +218,7 @@ $redis = new PredisClient([ ### Fail-Open Behavior -RedisCache is designed to fail open. If Redis is unavailable, `increment()` returns `0`, which means no throttle or Fail2Ban rule will trigger. This prevents Redis outages from blocking all traffic to your application. - -When `increment()` catches a Redis error, it emits an `E_USER_WARNING` via `trigger_error()` before returning `0`. This gives you visibility into cache failures without breaking the request flow. You can capture these warnings in your application's error handler or logging setup: +On a Redis error, `increment()` emits a diagnostic `E_USER_WARNING` via `trigger_error()` and then re-throws the underlying exception. Fail-open is applied one layer up: the middleware catches the error and, because `failOpen` is on by default (toggle it with `Config::setFailOpen()`), lets the request through and dispatches a `FirewallError` event. So a Redis outage does not block traffic under the default policy; with `setFailOpen(false)` the same outage fails closed and the exception surfaces as a 500. The warning message begins with `RedisCache::increment()`, so you can capture it in your application's error handler or logging setup: ```php set_error_handler(function (int $errno, string $message): bool { @@ -339,7 +337,7 @@ CREATE INDEX idx_phirewall_expires ON phirewall_cache (expires_at); - Prepared statement caching for optimal performance - Full persistence across application and server restarts - Implements both `CacheInterface` and `CounterStoreInterface` -- No additional dependencies -- uses PHP's built-in PDO extension +- No additional dependencies - uses PHP's built-in PDO extension ### When to Use @@ -440,22 +438,22 @@ 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. +::: tip Key segments join with `.` +`CacheKeyGenerator` (and the trusted-bot rDNS cache) join key segments with `.` rather than `:`. PSR-16 reserves `:` for the *cache implementation*, not its callers, so joining with `.` keeps Phirewall's own keys spec-compliant. `RedisCache`'s own namespace prefix (default `Phirewall:`) is the backend's keyspace, applied *after* the public key, and is unaffected. ::: 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. +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 **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. +- for the multi-key methods (`getMultiple` / `setMultiple` / `deleteMultiple`), a **non-string key**. ```php use Flowd\Phirewall\Store\InMemoryCache; @@ -473,20 +471,20 @@ try { 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. +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.` — 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')`. +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 redis-cli monitor | grep Phirewall -# Count Phirewall keys (use SCAN in production -- see warning below) +# Count Phirewall keys (use SCAN in production, see warning below) redis-cli keys "Phirewall:*" | wc -l # Check memory usage @@ -496,7 +494,7 @@ redis-cli info memory redis-cli --scan --pattern "Phirewall:phirewall.throttle.ip-limit.*" ``` -You cannot look up a specific client by plaintext IP — the discriminator segment is hashed. +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: diff --git a/docs/getting-started.md b/docs/getting-started.md index c80f2da..7bcd664 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,7 +37,7 @@ composer require monolog/monolog ## Step 1: Choose a Storage Backend -Phirewall needs a PSR-16 (PHP Standard Recommendation for Simple Caching) cache for storing counters and ban states. Pick the backend that fits your deployment. +Phirewall needs a PSR-16 (PHP Standard Recommendation for Simple Caching) cache for storing counters and ban states. Pick the backend that fits your deployment. If you are just trying Phirewall locally, start with `InMemoryCache`; choose a shared backend (Redis or PDO) before production. ::: code-group @@ -100,11 +100,7 @@ The `Config` constructor accepts: ## Step 3: Define Rules -All imports needed for the examples below: - -```php -use Flowd\Phirewall\KeyExtractors; -``` +Every safelist, blocklist, throttle, fail2ban, and track callback receives the incoming PSR-7 `ServerRequestInterface`, so you can branch on the path, method, headers, and so on. The snippets below use the `$config` from Step 2; the `$req` parameter is that request. ### Safelists (Allow Trusted Traffic) @@ -117,7 +113,8 @@ $config->safelists->add('metrics', fn($req) => $req->getUri()->getPath() === '/m // Safelist specific IPs or CIDR ranges $config->safelists->ip('office', ['10.0.0.0/8', '192.168.1.0/24']); -// Safelist verified search engine bots (Googlebot, Bingbot, etc.) +// Safelist verified search engine bots (Googlebot, Bingbot, etc.). +// Verified via reverse DNS; pass a cache to skip repeat lookups (see Bot Detection). $config->safelists->trustedBots(); ``` @@ -144,7 +141,7 @@ $config->blocklists->suspiciousHeaders(); ### Throttling (Rate Limiting) -Throttled requests receive `429 Too Many Requests` with a `Retry-After` header. +Throttled requests receive `429 Too Many Requests` with a `Retry-After` header. Counting rules (throttle, fail2ban, allow2ban, track) count requests against a *key*, an identity that defaults to the client IP (`KeyExtractors::ip()`, which reads `REMOTE_ADDR`). Pass a `key:` with a `KeyExtractors::*` callable to count against something else; behind a proxy, set the real client IP with a resolver (see [Client IP Behind Proxies](#client-ip-behind-proxies)). ```php // 100 requests per minute per IP @@ -173,7 +170,7 @@ See [Rate Limiting](/features/rate-limiting) and [Dynamic Throttle](/advanced/dy ### Fail2Ban (Brute Force Protection) -Automatically ban clients after repeated failures. The filter evaluates each incoming request; matching requests increment a failure counter, and the client is banned as soon as the count reaches the threshold (e.g., `threshold: 5` bans on the 5th matching request — that request is itself blocked). +Automatically ban clients after repeated failures. The filter evaluates each incoming request; matching requests increment a failure counter, and the client is banned as soon as the count reaches the threshold (e.g., `threshold: 5` bans on the 5th matching request; that request is itself blocked). ```php // Ban IPs that POST to /login more than 5 times in 5 minutes @@ -252,12 +249,47 @@ See [PSR-17 Factories](/advanced/psr17) for custom response configuration. ## Step 5: Add to Your Application +::: warning +The framework tabs below use `ApcuCache`, which needs the `ext-apcu` extension and throws without it. On a machine without APCu, swap in `new InMemoryCache()` to try it out, or `RedisCache` / `PdoCache` for shared production storage (see [Step 1](#step-1-choose-a-storage-backend)). +::: + ::: code-group -```php [PSR-15] -// Any PSR-15 compatible stack (Mezzio, custom dispatchers, etc.) -// The middleware from Step 4 plugs directly into your pipeline. -$app->pipe($middleware); +```php [PSR-15 / Plain PHP] +// In a PSR-15 pipeline (Mezzio, custom dispatchers): $app->pipe($middleware); +// +// With no framework, a minimal public/index.php front controller. Serve with: +// php -S localhost:8080 public/index.php +// Requires: composer require nyholm/psr7 nyholm/psr7-server +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +// $middleware is the Phirewall middleware from Step 4. +$psr17 = new Psr17Factory(); +$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals(); + +// Your application handler; it runs only if Phirewall lets the request through. +$appHandler = new class ($psr17) implements RequestHandlerInterface { + public function __construct(private Psr17Factory $psr17) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->psr17->createResponse(200); + } +}; + +$response = $middleware->process($request, $appHandler); + +// Emit the PSR-7 response. +http_response_code($response->getStatusCode()); +foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header("$name: $value", false); + } +} +echo $response->getBody(); ``` ```php [Symfony] @@ -371,14 +403,26 @@ final class PhirewallListener 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()); + $probe = new class ($this->responseFactory) implements RequestHandlerInterface { + private bool $invoked = false; + public function __construct(private readonly ResponseFactoryInterface $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->invoked = true; + return $this->responseFactory->createResponse(200); } + public function wasInvoked(): bool { return $this->invoked; } + }; + $psrResponse = $this->middleware->process($psrRequest, $probe); + if (!$probe->wasInvoked()) { + // The handler never ran: Phirewall produced a block/throttle response. + $event->setResponse($this->httpFoundationFactory->createResponse($psrResponse)); return; } - $event->setResponse($this->httpFoundationFactory->createResponse($psrResponse)); + // Allowed: carry Phirewall's rate-limit headers onto the real response. + if ($psrResponse->getHeaders() !== []) { + $event->getRequest()->attributes->set(self::HEADERS_ATTRIBUTE, $psrResponse->getHeaders()); + } } #[AsEventListener(event: KernelEvents::RESPONSE)] @@ -394,22 +438,12 @@ final class PhirewallListener } } - 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); - } - }; - } } ``` ```php [Laravel] // Flowd\Phirewall\Middleware is a PSR-15 middleware (process(...)), NOT a -// Laravel middleware (handle($request, $next)) -- registering the class +// 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 // @@ -485,7 +519,7 @@ class PhirewallServiceProvider extends ServiceProvider // 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. +// 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 @@ -608,7 +642,7 @@ $app->run(); ``` ```php [Mezzio] -// Mezzio uses PSR-15 natively -- pipe Phirewall first. +// Mezzio uses PSR-15 natively. Pipe Phirewall right after the ErrorHandler. // In config/autoload/phirewall.global.php: // return [ @@ -677,15 +711,16 @@ class PhirewallMiddlewareFactory } } -// In config/pipeline.php: -// $app->pipe(\Flowd\Phirewall\Middleware::class); // outermost +// In config/pipeline.php (pipe the ErrorHandler first, then Phirewall): +// $app->pipe(\Laminas\Stratigility\Middleware\ErrorHandler::class); +// $app->pipe(\Flowd\Phirewall\Middleware::class); // $app->pipe(\Mezzio\Router\Middleware\RouteMiddleware::class); // $app->pipe(\Mezzio\Router\Middleware\DispatchMiddleware::class); ``` ::: -> **Middleware ordering:** Ensure Phirewall runs as the outermost middleware so it executes before your application handles the request. See the [Examples](/examples#framework-integration) page for more detailed, production-ready integrations. +> **Middleware ordering:** Pipe Phirewall as early as possible, but after your error-handling middleware. Phirewall does not wrap the downstream handler, so a handler exception must be able to reach the error handler. See the [Examples](/examples#framework-integration) page for more detailed, production-ready integrations. ## Complete Example @@ -699,7 +734,6 @@ declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use Flowd\Phirewall\Config; -use Flowd\Phirewall\KeyExtractors; use Flowd\Phirewall\Middleware; use Flowd\Phirewall\Store\InMemoryCache; use Nyholm\Psr7\Factory\Psr17Factory; @@ -770,12 +804,12 @@ Request --> Track (passive) --> Safelist --> Blocklist --> Fail2Ban --> Throttle The evaluation order is: 1. **Track** rules are always evaluated first (passive counting, never blocks) -2. **Safelist** -- if matched, the request bypasses all remaining checks -3. **Blocklist** -- if matched, the request is rejected with `403` -4. **Fail2Ban** -- if the client is already banned, `403`; if the filter matches, increment failure counter -5. **Throttle** -- if the counter exceeds the limit, `429` with `Retry-After` -6. **Allow2Ban** -- if the client has exceeded the request threshold, `403` with `Retry-After` -7. **Pass** -- the request reaches your application +2. **Safelist**: if matched, the request bypasses all remaining checks +3. **Blocklist**: if matched, the request is rejected with `403` +4. **Fail2Ban**: if the client is already banned, `403`; if the filter matches, increment the failure counter +5. **Throttle**: if the counter exceeds the limit, `429` with `Retry-After` +6. **Allow2Ban**: if the client has exceeded the request threshold, `403` with `Retry-After` +7. **Pass**: the request reaches your application ## Fail-Open / Fail-Closed @@ -840,11 +874,11 @@ $config->throttles->add('api', limit: 100, period: 60, $config->setIpResolver(KeyExtractors::clientIp($resolver)); ``` -::: 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. +::: 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) +### Resolver behavior `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: @@ -857,13 +891,14 @@ new TrustedProxyResolver( ); ``` -- **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. +- **The default header is a single header.** `allowedHeaders` 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. +- **Proxy headers are read only when the direct peer is trusted.** The resolver consults `X-Forwarded-For` (or `Forwarded`) only when `REMOTE_ADDR`, the address that actually connected, is itself in your trusted-proxy list. A request arriving directly from an untrusted client has its forwarded headers ignored and is keyed on `REMOTE_ADDR`. +- **All header instances are folded into one chain.** Whether intermediaries keep `X-Forwarded-For` as separate lines or fold them into one comma-separated value (the nginx default), the resolver flattens them and walks the chain right to left, returning the first hop that is not in your trusted-proxy list. The protection is this trusted-hop walk, not the number or order of header instances: a client-prepended value sits to the left of the addresses your proxies append, so it is returned only if every hop to its right is trusted. Correct trusted ranges are therefore essential, and stripping or overwriting the inbound header at the edge prevents spoofing outright. +- **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. When keys are derived through `KeyExtractors::clientIp()`, the resolver canonicalizes the address it returns, so per-client keys stay stable regardless of the spelling the client presents; the consistent-spelling caveat applies only to raw `KeyExtractors::ip()` (`REMOTE_ADDR`) or a custom resolver that does not canonicalize. ## First Test -Verify your setup works by sending requests: +With your application served (for the plain-PHP front controller in Step 5, run `php -S localhost:8080 public/index.php`; otherwise use your framework's own server), verify the firewall by sending requests: ```bash # Should pass (200) diff --git a/docs/index.md b/docs/index.md index d9f574b..7737ef8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ features: details: Fixed-window, sliding-window, and multi-window throttling with dynamic per-request limits. - icon: "\uD83D\uDEE1\uFE0F" title: Bot Detection - details: Block known scanners, verify search engine bots via rDNS, and detect suspicious headers — each with a single method call. + details: Block known scanners, verify search engine bots via rDNS, and detect suspicious headers, each with a single method call. - icon: "\uD83D\uDEAB" title: IP Blocking & Safelisting details: Safelist and blocklist IPs and CIDR ranges. Pattern backends with file-backed persistence and automatic expiration. @@ -35,7 +35,7 @@ features: details: Ready-made, serializable rule bundles you can layer, compose, and override by name. - icon: "\uD83D\uDCE6" title: PSR-15 Compatible - details: Works with any PSR-15 framework — Laravel, Symfony, Slim, TYPO3, Mezzio, and more. + details: "Works with any PSR-15 framework: Laravel, Symfony, Slim, TYPO3, Mezzio, and more." - icon: "💾" title: Flexible Storage details: "PSR-16 cache backends included: in-memory, APCu, Redis, and PDO." @@ -43,10 +43,10 @@ features:
-

Built by Flowd GmbH — Available for Your Next Project

+

Built by Flowd GmbH - Available for Your Next Project

Flowd GmbH (opens in new tab) is a highly specialized partner for custom application solutions. - Since 2018, our team of experienced software engineers and IT consultants builds tailored software for mid-market companies and enterprises — efficient, stable, and with long-term support. + Since 2018, our team of experienced software engineers and IT consultants builds tailored software for mid-market companies and enterprises: efficient, stable, and with long-term support.

diff --git a/docs/services.md b/docs/services.md index 211b782..fc1de3f 100644 --- a/docs/services.md +++ b/docs/services.md @@ -6,15 +6,15 @@ outline: deep ## About Flowd GmbH -**Flowd GmbH** is not a generic full-service agency — we are a highly specialized partner for custom application solutions in the B2B space. Since our founding in 2018, we have been building tailored software for mid-market companies and enterprises — efficient, stable, and with long-term support. +**Flowd GmbH** is not a generic full-service agency; we are a highly specialized partner for custom application solutions in the B2B space. Since our founding in 2018, we have been building tailored software for mid-market companies and enterprises: efficient, stable, and with long-term support. Founded by **Sascha Egerer** and **Felix Tscheulin**, our team consists of six experienced software engineers and IT consultants who work closely together with minimal organizational overhead. ### What sets us apart -- **Management = Developers** — Decisions are made by those who write the code. Our leadership team are trained computer scientists who are responsible for projects both technically and organizationally. -- **Lean structures, high quality** — No layers of project managers between you and the engineers. -- **Long-term partnerships** — We support our clients over many years and take responsibility for the solutions we build. +- **Management = Developers**: Decisions are made by those who write the code. Our leadership team are trained computer scientists who are responsible for projects both technically and organizationally. +- **Lean structures, high quality**: No layers of project managers between you and the engineers. +- **Long-term partnerships**: We support our clients over many years and take responsibility for the solutions we build. ## Our Services @@ -74,7 +74,7 @@ Unified digital platform for multi-brand, multi-country website operations. Cust Development of the Paperless API with Ruby on Rails. Architecture, security consulting, eIDAS 2.0 certification support. -### Mankido — Marketing Website +### Mankido - Marketing Website *TYPO3 CMS* Design and development of the Mankido marketing website on TYPO3, providing a flexible content management platform for brand communication. @@ -82,8 +82,8 @@ Design and development of the Mankido marketing website on TYPO3, providing a fl ## How We Work - **Long-term partnerships** over one-off projects -- **Direct, technical communication** — no classic project managers, just senior engineers -- **Transparency & responsibility** — open error culture, clear budgets, accountability in operations +- **Direct, technical communication**: no classic project managers, just senior engineers +- **Transparency & responsibility**: open error culture, clear budgets, accountability in operations ## Get in Touch From bf530d45ec2410f844949eb56f49fbffb3e61329 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Tue, 9 Jun 2026 08:58:33 +0200 Subject: [PATCH 13/17] Read trusted per-request values from request attributes, not headers Tier, role, user-id, and signature-validation values are computed server-side by your own middleware, so the examples now read them from PSR-7 request attributes (set via withAttribute) instead of client-supplied headers, which a caller could forge. Attributes live only on the server-side request object and never travel on the wire. Covers dynamic-throttle, rate-limiting, common-attacks, faq, getting-started, examples, and the fail2ban API-abuse rule. Login-failure markers keep the header form only for the separate-upstream-service case, with a note to prefer an attribute when an in-pipeline middleware sets the marker. --- docs/advanced/dynamic-throttle.md | 33 +++++++++++++++++-------------- docs/common-attacks.md | 14 ++++++------- docs/examples.md | 8 ++++++-- docs/faq.md | 4 ++-- docs/features/fail2ban.md | 18 +++++++---------- docs/features/rate-limiting.md | 10 ++++++---- docs/getting-started.md | 5 +++-- 7 files changed, 49 insertions(+), 43 deletions(-) diff --git a/docs/advanced/dynamic-throttle.md b/docs/advanced/dynamic-throttle.md index 08f552c..907fad9 100644 --- a/docs/advanced/dynamic-throttle.md +++ b/docs/advanced/dynamic-throttle.md @@ -25,10 +25,13 @@ use Psr\Http\Message\ServerRequestInterface; $config = new Config(new InMemoryCache()); $config->enableRateLimitHeaders(); +// `role` is a PSR-7 request attribute your auth middleware sets before Phirewall +// runs (e.g. $request->withAttribute('role', $user->role)). Attributes are +// server-side only, so a client cannot forge them the way it could a header. // Admins get 1000 req/min, regular users get 100 req/min $config->throttles->add('role-based', limit: fn(ServerRequestInterface $request): int => - $request->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, + $request->getAttribute('role') === 'admin' ? 1000 : 100, period: 60, ); ``` @@ -60,9 +63,9 @@ You can make both the limit and the period dynamic: // Enterprise users: 10,000 req/hour. Everyone else: 100 req/min. $config->throttles->add('fully-dynamic', limit: fn(ServerRequestInterface $request): int => - $request->getHeaderLine('X-Plan') === 'enterprise' ? 10000 : 100, + $request->getAttribute('plan') === 'enterprise' ? 10000 : 100, period: fn(ServerRequestInterface $request): int => - $request->getHeaderLine('X-Plan') === 'enterprise' ? 3600 : 60, + $request->getAttribute('plan') === 'enterprise' ? 3600 : 60, ); ``` @@ -214,7 +217,7 @@ Use a single rule with a dynamic limit. This is simpler and requires less config ```php $config->throttles->add('api', - limit: fn(ServerRequestInterface $request): int => match ($request->getHeaderLine('X-Plan')) { + limit: fn(ServerRequestInterface $request): int => match ($request->getAttribute('plan')) { 'enterprise' => 10000, 'pro' => 1000, 'free' => 100, @@ -234,8 +237,8 @@ Create separate rules and use the key closure returning `null` to skip: $config->throttles->add('free-tier', limit: 100, period: 60, key: function ($request): ?string { - if ($request->getHeaderLine('X-Plan') !== 'free') return null; - return $request->getHeaderLine('X-User-Id') ?: null; + if ($request->getAttribute('plan') !== 'free') return null; + return $request->getAttribute('userId'); }, ); @@ -243,8 +246,8 @@ $config->throttles->add('free-tier', $config->throttles->add('pro-tier', limit: 1000, period: 60, key: function ($request): ?string { - if ($request->getHeaderLine('X-Plan') !== 'pro') return null; - return $request->getHeaderLine('X-User-Id') ?: null; + if ($request->getAttribute('plan') !== 'pro') return null; + return $request->getAttribute('userId'); }, ); @@ -252,14 +255,14 @@ $config->throttles->add('pro-tier', $config->throttles->add('anonymous', limit: 50, period: 60, key: function ($request): ?string { - if ($request->getHeaderLine('X-User-Id') !== '') return null; + if ($request->getAttribute('userId') !== null) return null; return $request->getServerParams()['REMOTE_ADDR'] ?? null; }, ); ``` -::: warning Tier and identity headers must come from your auth layer -`X-User-Id` and `X-Plan` are read straight from the request. A client can send `X-Plan: enterprise` to self-grant the highest limit, or rotate `X-User-Id` to dodge a per-user limit. Set these headers in your authentication middleware **after** it has verified the principal and before the request reaches Phirewall, and strip or overwrite any inbound copy at the trusted edge. Looking the tier up from a verified identity, rather than trusting a request header, avoids this entirely. +::: tip Read tier and identity from request attributes, not headers +`plan` and `userId` here are PSR-7 request **attributes**, set by your authentication middleware after it verifies the principal: `$request = $request->withAttribute('plan', $user->plan)`. Attributes live only on the server-side request object and are never part of the incoming HTTP message, so a client cannot forge them the way it could an `X-Plan` header. Place that middleware before Phirewall in the pipeline (Phirewall still runs after your error handler). Only fall back to a header if a separate upstream service sets it, and then strip or overwrite any inbound copy at the trusted edge. ::: ## Per-Endpoint Cost @@ -290,8 +293,8 @@ $config->throttles->add('export-endpoints', limit: 10, period: 3600, key: function ($request): ?string { if (!str_starts_with($request->getUri()->getPath(), '/api/export')) return null; - return $request->getHeaderLine('X-User-Id') - ?: $request->getServerParams()['REMOTE_ADDR'] ?? null; + return $request->getAttribute('userId') + ?? ($request->getServerParams()['REMOTE_ADDR'] ?? null); }, ); ``` @@ -309,7 +312,7 @@ $config->throttles->add('api-limit', if (str_starts_with($ip, '10.')) return null; // Skip for admin users - if ($request->getHeaderLine('X-Role') === 'admin') return null; + if ($request->getAttribute('role') === 'admin') return null; // Skip for webhooks if (str_starts_with($request->getUri()->getPath(), '/webhooks/')) return null; @@ -334,7 +337,7 @@ $tierMap = array_column($userTiers, 'plan', 'user_id'); $config->throttles->add('db-tiered', limit: fn(ServerRequestInterface $request) use ($tierMap): int => - match ($tierMap[$request->getHeaderLine('X-User-Id')] ?? 'anonymous') { + match ($tierMap[$request->getAttribute('userId') ?? ''] ?? 'anonymous') { 'enterprise' => 10000, 'pro' => 1000, 'free' => 100, diff --git a/docs/common-attacks.md b/docs/common-attacks.md index 3a6f9d2..4782af5 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -67,7 +67,7 @@ $config->fail2ban->add('login-brute-force', 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. ::: 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. +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. If that component is a PSR-15 middleware in the same application, set a request attribute (`$request->withAttribute('loginFailed', true)`) instead of a header, which a client cannot forge at all; a header is only necessary when the marker comes from a separate upstream service. When in doubt, prefer the post-handler `RequestContext` approach above. ::: ### Login Endpoint Throttle @@ -338,7 +338,7 @@ Apply different limits based on subscription tier: ```php $config->throttles->add('api', - limit: fn(ServerRequestInterface $req): int => match ($req->getHeaderLine('X-Plan')) { + limit: fn(ServerRequestInterface $req): int => match ($req->getAttribute('plan')) { 'enterprise' => 10000, 'pro' => 1000, 'free' => 100, @@ -346,12 +346,12 @@ $config->throttles->add('api', }, period: 60, key: fn($req): ?string => - $req->getHeaderLine('X-User-Id') ?: $req->getServerParams()['REMOTE_ADDR'] ?? null, + $req->getAttribute('userId') ?? ($req->getServerParams()['REMOTE_ADDR'] ?? null), ); ``` -::: warning Tier and identity headers must come from your auth layer -`X-Plan` and `X-User-Id` are read straight from the request here. A client can send `X-Plan: enterprise` to self-grant the highest limit, or rotate `X-User-Id` to dodge a per-user limit. Set these headers in your authentication layer **after** it verifies the principal, and strip or overwrite any inbound copy at the trusted edge (see the **Header keys are client-controlled** warning later on this page). +::: tip Read tier and identity from request attributes, not headers +`plan` and `userId` are PSR-7 request **attributes** that your authentication layer sets after verifying the principal: `$request = $request->withAttribute('plan', $user->plan)`. Attributes are server-side only and never part of the incoming request, so a client cannot forge them the way it could an `X-Plan` header. Place the middleware that sets them before Phirewall in the pipeline. ::: ### Write Operation Limits @@ -409,8 +409,8 @@ $config->throttles->add('export', period: 3600, key: function (ServerRequestInterface $req): ?string { if (str_starts_with($req->getUri()->getPath(), '/api/export')) { - return $req->getHeaderLine('X-User-Id') - ?: $req->getServerParams()['REMOTE_ADDR'] ?? null; + return $req->getAttribute('userId') + ?? ($req->getServerParams()['REMOTE_ADDR'] ?? null); } return null; }, diff --git a/docs/examples.md b/docs/examples.md index bb1a1d3..936d245 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -522,9 +522,11 @@ class PhirewallServiceProvider extends ServiceProvider $config->throttles->add('global', limit: 1000, period: 60, ); + // `role` is a request attribute; set it on the PSR request in the bridge + // from Laravel's authenticated user (e.g. ->withAttribute('role', $request->user()?->role)). $config->throttles->add('api', limit: fn(ServerRequestInterface $req): int => - $req->getHeaderLine('X-Role') === 'admin' ? 5000 : 200, + $req->getAttribute('role') === 'admin' ? 5000 : 200, period: 60, ); @@ -1051,10 +1053,12 @@ use Psr\Http\Message\ServerRequestInterface; $config = new Config(new InMemoryCache()); $config->enableRateLimitHeaders(); +// `role` is a request attribute set by an upstream auth middleware +// ($req->withAttribute('role', ...)), not a forgeable client header. // Dynamic limit: admins get 1000 req/min, regular users get 100 req/min $config->throttles->add('role-based', limit: fn(ServerRequestInterface $req): int => - $req->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, + $req->getAttribute('role') === 'admin' ? 1000 : 100, period: 60, ); ``` diff --git a/docs/faq.md b/docs/faq.md index 1569c55..b685861 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -238,7 +238,7 @@ Yes. Use a dynamic `limit` closure: ```php $config->throttles->add('api', - limit: fn($request): int => match ($request->getHeaderLine('X-Plan')) { + limit: fn($request): int => match ($request->getAttribute('plan')) { 'enterprise' => 10000, 'pro' => 1000, default => 100, @@ -248,7 +248,7 @@ $config->throttles->add('api', ); ``` -Set `X-Plan` in your authentication layer after verifying the principal, and strip any inbound copy at the edge; otherwise a client can send `X-Plan: enterprise` to grant itself the top limit. +`plan` is a PSR-7 request **attribute** your authentication middleware sets after verifying the principal (`$request->withAttribute('plan', ...)`), not a client header a caller could forge. Place that middleware before Phirewall in the pipeline. See [Dynamic Throttle: Per-User Tier Limits](/advanced/dynamic-throttle#per-user-tier-limits) for more patterns. diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index 2966cdb..0745eb3 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -140,23 +140,19 @@ This three-layer strategy defends against different attack speeds: ### API Signature Abuse -Ban clients sending invalid API signatures. A middleware running before Phirewall validates signatures and marks the request: +Ban clients sending invalid API signatures. A middleware running before Phirewall validates the signature and records the outcome and the verified client id on the request as attributes: ```php -// The Fail2Ban rule reads the header set by the prior middleware +// The Fail2Ban rule reads the attributes the prior middleware set $config->fail2ban->add('api-abuse', threshold: 3, period: 120, // 2 minute window ban: 900, // 15 minute ban - filter: fn($req) => $req->getHeaderLine('X-Signature-Invalid') === '1', - key: function ($req): ?string { - // Hash the key so the raw secret never reaches the counter, ban - // registry, or event payloads; fall back to the client IP when absent. - $apiKey = $req->getHeaderLine('X-API-Key'); - return $apiKey !== '' - ? 'key:' . hash('sha256', $apiKey) - : ($req->getServerParams()['REMOTE_ADDR'] ?? null); - } + filter: fn($req) => $req->getAttribute('apiSignatureValid') === false, + // Key on the verified client id (an internal identifier, not the raw API + // secret), falling back to the client IP when the request is unauthenticated. + key: fn($req): ?string => + $req->getAttribute('apiClientId') ?? ($req->getServerParams()['REMOTE_ADDR'] ?? null), ); ``` diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index 80aabe8..7fae0a9 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -171,16 +171,18 @@ $config->throttles->add( ```php use Psr\Http\Message\ServerRequestInterface; -// Single rule handles all plans; no need for separate rules per tier +// `plan` and `userId` are request attributes set by your auth middleware +// (e.g. $req->withAttribute('plan', ...)), not client headers a caller could forge. +// A single rule handles all plans; no need for separate rules per tier. $config->throttles->add( 'api', - fn(ServerRequestInterface $req): int => match ($req->getHeaderLine('X-Plan')) { + fn(ServerRequestInterface $req): int => match ($req->getAttribute('plan')) { 'enterprise' => 10000, 'pro' => 1000, default => 100, }, 60, - fn(ServerRequestInterface $req): ?string => $req->getHeaderLine('X-User-Id') ?: null + fn(ServerRequestInterface $req): ?string => $req->getAttribute('userId') ); ``` @@ -191,7 +193,7 @@ $config->throttles->add( $config->throttles->add( 'role-based', fn(ServerRequestInterface $req): int => - $req->getHeaderLine('X-Role') === 'admin' ? 100 : 5, + $req->getAttribute('role') === 'admin' ? 100 : 5, 60, fn(ServerRequestInterface $req): string => $req->getServerParams()['REMOTE_ADDR'] ?? '127.0.0.1' diff --git a/docs/getting-started.md b/docs/getting-started.md index 7bcd664..4833abe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -156,9 +156,10 @@ $config->throttles->multi('api', [ 60 => 100, // 100 req/min sustained limit ]); -// Dynamic limits based on request properties +// Dynamic limit from a request attribute your auth middleware sets server-side +// ($req->withAttribute('role', ...)), never a forgeable header $config->throttles->add('role-based', - limit: fn($req) => $req->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, + limit: fn($req) => $req->getAttribute('role') === 'admin' ? 1000 : 100, period: 60, ); From ea45cb2c89b7051963fe4f50b0f96c2906d569e8 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Tue, 9 Jun 2026 09:04:14 +0200 Subject: [PATCH 14/17] Drop the forgeable X-Login-Failed marker example for login brute force A login failure is determined by the application while it handles the request, so it is signaled after the handler runs via RequestContext::recordFailure(), the approach the Brute Force Login section already leads with. The "Fail2Ban on a Request Marker" example relied on a trusted upstream setting an X-Login-Failed request header before Phirewall, which is contrived and forgeable, so it is removed. The comprehensive production setup now uses the never-match filter plus recordFailure() pattern, and the portable-config builder example swaps the login marker for a honeypot-path fail2ban rule. --- docs/advanced/portable-config.md | 4 +--- docs/common-attacks.md | 38 ++++---------------------------- 2 files changed, 5 insertions(+), 37 deletions(-) diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md index a830c6f..1e8ba95 100644 --- a/docs/advanced/portable-config.md +++ b/docs/advanced/portable-config.md @@ -33,7 +33,7 @@ $portable = PortableConfig::create() ->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()) + ->fail2ban('wp-login-probe', threshold: 5, period: 60, ban: 900, filter: PortableConfig::filterPathEquals('/wp-login.php'), key: PortableConfig::keyIp()) ->patternBlocklist('threats', [ PortableConfig::patternEntry(PatternKind::CIDR, '10.66.0.0/16'), PortableConfig::patternEntry(PatternKind::PATH_REGEX, '#/\.git(/|$)#'), @@ -47,8 +47,6 @@ $config = (new Config($cache))->combine(PortableConfig::fromArray(json_decode( $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 diff --git a/docs/common-attacks.md b/docs/common-attacks.md index 4782af5..4f1b775 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -40,36 +40,6 @@ if (!$this->authenticate($username, $password)) { 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, - ban: 3600, - filter: fn(ServerRequestInterface $req): bool => - $req->getMethod() === 'POST' - && $req->getUri()->getPath() === '/login' - && $req->getHeaderLine('X-Login-Failed') === '1', -); -``` - -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. - -::: 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. If that component is a PSR-15 middleware in the same application, set a request attribute (`$request->withAttribute('loginFailed', true)`) instead of a header, which a client cannot forge at all; a header is only necessary when the marker comes from a separate upstream service. When in doubt, prefer the post-handler `RequestContext` approach above. -::: - ### Login Endpoint Throttle Add a rate limit specifically on the login path to slow down attackers: @@ -483,12 +453,12 @@ CRS); $config->blocklists->owasp('owasp', $rules); // ── Layer 4: Fail2Ban ───────────────────────────────────────────────── +// The filter never matches pre-handler; your login handler signals each +// verified failure with $context->recordFailure('login-brute-force'). +// See Brute Force Login above. $config->fail2ban->add('login-brute-force', threshold: 5, period: 300, ban: 3600, - // X-Login-Failed must be set by trusted middleware and any inbound copy - // stripped at the edge (see Brute Force Login above); otherwise prefer the - // post-handler RequestContext::recordFailure() pattern. - filter: fn($req): bool => $req->getHeaderLine('X-Login-Failed') === '1', + filter: fn($req): bool => false, ); // ── Layer 5: Throttling ─────────────────────────────────────────────── From b4767372c20f5e0a1a8fdf4afb9590978e9c0f67 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Tue, 9 Jun 2026 09:59:05 +0200 Subject: [PATCH 15/17] Re-scope the portable-config database example to per-request loading The reload()/version-check loop assumed a process that survives many requests, which is the long-running-worker case (Swoole, RoadRunner, FrankenPHP worker mode, Octane). Under PHP-FPM every request is a fresh process, so the default is now a plain per-request load: read the signed ruleset from the store and build the Config, and a row change takes effect on the next request. The version-check is kept but demoted to a labeled long-running-worker optimization. --- docs/advanced/portable-config.md | 35 +++++++++++++++++--------------- docs/examples.md | 2 +- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md index 1e8ba95..910ae9d 100644 --- a/docs/advanced/portable-config.md +++ b/docs/advanced/portable-config.md @@ -6,7 +6,7 @@ outline: deep `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), +- **store it in a database** and pick up rule changes on the next request, - **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** @@ -132,33 +132,36 @@ Pattern backends carry a list of entries; each entry has a `PatternKind`: | `setFailOpen(bool)` | fail-open (default) vs fail-closed on backend errors | | `setKeyPrefix(prefix)` | cache-key prefix | -## Pattern backends: rules in a database, hot-reloaded +## Rules in a database -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: +Pattern backends and the portable schema are the natural fit for a block catalogue you maintain *outside* code, e.g. a `blocked_patterns` table or a threat feed. Store the (ideally [signed](#signed-transport)) ruleset in your store, and on each request load it and build the `Config`. Changing the stored rules then takes effect on the next request, with no deploy: ```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; +// $store->load() returns the signed blob from your DB / cache / config service. +$portable = PortableConfig::loadSigned($store->load(), $secret); +$firewall = new Firewall((new Config($cache))->combine($portable)); +``` + +Under classic PHP-FPM each request is a fresh process, so this runs once per request and always reflects the current rules. To avoid querying the database on every request, put a shared cache (APCu, for example) in front of the store. + +### Long-running workers -$reload = static function () use (&$store, &$loadedVersion, &$firewall, $secret, $cache): bool { - $row = $store->load(); - if ($loadedVersion === $row['version']) { - return false; // already current; no rebuild - } +Under a long-running worker runtime (Swoole, RoadRunner, FrankenPHP worker mode, Octane) the process handles many requests, so keep the built `Firewall` in memory and rebuild it only when the stored ruleset version changes: +```php +// $store->load() returns ['version' => int, 'blob' => string]. +$row = $store->load(); +if ($loadedVersion !== $row['version']) { $portable = PortableConfig::loadSigned($row['blob'], $secret); $firewall = new Firewall((new Config($cache))->combine($portable)); $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. +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 @@ -196,7 +199,7 @@ A few capabilities cannot be represented as pure data and are intentionally **ex ## 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. +- [`examples/29-portable-config.php`](https://github.com/flowd/phirewall/blob/main/examples/29-portable-config.php) - round-trip, signing, and a database-backed, per-request loading scenario. ## Related pages diff --git a/docs/examples.md b/docs/examples.md index 936d245..956375c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -47,7 +47,7 @@ php examples/01-basic-setup.php | 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 | +| 29 | [portable-config](https://github.com/flowd/phirewall/blob/main/examples/29-portable-config.php) | PortableConfig as a first-class transport with database-backed rules | | 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 version comparison (compare `Presets::VERSION` against your own release feed) | From 2050e9632e6436d679539311054e6709498337c0 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Tue, 9 Jun 2026 10:23:12 +0200 Subject: [PATCH 16/17] Drop the route-scoped apiRateLimiting and loginProtection presets Presets now ship only universal-signal bundles (scannerBlocking, sensitivePathBlocking). API rate limiting and login brute-force protection hardcode an application's own routes (/api, /login), which vary per app, so they are documented as plain config instead. presets.md gains a "Route-specific protection" section pointing to Rate Limiting, Dynamic Throttle, and Brute Force Login, and the two preset tips on the rate-limiting and fail2ban pages are removed. --- docs/advanced/presets.md | 15 +++++---------- docs/features/fail2ban.md | 4 ---- docs/features/rate-limiting.md | 4 ---- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/docs/advanced/presets.md b/docs/advanced/presets.md index d38fd13..9a590e5 100644 --- a/docs/advanced/presets.md +++ b/docs/advanced/presets.md @@ -4,7 +4,7 @@ outline: deep # Presets -Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a [`PortableConfig`](/advanced/portable-config) returned by an accessor (e.g. `Presets::apiRateLimiting()`): plain, inspectable, serializable data you can diff, sign, or layer. +Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a [`PortableConfig`](/advanced/portable-config) returned by an accessor (e.g. `Presets::scannerBlocking()`): plain, inspectable, serializable data you can diff, sign, or layer. Materialize one or several onto your own cache with [`Config::combine()`](/advanced/config-composition); presets are pure data and never receive a cache. Every rule is namespaced `preset..*`, so a later layer that redefines it by name overrides predictably. @@ -15,16 +15,15 @@ use Flowd\Phirewall\Config; use Flowd\Phirewall\Preset\Presets; // A preset on its own; combine it onto a Config you build with your cache: -$config = (new Config($cache))->combine(Presets::apiRateLimiting()); +$config = (new Config($cache))->combine(Presets::scannerBlocking()); // Inspect / serialize the underlying portable schema: -$schema = Presets::apiRateLimiting()->toArray(); +$schema = Presets::scannerBlocking()->toArray(); // Stack several presets, then your own rules last (later layers win by name): $config = (new Config($cache))->combine( Presets::scannerBlocking(), Presets::sensitivePathBlocking(), - Presets::apiRateLimiting(), $myPortable, ); ``` @@ -35,8 +34,6 @@ Preset rules emit the same [observability events](/advanced/observability) as ha | 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`. | @@ -44,10 +41,8 @@ Resolve any preset by name with `Presets::get($name)` (a `PortableConfig`), pass ## 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). +- The shipped presets target signals that are universal across applications (scanner User-Agents, missing browser headers, well-known sensitive paths), so they assume nothing about your routing. A preset you build yourself is just a `PortableConfig`, so it can key on whatever fits your environment, including routes your own apps standardize. - Override any rule by combining the preset with your own portable rules that redefine the rule by the same name (later layer wins), or by rebuilding the preset's schema. -- 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. @@ -76,4 +71,4 @@ See [`examples/31-presets.php`](https://github.com/flowd/phirewall/blob/main/exa - [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()`. +- [Fail2Ban & Allow2Ban](/features/fail2ban) - the brute-force ban mechanism. diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index 0745eb3..f269483 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -86,10 +86,6 @@ $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: diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index 7fae0a9..27129e6 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -14,10 +14,6 @@ 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. -::: - ::: tip Default key The `key` argument on `add()`, `sliding()`, and `multi()` is optional. When omitted, the throttle keys on the client IP resolved by the Config's IP resolver (set via `Config::setIpResolver(KeyExtractors::clientIp($trustedProxyResolver))` behind a proxy), falling back to `KeyExtractors::ip()` (REMOTE_ADDR) when none is set. The resolver is read per request, so it can be set before or after adding rules. The examples below omit `key:` to use this default; pass an explicit `key:` only to key on something other than the client IP (a header, a username, and so on). ::: From f8e91386d40a4611b8d5a1e9f7da20c373b1362d Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Tue, 9 Jun 2026 11:23:41 +0200 Subject: [PATCH 17/17] Stop pinning TYPO3 versions in the framework support list v14 is supported too and the range can change; specific versions are not part of these docs (the flowd/typo3-firewall extension owns that). --- docs/faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index b685861..4ed2095 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -16,7 +16,7 @@ Phirewall works with any PSR-15 (PHP Standard Recommendation for HTTP Server Mid - **Slim** (4.x+) - **Mezzio** (Laminas) -- **TYPO3** (v12/v13, via an extension middleware) +- **TYPO3** (via an extension middleware) - **Laravel** (via `symfony/psr-http-message-bridge` + `nyholm/psr7`) - **Symfony** (via `symfony/psr-http-message-bridge`) - **Spiral**