v2.8.0
The largest release in the 2.x line: a full security audit of the library (14 findings across two passes), a new challenge/interstitial response type with two providers, OWASP CRS rule evaluation, and a set of fail-open fixes in config loading. Several changes tighten default posture — read Upgrade notes before deploying.
PHP 8.1–8.5 supported. Package name is unchanged (kanopi/firewall).
Highlights
response: challengeplugin type — plugins can now serve a CAPTCHA-style interstitial instead of allowing or blocking outright. Solving it mints an HMAC-signed, IP-bound pass token delivered as aSecure; HttpOnly; SameSite=Strictcookie and as a header value the interstitial JS stores inlocalStoragefor SPA callers. Later requests carrying a valid token skip the challenge bucket; block plugins still apply. (#73, fixes #39)- Two built-in challenge providers —
math(stateless "What is A + B?", HMAC-signed state) andaltcha(proof-of-work via the ALTCHA widget, pinned toaltcha@2.3.0with a SHA-384 SRI digest, source overridable viachallenge.provider_options). Custom providers plug in throughChallengeProviderInterface. (#73, #76, fixes #39, #72) - OWASP Core Rule Set plugin — new
Crsplugin backed bykanopi/crs-engine, parsing real upstream CRS rule files (489+ request-side rules across 22 files in CRS 4.x). Configurable paranoia level, block/monitor mode, disabled rules and categories, anomaly thresholds, block status code, and block duration. (#70, fixes #9) - Config load failures no longer fail open — a missing, unreadable, or malformed config file used to produce an empty plugin registry that allowed every request, with nothing logged. Failures are now recorded and reported at
errorlevel, andglobal.require_config: trueturns them into a startupConfigurationException. (#86, fixes #78) - Security audit fixes — 14 findings resolved, including a PHP Object Injection chain on the file storage backend, reflected XSS in the banning response, and a class-instantiation gadget in the logging factory. Details below.
- Docs overhaul — the README, presets, and examples are now oriented around the canonical
plugins:array withresponse:/weight:. Legacybypass:/block:still works and is documented in one deprecated section. New logging recipes (Slack, Pushover, Telegram, SendGrid, IFTTT, rotating file, JSON,error_log) are each pinned by a test that round-trips them through the real loader. (#68, #71, #77) - Project renamed from Simple Firewall to Lite Firewall. Docs only — no code, namespace, or package changes. (#67, fixes #19)
Upgrade notes
- On-disk storage format changed from
serialize()to JSON. ExistingFileStorageandFileRateLimitStoragedata files become unreadable and are discarded. There is no migration path by design — this is ephemeral firewall state (block list, rate-limit counters). The same applies toDatabaseStorage'srequestcolumn, which now stores JSON. %env(file:...)%and%env(require:...)%are disabled by default. If you use either, opt in during bootstrap:TokenSubstitute::enableUnsafeProcessors(['file', 'require'], $allowedBaseDirs). An empty allowlist preserves the old any-readable-path behavior; a populated one confines every resolved path under the given base directories.AsnandGeoLocationnow fail closed when a reader was configured but failed to initialize (missing or stale MaxMind database, transient error) — the request is blocked and the failure logged aterror. Plugins with nometadata.readerkey at all remain intentionally inactive, so opt-out users are unaffected. Setmetadata.fail_open: trueto restore the old allow-on-failure behavior.- New startup warning if trusted proxies are unset. Every plugin evaluates
$request->getClientIp(), which honorsX-Forwarded-Foronly after the integrator callsRequest::setTrustedProxies(...). Deployments behind a load balancer without that call now get a clear warning naming the missing call;global.require_trusted_proxies: trueescalates it to a startupConfigurationExceptionrather than starting in a spoofable state. - Default file-storage paths moved.
FileStorageandFileRateLimitStorageno longer default to predictable/tmp/*.datapaths; defaults now sit in a per-install0700subdirectory ofsys_get_temp_dir(), and all storage files are forced to0600. Explicitly configured paths are unaffected apart from the permission tightening. - New required dependency:
kanopi/crs-engine ^0.1.0. Config::fileGetContents()argument precedence flipped. Explicit arguments now win;KANOPI_FIREWALL_CACHE_DIR/_TTL/_TIMEOUTonly fill in arguments passed asnull. The single production caller passes no arguments, so behavior throughConfig::loadFile()is unchanged — this only affects code calling the method directly.- Pass token
audclaim (relevant only if you ran the challenge feature from an unreleased branch): tokens minted without an audience are rejected, so live token holders are challenged once more after deploying. Tokens are short-lived (default one hour), so it clears on its own. bypass:/block:config sections are deprecated but still accepted and auto-normalized intoplugins:entries at load time. No action required; new configs should use theplugins:array.
New features
Challenge response type (#73, #76)
New config section, active only when at least one plugin uses response: challenge:
challenge:
provider: math # `math`, `altcha`, or a FQCN
secret: '%env(FW_CHALLENGE_SECRET)%' # required when challenge plugins are active
path: /_firewall/challenge
cookie_name: fw_challenge_pass # "" disables cookie delivery
header_name: X-Firewall-Challenge # "" disables the localStorage path
provider_options: {} # forwarded to the provider constructor
audience: '' # defaults to the provider nameChallengeProviderInterface— extension point (renderInterstitial(),verifySolution(),getName()), withREDIRECT_FIELD/TTL_FIELDpromoted onto the interface so third-party providers work through the submission flow.TokenManager— mints and verifies pass tokens over{ip, exp, nonce, aud}with HMAC-SHA256 and a constant-time signature check; fails fast on an empty secret.InterstitialRenderer— shared document shell and submit script; providers supply only their own control markup, CSS, and wiring. Script-context values are emitted as JS literals viajson_encodewith the HEX flags, so a hostileredirect_tocannot break out of the string or close the<script>element early.SingleUseSolutionInterface— opt-in contract letting a provider mark a solved challenge as spent. ALTCHA adopts it, so one centrally-solved proof of work cannot be redistributed to a fleet of clients.MathChallengeProviderdeliberately does not: with nine possible answers, distinct visitors legitimately share a state value.- Pass tokens are scoped by an
audclaim inside the signed payload, so two Firewall instances sharing achallenge.secretno longer accept each other's tokens — the weakest challenge in a deployment can't set the effective security of every route. ChallengeRequiredException/ChallengeSolvedExceptionparallelFirewallBlockedExceptionforFirewallMode::Exceptionintegrations.- Evaluation order: challenge-submission intercept → bypass → repeat-offender check → valid-token short-circuit → challenge plugins → block plugins.
Demo application (#73, #76)
example/demo/ ships a general-purpose demo covering all three response modes via URL rules (/admin blocked, /secure challenged with math, /secure-altcha with ALTCHA, / allowed), in three stacks: PHP's built-in server, a multi-worker Docker variant, and a production-shaped nginx + php-fpm stack for perf testing. Driven by composer demo, demo:workers, demo:reset, demo:perf, demo:perf:restart, and demo:perf:down.
Other
global.require_configplus aKANOPI_FIREWALL_REQUIRE_CONFIGconstant for the case where the file carrying the flag is itself the one that failed to load.Config::getLoadErrors()exposes the failures for callers who prefer to assert on them directly. (#86)global.require_trusted_proxiesrefuses to start when trusted proxies are empty. (#66)LoggingFactory::setRedactedVariables()configures which rule variables get redacted in logs, system-wide, from one bootstrap call. (#66)DatabaseTrait::getStorageTables()and theEvaluateTraithook are now declared with no-op defaults instead of being probed withmethod_exists()— an implicit convention is now a documented extension point. (#83, fixes #80)ext-redisis declared undersuggest; every other rate-limit backend (memory, file, database, PSR-6 cache) works without it. (#84)
Security fixes
Critical and high (#65):
- PHP Object Injection via file storage (#51) —
FileTrait::loadFromFile()ranunserialize()on the storage file. With predictable/tmpdefaults, any local user on a shared host or container could plant a payload and get RCE in the host application's context on the next request. On-disk format is now JSON, with non-array roots rejected and decode failures logged. - Reflected XSS and response splitting in the banning message (#52) —
{{request.header.*}},{{request.query.*}},{{request.post.*}}, and{{request.cookie.*}}placeholders were written verbatim to the response. Substitutions are now HTML-escaped and stripped of CR/LF, and blocking responses sendContent-Type: text/plain; charset=utf-8withX-Content-Type-Options: nosniff. - Predictable storage paths and world-readable files (#53) — defaults moved into a per-install
0700directory offsys_get_temp_dir(); files are created0600and existing group/other bits are stripped best-effort. - Arbitrary class instantiation in
LoggingFactory(#54) — handler and formatter class names from config were passed tonewwith attacker-influenceable constructor args behind a tautological guard. Both branches now requireis_a($class, HandlerInterface::class, true)/FormatterInterfacebefore instantiation. file:/require:env processors as LFI/RCE primitives (#55) — now opt-in only, with an optional base-directory allowlist.- Fail-open GeoIP/ASN readers (#56) — a broken reader silently turned country and ASN block lists into no-ops. Now fails closed and logs at
error, withmetadata.fail_open: trueto opt back out. - ReDoS in
VulnerabilityScore(#57) —@preg_match()with nopreg_last_error()inspection let a pathological config regex hang the request thread. NewsafeRegexMatch()validates the delimiter, drops the@, and bails out on any PCRE limit failure — matching the protection theUrlandUserAgentpaths already had.
Medium and low (#66):
- Trusted proxies not surfaced (#58) — see Upgrade notes.
- Unserialized read-modify-write on file storage (#59) — concurrent writers could drop a block-list entry (a fast burst avoided being added to the offender list) and race rate-limit counters past the threshold at exactly the load where the limit should bite. Writes now run under
flockon a sidecar.lockfile, which survives the inode replacementfile_put_contents()performs. If the filesystem can't lock, the write proceeds and a warning is logged rather than hanging the firewall. - Predictable request IDs (#60) —
md5(clientIp . time())was brute-forceable by anyone sharing the client's proxy IP, and the ID is both surfaced to blocked clients via{{request.id}}and the join key for downstream logs, enabling log-stitching. Nowbin2hex(random_bytes(16)), preserving the 32-char uppercase-hex shape. - Identifier quoting in
DatabaseStorage::addToExpire()(#61) — a hand-concatenated table alias bypassed DBAL's quoting, producing malformed SQL for any table name needing quotes (reserved words,db.table, hyphens). The alias is gone; the table name flows through DBAL. serialize()in theDatabaseStoragerequestcolumn (#62) — replaced withjson_encode(), removing a latent Object Injection sink for the next caller who reaches forunserialize().- Unvalidated CIDR prefix length (#63) —
10.0.0.0/300produced nonsense byte/bit math that could degenerate into match-everything or match-nothing. Prefixes are now validated as digits and range-checked per address family (0–32 / 0–128), with rejections logged atwarning. - Secrets and CRLF in logs (#64) — a rule matching
header.cookiewrote session IDs and CSRF tokens verbatim into the debug log.LoggingFactorygains a redaction allowlist defaulting to the well-known credential headers pluscookie.*(wildcards supported), andLoggingTrait::sanitizeContext()recursively strips CR/LF from every context value so log-injection can't fake structured fields.
Fixes
- Log mode is now exempt from durable-blocklist enforcement (#88, fixes #44).
mode: logis documented as a dry run, but the repeat-offender check ran with no mode check — for any client already on the blocklist, an audit-only deployment still recorded an offense, extended the ban, and hard-blocked the request, makinglogindistinguishable fromblockfor every repeat offender. - The storage blocklist now applies to challenge submissions (#87, fixes #75). A POST to the challenge path short-circuited all plugin evaluation, so a blocklisted IP could still submit a valid solution and mint a fresh pass token.
- Documented config overrides now actually land (#84, fixes #82).
config/config.ymlshipsglobal:,storage:,logger:,bypass:andblock:as NULL, and PropertyAccess cannot traverse into NULL — so 4 of the 5 README override examples were silent no-ops, including the flagship[storage][config][file]and the reported "inject your own logger" case.Config::load()now opens NULL nodes along an override path before writing. A newReadmeOverrideExamplesTestguards against the docs drifting again; 5 of its 10 cases fail without the fix. - Corrected
Firewall@throwsdocblocks (#85, fixes #79).create()advertised aFirewallBlockedExceptionit never throws and described a failure mode that doesn't occur;evaluate()omitted three exceptions it can raise and claimed aFALSEreturn it never produces. Three regression tests pin the documented behavior. - Fixed the README's syslog example, which passed the literal string
LOG_USERtoAbstractSyslogHandler— YAML can't resolve PHP constants, and the handler only accepts facility names, so the documented config threwUnexpectedValueException. (#68) - PHPStan level max is green again — 6
function.alreadyNarrowedTypeerrors had been failing CI on every PHP version since 2026-05-19. (#83, fixes #80) - Removed several sources of CI flake:
ext-redisintegration tests are gated onextension_loaded()rather than erroring the suite, storage perf thresholds are sized for slow runners, fiveConfigTestcases no longer need fragileRunInSeparateProcessforking, and a token-tampering test is deterministic regardless of the random signature it mints.
Docs
- README rewritten around the
plugins:array, with the Configuration Overview table, Plugin Architecture, and Dynamic Overrides sections updated and legacy formats collected into one deprecated section at the end. New sections cover the challenge response type, "Fail open or fail closed?", requiring the config to load, and a trusted-proxies warning placed ahead of the first snippet users copy. - All production and example preset YAMLs migrated to the
plugins:format;presets/README.mdandpresets/RATE-LIMITING-REFERENCE.mdupdated. - Nine new logging handler recipes, each verified by
ReadmeLoggingExamplesTest, plus a note on why wrapper handlers likeFingersCrossedmust be wired up programmatically. CONTRIBUTING.md,example/README.md, andexample/config.notes.ymlexpanded; docblocks filled in across the challenge classes, exceptions, andFileRateLimitStorage.
Full Changelog: v2.7.0...v2.8.0