Skip to content

v2.8.0

Choose a tag to compare

@sean-e-dietrich sean-e-dietrich released this 26 Jul 17:30
2ec9db8

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: challenge plugin 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 a Secure; HttpOnly; SameSite=Strict cookie and as a header value the interstitial JS stores in localStorage for SPA callers. Later requests carrying a valid token skip the challenge bucket; block plugins still apply. (#73, fixes #39)
  • Two built-in challenge providersmath (stateless "What is A + B?", HMAC-signed state) and altcha (proof-of-work via the ALTCHA widget, pinned to altcha@2.3.0 with a SHA-384 SRI digest, source overridable via challenge.provider_options). Custom providers plug in through ChallengeProviderInterface. (#73, #76, fixes #39, #72)
  • OWASP Core Rule Set plugin — new Crs plugin backed by kanopi/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 error level, and global.require_config: true turns them into a startup ConfigurationException. (#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 with response: / weight:. Legacy bypass: / 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. Existing FileStorage and FileRateLimitStorage data 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 to DatabaseStorage's request column, 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.
  • Asn and GeoLocation now 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 at error. Plugins with no metadata.reader key at all remain intentionally inactive, so opt-out users are unaffected. Set metadata.fail_open: true to restore the old allow-on-failure behavior.
  • New startup warning if trusted proxies are unset. Every plugin evaluates $request->getClientIp(), which honors X-Forwarded-For only after the integrator calls Request::setTrustedProxies(...). Deployments behind a load balancer without that call now get a clear warning naming the missing call; global.require_trusted_proxies: true escalates it to a startup ConfigurationException rather than starting in a spoofable state.
  • Default file-storage paths moved. FileStorage and FileRateLimitStorage no longer default to predictable /tmp/*.data paths; defaults now sit in a per-install 0700 subdirectory of sys_get_temp_dir(), and all storage files are forced to 0600. 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 / _TIMEOUT only fill in arguments passed as null. The single production caller passes no arguments, so behavior through Config::loadFile() is unchanged — this only affects code calling the method directly.
  • Pass token aud claim (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 into plugins: entries at load time. No action required; new configs should use the plugins: 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 name
  • ChallengeProviderInterface — extension point (renderInterstitial(), verifySolution(), getName()), with REDIRECT_FIELD / TTL_FIELD promoted 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 via json_encode with the HEX flags, so a hostile redirect_to cannot 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. MathChallengeProvider deliberately does not: with nine possible answers, distinct visitors legitimately share a state value.
  • Pass tokens are scoped by an aud claim inside the signed payload, so two Firewall instances sharing a challenge.secret no longer accept each other's tokens — the weakest challenge in a deployment can't set the effective security of every route.
  • ChallengeRequiredException / ChallengeSolvedException parallel FirewallBlockedException for FirewallMode::Exception integrations.
  • 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_config plus a KANOPI_FIREWALL_REQUIRE_CONFIG constant 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_proxies refuses 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 the EvaluateTrait hook are now declared with no-op defaults instead of being probed with method_exists() — an implicit convention is now a documented extension point. (#83, fixes #80)
  • ext-redis is declared under suggest; 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() ran unserialize() on the storage file. With predictable /tmp defaults, 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 send Content-Type: text/plain; charset=utf-8 with X-Content-Type-Options: nosniff.
  • Predictable storage paths and world-readable files (#53) — defaults moved into a per-install 0700 directory off sys_get_temp_dir(); files are created 0600 and existing group/other bits are stripped best-effort.
  • Arbitrary class instantiation in LoggingFactory (#54) — handler and formatter class names from config were passed to new with attacker-influenceable constructor args behind a tautological guard. Both branches now require is_a($class, HandlerInterface::class, true) / FormatterInterface before 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, with metadata.fail_open: true to opt back out.
  • ReDoS in VulnerabilityScore (#57)@preg_match() with no preg_last_error() inspection let a pathological config regex hang the request thread. New safeRegexMatch() validates the delimiter, drops the @, and bails out on any PCRE limit failure — matching the protection the Url and UserAgent paths 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 flock on a sidecar .lock file, which survives the inode replacement file_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. Now bin2hex(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 the DatabaseStorage request column (#62) — replaced with json_encode(), removing a latent Object Injection sink for the next caller who reaches for unserialize().
  • Unvalidated CIDR prefix length (#63)10.0.0.0/300 produced 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 at warning.
  • Secrets and CRLF in logs (#64) — a rule matching header.cookie wrote session IDs and CSRF tokens verbatim into the debug log. LoggingFactory gains a redaction allowlist defaulting to the well-known credential headers plus cookie.* (wildcards supported), and LoggingTrait::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: log is 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, making log indistinguishable from block for 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.yml ships global:, storage:, logger:, bypass: and block: 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 new ReadmeOverrideExamplesTest guards against the docs drifting again; 5 of its 10 cases fail without the fix.
  • Corrected Firewall @throws docblocks (#85, fixes #79). create() advertised a FirewallBlockedException it never throws and described a failure mode that doesn't occur; evaluate() omitted three exceptions it can raise and claimed a FALSE return it never produces. Three regression tests pin the documented behavior.
  • Fixed the README's syslog example, which passed the literal string LOG_USER to AbstractSyslogHandler — YAML can't resolve PHP constants, and the handler only accepts facility names, so the documented config threw UnexpectedValueException. (#68)
  • PHPStan level max is green again — 6 function.alreadyNarrowedType errors had been failing CI on every PHP version since 2026-05-19. (#83, fixes #80)
  • Removed several sources of CI flake: ext-redis integration tests are gated on extension_loaded() rather than erroring the suite, storage perf thresholds are sized for slow runners, five ConfigTest cases no longer need fragile RunInSeparateProcess forking, 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.md and presets/RATE-LIMITING-REFERENCE.md updated.
  • Nine new logging handler recipes, each verified by ReadmeLoggingExamplesTest, plus a note on why wrapper handlers like FingersCrossed must be wired up programmatically.
  • CONTRIBUTING.md, example/README.md, and example/config.notes.yml expanded; docblocks filled in across the challenge classes, exceptions, and FileRateLimitStorage.

Full Changelog: v2.7.0...v2.8.0