From e574f5627421177c3ecc427b4d2bb7926621775d Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Fri, 24 Jul 2026 23:40:25 +0200 Subject: [PATCH 1/5] Document the generic diagnostic-headers mechanism --- docs/advanced/architecture.md | 4 ++-- docs/advanced/portable-config.md | 2 +- docs/faq.md | 10 +++++----- docs/features/owasp-crs.md | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 3296047..4deb646 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -66,7 +66,7 @@ The `EvaluationContext` is a mutable transport object that carries shared config | `normalize` | `Closure(string): string` | Discriminator key normalizer | | `responseHeadersEnabled` | `bool` | Whether `X-Phirewall` headers are active | | `rateLimitHeadersEnabled` | `bool` | Whether `X-RateLimit-*` headers are active | -| `owaspDiagnosticsHeaderEnabled` | `bool` | Whether `X-Phirewall-Owasp-Rule` header is active | +| `diagnosticsHeadersEnabled` | `bool` | Whether matcher-provided diagnostic headers are copied onto blocked responses | | `counter` | `FixedWindowCounter` | Shared counter for fail2ban, allow2ban, and track rules | | `decisionPath` | `DecisionPath` | Updated by evaluators to record which stage decided | | `decisionRule` | `?string` | Updated by evaluators to record the matching rule name | @@ -89,7 +89,7 @@ Checks safelist rules. On the first match, dispatches `SafelistMatched`, sets th ### BlocklistEvaluator -Checks blocklist rules. On the first match, dispatches `BlocklistMatched`, sets the decision path to `Blocklisted`, and returns `FirewallResult::blocked()`. For OWASP-sourced rules, includes the `X-Phirewall-Owasp-Rule` diagnostic header when enabled. +Checks blocklist rules. On the first match, dispatches `BlocklistMatched`, sets the decision path to `Blocklisted`, and returns `FirewallResult::blocked()`. Diagnostic headers the matcher declared in its `MatchResult` metadata (`diagnostic_headers`) are copied onto the response when `enableDiagnosticsHeaders()` is active. ### Fail2BanEvaluator diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md index f4ae4ab..d5cce62 100644 --- a/docs/advanced/portable-config.md +++ b/docs/advanced/portable-config.md @@ -132,7 +132,7 @@ Pattern backends carry a list of entries; each entry has a `PatternKind`: |---------|------------------------------| | `enableRateLimitHeaders()` | emit `X-RateLimit-*` headers | | `enableResponseHeaders()` | emit `X-Phirewall-*` headers | -| `enableOwaspDiagnosticsHeader()` | emit the OWASP diagnostics header | +| `enableDiagnosticsHeaders()` | copy matcher-provided diagnostic headers onto blocked responses (`enableOwaspDiagnosticsHeader()` is a deprecated alias) | | `setFailOpen(bool)` | fail-open (default) vs fail-closed on backend errors | | `setKeyPrefix(prefix)` | cache-key prefix | diff --git a/docs/faq.md b/docs/faq.md index 38a06e8..363e377 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -322,13 +322,13 @@ $crs->enable(942100); // Re-enable it later ### How do I debug which OWASP rule is blocking a request? -Enable the diagnostics header: +Enable diagnostic headers: ```php -$config->enableOwaspDiagnosticsHeader(); +$config->enableDiagnosticsHeaders(); ``` -This adds an `X-Phirewall-Owasp-Rule` header to blocked responses containing the matched rule ID. +The CRS matcher attaches an `X-Phirewall-Owasp-Rule` header with the matched rule ID to blocked responses. This works wherever the matcher decides the block: as a blocklist rule and as a fail2ban filter. Any matcher can ship such headers via the `diagnostic_headers` metadata key on its `MatchResult`. ::: warning Only enable this in development or staging. In production, it reveals information about your security rules to potential attackers. @@ -372,7 +372,7 @@ Common causes: Debug steps: 1. Enable `$config->enableResponseHeaders()` and check the `X-Phirewall` and `X-Phirewall-Matched` response headers to identify the blocking rule 2. Temporarily disable suspect rules and re-enable them one by one -3. If OWASP rules are involved, enable `$config->enableOwaspDiagnosticsHeader()` to see which rule ID matched +3. If OWASP rules are involved, enable `$config->enableDiagnosticsHeaders()` to see which rule ID matched ### Rate limits are not working in PHP-FPM @@ -386,7 +386,7 @@ Enable `$config->enableResponseHeaders()` and check the response headers on bloc |--------|-------| | `X-Phirewall` | Block type: `blocklist`, `throttle`, `fail2ban`, or `allow2ban` | | `X-Phirewall-Matched` | Name of the rule that triggered the block | -| `X-Phirewall-Owasp-Rule` | OWASP rule ID (only if diagnostics are enabled) | +| `X-Phirewall-Owasp-Rule` | OWASP rule ID (only if `enableDiagnosticsHeaders()` is active) | ::: info These headers are disabled by default. Call `$config->enableResponseHeaders()` to enable them for debugging. diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index cdb1af5..9f8975e 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -282,11 +282,11 @@ $rule = $rules->getRule(942100); ## OWASP Diagnostics Header -Enable the diagnostics header to see which OWASP rule matched: +Enable diagnostic headers to see which OWASP rule matched: ```php $config->enableResponseHeaders(); -$config->enableOwaspDiagnosticsHeader(); +$config->enableDiagnosticsHeaders(); ``` When an OWASP rule blocks a request, the response includes: @@ -298,7 +298,7 @@ X-Phirewall-Owasp-Rule: 942100 ``` ::: info -`X-Phirewall` and `X-Phirewall-Matched` require `enableResponseHeaders()`. The `X-Phirewall-Owasp-Rule` header is controlled independently by `enableOwaspDiagnosticsHeader()`. +`X-Phirewall` and `X-Phirewall-Matched` require `enableResponseHeaders()`. The `X-Phirewall-Owasp-Rule` header is controlled independently by `enableDiagnosticsHeaders()` (`enableOwaspDiagnosticsHeader()` is a deprecated alias): the CRS matcher declares it via the generic `diagnostic_headers` metadata key on its `MatchResult`, so it also appears when the matcher is used as a fail2ban filter. ::: ::: info From 3328341cc8aeb27856e12942adfe4099d836bd88 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Sat, 25 Jul 2026 00:40:45 +0200 Subject: [PATCH 2/5] Document the MatchResult property on matcher-carried events --- docs/advanced/observability.md | 2 ++ docs/advanced/track-notifications.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/advanced/observability.md b/docs/advanced/observability.md index 5d0ebfe..0bb8aec 100644 --- a/docs/advanced/observability.md +++ b/docs/advanced/observability.md @@ -43,6 +43,8 @@ All events are dispatched **synchronously** during request processing. Every eve All event classes live in the `Flowd\Phirewall\Events` namespace and are `readonly`. +Every matcher-carried event (`SafelistMatched`, `BlocklistMatched`, `ThrottleExceeded`, `TrackHit`, `Fail2BanMatched`, `Fail2BanBanned`, `Allow2BanBanned`) additionally exposes a `MatchResult $matchResult` property with the match (or filter/scope match) that triggered it. It is nullable only on `ThrottleExceeded`, `Fail2BanBanned` and `Allow2BanBanned`, where no matcher may have run (unscoped throttles, post-handler `recordFailure()`/`recordHit()` signals, filterless allow2ban rules). Listeners can read matcher metadata such as `diagnostic_headers` from it without enabling the attacker-visible response headers. + ### SafelistMatched Dispatched when a request matches a safelist rule. The request bypasses all remaining checks. diff --git a/docs/advanced/track-notifications.md b/docs/advanced/track-notifications.md index c91d9f3..00f0814 100644 --- a/docs/advanced/track-notifications.md +++ b/docs/advanced/track-notifications.md @@ -187,7 +187,7 @@ Phirewall dispatches events for every significant decision. You can listen for a | `PerformanceMeasured` | Every firewall decision (for metrics) | `decisionPath`, `durationMicros`, `ruleName` | | `FirewallError` | An exception occurs in fail-open mode | `exception`, `serverRequest` | -All event classes live in the `Flowd\Phirewall\Events` namespace and are `readonly`. +All event classes live in the `Flowd\Phirewall\Events` namespace and are `readonly`. The matcher-carried events also expose a `?MatchResult $matchResult` with the match that triggered them (see [Observability](/advanced/observability#event-summary)). ## Notification Examples From 642d47fa513a0854f582a59ae6def5ed815fe075 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Sun, 26 Jul 2026 23:13:26 +0200 Subject: [PATCH 3/5] Clarify matchResult nullability in the event overview --- docs/advanced/track-notifications.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced/track-notifications.md b/docs/advanced/track-notifications.md index 00f0814..fde002a 100644 --- a/docs/advanced/track-notifications.md +++ b/docs/advanced/track-notifications.md @@ -187,7 +187,7 @@ Phirewall dispatches events for every significant decision. You can listen for a | `PerformanceMeasured` | Every firewall decision (for metrics) | `decisionPath`, `durationMicros`, `ruleName` | | `FirewallError` | An exception occurs in fail-open mode | `exception`, `serverRequest` | -All event classes live in the `Flowd\Phirewall\Events` namespace and are `readonly`. The matcher-carried events also expose a `?MatchResult $matchResult` with the match that triggered them (see [Observability](/advanced/observability#event-summary)). +All event classes live in the `Flowd\Phirewall\Events` namespace and are `readonly`. The matcher-carried events also expose a `MatchResult $matchResult` with the match that triggered them; it is nullable only where no matcher may have run (see [Observability](/advanced/observability#event-summary)). ## Notification Examples From 0af7667ec2ea8bf788273f2d89ae65ade4ee4546 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Tue, 28 Jul 2026 09:10:27 +0200 Subject: [PATCH 4/5] Use consistent Fail2Ban and Allow2Ban capitalization in prose --- docs/advanced/architecture.md | 6 +++--- docs/advanced/observability.md | 2 +- docs/advanced/portable-config.md | 4 ++-- docs/faq.md | 4 ++-- docs/features/owasp-crs.md | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 4deb646..fc558f0 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -67,7 +67,7 @@ The `EvaluationContext` is a mutable transport object that carries shared config | `responseHeadersEnabled` | `bool` | Whether `X-Phirewall` headers are active | | `rateLimitHeadersEnabled` | `bool` | Whether `X-RateLimit-*` headers are active | | `diagnosticsHeadersEnabled` | `bool` | Whether matcher-provided diagnostic headers are copied onto blocked responses | -| `counter` | `FixedWindowCounter` | Shared counter for fail2ban, allow2ban, and track rules | +| `counter` | `FixedWindowCounter` | Shared counter for Fail2Ban, Allow2Ban, and track rules | | `decisionPath` | `DecisionPath` | Updated by evaluators to record which stage decided | | `decisionRule` | `?string` | Updated by evaluators to record the matching rule name | | `pendingRateLimitHeaders` | `?array` | Rate-limit headers captured by `ThrottleEvaluator` for pass-through responses | @@ -93,7 +93,7 @@ Checks blocklist rules. On the first match, dispatches `BlocklistMatched`, sets ### Fail2BanEvaluator -For each fail2ban rule: +For each Fail2Ban rule: 1. Checks if the key is already banned - if so, dispatches `Fail2BanBlocked` and returns a blocked result immediately 2. If the filter matches, increments the failure counter and blocks the request (`403`). A match below the threshold sets `DecisionPath::Fail2BanMatched` and dispatches `Fail2BanMatched`; the Nth match additionally bans the key, sets `DecisionPath::Fail2BanBanned`, and dispatches `Fail2BanBanned` (never both events) @@ -112,7 +112,7 @@ For each throttle rule: ### Allow2BanEvaluator -Unlike other evaluators, Allow2BanEvaluator **processes all rules before returning**. For each allow2ban rule: +Unlike other evaluators, Allow2BanEvaluator **processes all rules before returning**. For each Allow2Ban rule: 1. If the key is already banned, records the block (regardless of the filter); the rule that captures the block dispatches `Allow2BanBlocked` 2. Otherwise, if the rule has a filter that does not match, skips the rule (not counted) diff --git a/docs/advanced/observability.md b/docs/advanced/observability.md index 0bb8aec..c8aadcd 100644 --- a/docs/advanced/observability.md +++ b/docs/advanced/observability.md @@ -43,7 +43,7 @@ All events are dispatched **synchronously** during request processing. Every eve All event classes live in the `Flowd\Phirewall\Events` namespace and are `readonly`. -Every matcher-carried event (`SafelistMatched`, `BlocklistMatched`, `ThrottleExceeded`, `TrackHit`, `Fail2BanMatched`, `Fail2BanBanned`, `Allow2BanBanned`) additionally exposes a `MatchResult $matchResult` property with the match (or filter/scope match) that triggered it. It is nullable only on `ThrottleExceeded`, `Fail2BanBanned` and `Allow2BanBanned`, where no matcher may have run (unscoped throttles, post-handler `recordFailure()`/`recordHit()` signals, filterless allow2ban rules). Listeners can read matcher metadata such as `diagnostic_headers` from it without enabling the attacker-visible response headers. +Every matcher-carried event (`SafelistMatched`, `BlocklistMatched`, `ThrottleExceeded`, `TrackHit`, `Fail2BanMatched`, `Fail2BanBanned`, `Allow2BanBanned`) additionally exposes a `MatchResult $matchResult` property with the match (or filter/scope match) that triggered it. It is nullable only on `ThrottleExceeded`, `Fail2BanBanned` and `Allow2BanBanned`, where no matcher may have run (unscoped throttles, post-handler `recordFailure()`/`recordHit()` signals, filterless Allow2Ban rules). Listeners can read matcher metadata such as `diagnostic_headers` from it without enabling the attacker-visible response headers. ### SafelistMatched diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md index d5cce62..87c49c5 100644 --- a/docs/advanced/portable-config.md +++ b/docs/advanced/portable-config.md @@ -72,7 +72,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 rule 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) | @@ -88,7 +88,7 @@ Everything `PortableConfig` can express today. `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`, `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, allow2ban, 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, Allow2Ban, and track rules. ::: ### Key extractors diff --git a/docs/faq.md b/docs/faq.md index 363e377..c28bf79 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -185,7 +185,7 @@ $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. -For allow2ban rules, use `$context->recordHit('rule-name')`, same shape, routed through the allow2ban evaluator instead. +For Allow2Ban rules, use `$context->recordHit('rule-name')`, same shape, routed through the Allow2Ban evaluator instead. ## Rate Limiting @@ -328,7 +328,7 @@ Enable diagnostic headers: $config->enableDiagnosticsHeaders(); ``` -The CRS matcher attaches an `X-Phirewall-Owasp-Rule` header with the matched rule ID to blocked responses. This works wherever the matcher decides the block: as a blocklist rule and as a fail2ban filter. Any matcher can ship such headers via the `diagnostic_headers` metadata key on its `MatchResult`. +The CRS matcher attaches an `X-Phirewall-Owasp-Rule` header with the matched rule ID to blocked responses. This works wherever the matcher decides the block: as a blocklist rule and as a Fail2Ban filter. Any matcher can ship such headers via the `diagnostic_headers` metadata key on its `MatchResult`. ::: warning Only enable this in development or staging. In production, it reveals information about your security rules to potential attackers. diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index 9f8975e..88e8110 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -42,9 +42,9 @@ $config = new Config(new InMemoryCache()); $config = $config->with(Presets::blocklist(ParanoiaLevel::Level1)); ``` -Want to also ban repeat offenders? Use the fail2ban preset instead. A CRS +Want to also ban repeat offenders? Use the Fail2Ban preset instead. A CRS match is malicious by definition, so from 0.8 both presets block every match -with `403`; the difference is that the fail2ban preset additionally **bans** +with `403`; the difference is that the Fail2Ban preset additionally **bans** the key after the threshold. A banned attacker is then blocked by a cheap ban lookup (the CRS engine no longer runs for them), and the ban is observable via `Fail2BanBanned` and mirrorable to your web server: @@ -298,7 +298,7 @@ X-Phirewall-Owasp-Rule: 942100 ``` ::: info -`X-Phirewall` and `X-Phirewall-Matched` require `enableResponseHeaders()`. The `X-Phirewall-Owasp-Rule` header is controlled independently by `enableDiagnosticsHeaders()` (`enableOwaspDiagnosticsHeader()` is a deprecated alias): the CRS matcher declares it via the generic `diagnostic_headers` metadata key on its `MatchResult`, so it also appears when the matcher is used as a fail2ban filter. +`X-Phirewall` and `X-Phirewall-Matched` require `enableResponseHeaders()`. The `X-Phirewall-Owasp-Rule` header is controlled independently by `enableDiagnosticsHeaders()` (`enableOwaspDiagnosticsHeader()` is a deprecated alias): the CRS matcher declares it via the generic `diagnostic_headers` metadata key on its `MatchResult`, so it also appears when the matcher is used as a Fail2Ban filter. ::: ::: info From 7dccd2dec42d22006af2c5065b256607d2d2d574 Mon Sep 17 00:00:00 2001 From: Sascha Egerer Date: Tue, 28 Jul 2026 09:11:54 +0200 Subject: [PATCH 5/5] Match the OWASP diagnostics instruction to its example --- docs/features/owasp-crs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index 88e8110..2a79711 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -282,7 +282,7 @@ $rule = $rules->getRule(942100); ## OWASP Diagnostics Header -Enable diagnostic headers to see which OWASP rule matched: +Enable response and diagnostic headers to see which OWASP rule matched: ```php $config->enableResponseHeaders();