Repository navigation
Basic Firewall 1.0.0-rc.1
Pre-release
Pre-release
·
27 commits
to main
since this release
Release candidate for 1.0.0. Published as a GitHub pre-release, so the
releases/latest/download URL does not serve it; install it by its own URL.
Please try it on a staging site and report anything that surprises you.
First release. The WordPress port of the Drupal module basic_firewall, at
parity with its 2.0.0-beta9, built on kanopi/firewall ^2.33.1. The release zip
bundles 2.33.1, namespace-scoped. Requires WordPress 6.4 and PHP 8.1.
It installs in log-only mode with no rules, so nothing is refused until you say
so. The README explains every feature below, and — as the module does — what
does not work as well as what does.
Added
- Nine rule types: IP address, Request / URL, user agent, rate limit, edge
signal, ASN, geolocation, IP reputation (AbuseIPDB) and the OWASP Core Rule
Set. Each has its own form, validation, compilation and
library capability detection, so a type the installed library cannot run is
not offered rather than compiled into something that matches nothing. Other
plugins can add types through thebasic_firewall_rule_typesfilter.- An IP address rule can reference published lists by URL instead of carrying
copies, refreshed on WP-Cron and fetched off the request path. - A user agent rule can verify a crawler by reverse and forward DNS, so an
allow rule forbot equals trueis not a skeleton key for anyone who types
Googlebot/2.1. Fails closed. - A rate limit line can count an account, header or field instead of the
address. That catches a botnet against one account, misses one client
walking a list of usernames, and never bans — so the screen and Site Health
warn when one has no address-keyed limit beside it. - An ASN rule's referenced list of numbers matches with is equal to, is
one of and is not equal to, each entry compared as a whole number. An
entry has to be digits alone to be used; one writtenAS16509is skipped,
and the rule screen says so. - A geolocation or ASN rule checks the type of the MaxMind database it is
given — City for geolocation, GeoLite2-ASN for ASN — and a database of the
wrong kind, which the reader refuses on every lookup, is reported on the
rule, on the Status screen and in Site Health. - The edge signal type matches what the CDN worked out that the site cannot:
a JA3 or JA4 TLS fingerprint, or the bot score, from Cloudflare, Fastly or a
custom CDN. Believed only behind a trusted proxy. - A regular expression condition is written as the pattern alone; the
delimiters and the case flag come from the form. A pattern between slashes,
such as/wp-admin/, is a path and is kept exactly as written through
every save. - A document written straight into the option — WP-CLI, a deploy, a restore
— is compiled as each rule type's validator reads it, so settings in the
shape a person types them compile as the rule screen would store them. A
rule that could only be read by dropping part of it is skipped and named on
the Status screen and in Site Health. - Every setting of every type, and every site-wide setting, is proved in CI
against the library that enforces it: the compiled file is loaded, and the
library's own objects are asked what they ended up with, or a request is
evaluated. A setting with no such test fails the build.
- An IP address rule can reference published lists by URL instead of carrying
- Six responses, evaluated in this order: allow, mark, record, challenge,
redirect, block. Mark and record do not end evaluation. Whether a match is
written to the durable block list is a separate, per-rule choice, which is
what a honeypot needs (record without refusing) and what a lockdown needs
(refuse without recording). - Observe only, per rule, so one rule can be tried on live traffic while the
rest keep enforcing. Matches are logged and counted apart on the Log screen. - Activity windows, per rule, in the rule's own timezone. The rule list says
whether a scheduled rule is awake and until when, because a sleeping rule
looks exactly like a broken one from outside. - Two evaluation paths. An mu-plugin, installed on activation and removed on
deactivation, runs atmuplugins_loadedwith no configuration. The installed
copy is replaced when a release ships a different one — on update, on the
first admin, cron or WP-CLI request after the loader's version changes, and
from Site Health — atomically, and never recreated once removed. An optional
wp-config.phpbootstrap runs beforeadvanced-cache.php, so a page cache
hit is evaluated too. The bootstrap calls no WordPress API; database block
storage works there when the snippet sits below theDB_constants. In
exceptionmode the bootstrap answers blocks, lockdowns, redirects and
challenges itself, with the plugin's own responder, rather than leaving them
for the mu-plugin — which runs after a page cache has served the page. - Fifteen admin screens: fourteen under the Firewall menu — Status,
General, Storage, Rules, Logging, Challenge, Presets, Advanced, Log, Blocked,
Compiled, Export, Import and Test — and the rule editor, reached from Rules,
with its type chooser. The Test screen evaluates a made-up request against the
live rules without recording anything: no block, no offense, no log line,
and no rate limit counter — every rate limit, presets' included, counts in
memory for the run. - Block list storage in files, the database or Redis. Redis stores each
block with a TTL, so expiry costs nothing. Database tables carry the site's
table prefix, so sites on a network do not share a block list. - A cache backend for parsed user agents and reverse-DNS verdicts: files,
the WordPress object cache (only where it is persistent) or APCu. The parsed
configuration and imported list bodies move withBASIC_FIREWALL_CACHE_DIR,
which both evaluation paths read. The 1.7 MB agent detection corpus is built
on WP-Cron after every rebuild, and on demand from the Storage screen or
WP-CLI, so a deploy does not leave the first visitor to pay for it. - Logging through Monolog, because it runs before WordPress's logger
exists: a file, rotating files, the PHP error log or a database table, which
the Log screen reads back. Any handler can send after the visitor has their
response. Names added under Additional variables to redact are redacted
on both paths as well as the library's own set. - Challenges: arithmetic, ALTCHA proof of work, Cloudflare Turnstile and
Google reCAPTCHA. - Presets from the library, included by reference so they update with it.
The library'swordpresspreset is withheld, because on a WordPress site it
locks every administrator out. - Incident tools. Lockdown refuses every client but an allowlist (addresses,
CIDR blocks orstart-endranges), before any rule is consulted, and records none of them — so lifting it does not leave a
block list full of customers. A panic file changes the operating mode on the
next request, on both paths, without a deploy.BASIC_FIREWALL_ENABLEDand
BASIC_FIREWALL_MODEinwp-config.phpneed no database at all. Unticking
Enable the firewall stops both paths as well: it is compiled as
mode: disabledand mirrored into aruntime.jsonsidecar the
wp-config.phppath reads, so a pinned mode cannot switch it back on. - Fifteen WP-CLI subcommands under
wp basic-firewall:status,rules,
rebuild,sources,refresh-sources,check,block,unblock,
blocked,clear-blocked,clear-cache,warm-cache,find-reference,
exportandimport. - Seventeen Site Health tests: lockdown, panic switch, library, backends,
compiled configuration, crawler verification, rate limit keys, private
directory, wp-config.php snippet, evaluation point, client IP, operating mode,
block list storage, cache, logging, block records and upgrades. Anything
critical is also raised as an admin notice. The evaluation point test is
critical when thewp-config.phppath is inexceptionmode and cannot
answer refusals before a page cache. - Hooks. Every decision is announced as
basic_firewall_decision, with its
kind, and asbasic_firewall_decision_{$type}—allowed,blocked,
challenged,challenge_solved,challenge_failed,recorded,
redirected,marked,tarpitted. They are held untilplugins_loadedso a
regular plugin hears them; a refusal is announced at shutdown, and a refusal
on thewp-config.phppath is never announced, because WordPress never loads.
Alsobasic_firewall_request_markedandRunner::is_marked(),
basic_firewall_request_ended,basic_firewall_compiled,
basic_firewall_settings_saved,basic_firewall_activatedand
basic_firewall_upgraded; and the filtersbasic_firewall_rule_types,
basic_firewall_presets,basic_firewall_private_path,
basic_firewall_trusted_proxiesandbasic_firewall_config_overrides. - Export and import, of the whole configuration or one rule, from the admin
screens or WP-CLI, with a preview or--dry-run. Credentials are stripped and
the document says which;%env()%tokens survive; an empty credential on
import means "not carried", never "set to nothing". - Three capabilities — manage, view reports, unblock — so support staff can
read the log and release a client without being able to change the rules. - Uninstall removes what the plugin wrote, on every site of a network: its
options, tables, scheduled events and capabilities; the private directory
wherever it resolves; the block list, offense, counter and log files at every
path the settings name; the file cache pools and what the library wrote into
BASIC_FIREWALL_CACHE_DIR; the Redis block list and counters, bySCANand
DELunder their prefix; the object cache group and APCu entries. A stored
path can point anywhere, so it deletes only inside a directory the plugin
created or guards, never follows a symlink, and has WP-CLI print a warning
for everything it leaves. - Schema upgrades that are numbered, idempotent and advance one routine at a
time, so an upgrade interrupted by a timeout resumes rather than corrupting
settings. A failure is reported in Site Health, never fatal. - Exempting a role. Members of roles ticked on the General screen are not
evaluated. A request carrying a WordPress login cookie is evaluated at
plugins_loadedinstead, on both paths, once the cookie can be validated; a
forged one is evaluated like any other request. Off by default, and while it
is off nothing about either path changes. - Multisite, per site. Each site of a network has its own settings,
compiled file, block list, counters and logs, and is evaluated from the
mu-plugin against its own rules. Thewp-config.phppath steps aside on a
network, since it runs before the site is known; Site Health says to remove
a snippet left in. - A release zip that vendors the library namespace-scoped, so it cannot
collide with another plugin bundlingkanopi/firewall, and needs no Composer
on the server. Both evaluation paths run the scoped copy even when another
copy of the library was loaded first. The build proves the scoped classes
resolve, and boots the zip beside an unscoped copy, before zipping, and CI installs the zip on a clean WordPress and makes it refuse a request
before publishing it to GitHub Releases on a version tag. It installs with
one WP-CLI command fromreleases/latest/download/basic-firewall.zip, a URL
that always means the current release. Composer installation works too,
unscoped.
Removed
- The vulnerability score rule type, before release. It saved a threshold
and weights that the library'sVulnerabilityScoreplugin never reads — it
scores with its ownscoring.*signals and matches onrisk_levels— so the
rule matched nothing. It is withdrawn until it is rebuilt on that model. A
rule saved by a pre-release build is kept, skipped, and named on the Status
screen and in Site Health.
Security
- The private directory is created with
.htaccess,web.configand
index.phpguards, given a random per-site name, and then probed over HTTP
rather than assumed protected. On nginx the first two guards are inert, which
Site Health reports as an error and gives the server snippet for. - WordPress's database credentials are never written into the compiled
configuration. They are injected at request time, so an export cannot leak
them, no plaintext password reaches disk, and a rotated password takes effect
on the next request. - Only the library's
filesecret processor is ever enabled, neverrequire,
which would turn an environment-variable injection into remote code execution.
%file()%is off untilBASIC_FIREWALL_SECRET_DIRECTORIESnames where it may
read. - A trusted proxy may set
X-Forwarded-For,-Protoand-Port, but not
-Host, because a forwarded host is how cache poisoning and malicious
password-reset links start. - Destructive WP-CLI commands refuse to run without
--yesand exit non-zero
when they refuse, rather than exiting 0 having done nothing. - An export strips a referenced list's credentials on every rule type that takes
a list —upstream.authand everyupstream.headersentry — and replaces a
credential in a list URL with***, naming each path in its header. - An import keeps a stored credential the document left out only while the
host, port, account or URL it belongs with is unchanged; otherwise it is
blanked and the preview says so. Credentials follow their rule by identifier. - A rule's credentials are never rendered into its edit form: a rate limit
rule's Redis password and DSN, a geolocation or ASN rule's MaxMind license
key, an AbuseIPDB rule's API key, and any setting a contributed rule type
declares secret. Blank keeps the stored value and a box removes it. A
referenced list's Advanced box shows itsupstream.authcredential and each
header value as[redacted], and a credential in the list's URL as***;
a save keeps each while the list's URL is unchanged. Limits
and counter storage have their own controls, so saving the form unchanged
stores what was there. - The Storage screen and each database log handler on the Logging screen offer
every way a connection can be given — WordPress's credentials, a DSN or
individual parameters, and for storage a preset — and render the DSN and
password write-only in the same way. Saving either screen unchanged keeps the
connection as stored. - The Compiled screen shows every credential in the compiled file as
[redacted]; the file itself keeps them for the library. - A user agent rule that asks to verify crawlers but is left with no domain to
accept — an import or WP-CLI drops*.googlebot.comand keeps the verify flag
— is skipped when the configuration is compiled and reported on the Status
screen and in Site Health, never compiled as a plain agent match that lets
anyone sendingGooglebot/2.1past an allow rule. - An
X-Firewall-Markheader sent by the client is removed before evaluation on
both paths, so$_SERVER['HTTP_X_FIREWALL_MARK']is only ever a mark the
firewall applied. - After a solved challenge in
exceptionmode the visitor is sent only to a
path on this site, rebuilt from the posted destination's path and query. A
destination holding a control character or whitespace, raw or
percent-encoded, or that a browser would read as//once its backslashes
are slashes, is replaced with/, so/<tab>/evil.examplecannot become
//evil.example.
Upgrading from a pre-release build
Only for a site that ran a development copy before 1.0.0. A new install can skip
this.
- Stored settings are rewritten on the first request after the update, and
the configuration is recompiled. The routines:- Paths lose Drupal's
private://prefix. A relative path now resolves
inside the private directory; the old spelling still resolves anywhere the
upgrade did not reach. - A regular expression is stored as its body rather than
#body#i, and the
iflag becomes the condition's case-sensitivity box, so no rule narrows
or changes case — upgrading from the earliest builds included. - Geolocation and ASN condition variables take the library's names:
country_name,timezone,latitude,longitudeandorganization
becomecountry.name,location.timeZone,location.latitude,
location.longitudeandasn_org. Before this those conditions matched
nothing. A condition on the ASNnetwork, which has no library equivalent,
is kept rather than dropped — dropping it would widen an "all" rule — and
reported on the rule and the Status screen. - Request / URL conditions on
refererandcontent_typebecome
header.refererandheader.content-type, which is how the library reads
them; before this a negated one matched every request. Conditions onuri,
bodyandserver.*, which the library cannot read at all, are kept and
reported on the rule, the Status screen and in Site Health. storage.record_request, new in library 2.31.0, is filled in.
- Paths lose Drupal's
- Translated at compile time rather than rewritten, so stored rules work
without being edited: the operatorsgt,gte,lt,lteand
not_contains, which the library does not know and which matched nothing;
the renamed condition variables above, for a document that reaches the
compiler without the upgrade; aportcompared with is equal to, is not
equal to or is one of, which is compiled as a number;
and the geolocation and ASN reader settings, which compiled to keys the
library never read. So did these, each now compiled to the key the library
reads:- Challenge provider options, which went nowhere. Turnstile or reCAPTCHA with
any challenge rule stopped the firewall starting, and the plugin failed
open. "Let the visitor through" if verification is unreachable is now
honoured, and a timeout above 10 seconds is written as the 10 the provider
allows. - The Core Rule Set's inbound and outbound thresholds, which were always 5
and 4. - A user agent rule's choice of what
botconsults, which was always the
curated database. - AbuseIPDB's report age, which was always 30 days whatever the screen said.
- A database-backed rate limit's table, which was always the library's
unprefixedfirewall_rate_limit_storage. - An IPv6
start-endrange on an IP address rule, which matched nobody. It
is compiled as the CIDR blocks covering the same addresses. - A referenced list declared as XML, which stopped its rule constructing. XML
is no longer offered; the format is detected from a.xmlURL instead. - A database log handler connecting with individual parameters, which was
compiled with no connection.
- Challenge provider options, which went nowhere. Turnstile or reCAPTCHA with
- Check by hand:
- A database-backed rate limit counted into
firewall_rate_limit_storage,
without the site's table prefix. It now counts into
{prefix}basic_firewall_ratelimit, so counters start again. Drop the old
table once nothing else using kanopi/firewall on that database needs it;
uninstall names it but will not drop it. - A rate limit's status code field is gone. The library always answers 429,
whatever it said. - Seconds added when a blocked client returns can no longer be 0, which
the library read as 3600. A stored 0 is reported until it is changed. - A user agent rule saved from the rule screen in a pre-release build has
Cache agent detection switched off, because the screen never posted it.
Tick it and save, or each PHP worker pays about 618 ms on its first request. - A geolocation or ASN rule saved from its own screen may have lost its reader:
a CDN-backed rule could have been put back on a database, and a custom header
mapping could have lost its field names.
- A database-backed rate limit counted into
- The mu-plugin loader refreshes itself. A pre-release copy states no
version, so the first admin, cron or WP-CLI request after the update replaces
it with 1.0.0's, as does updating through the WordPress updater or opening
Site Health. No deactivating and reactivating. Until then the older copy
still works; on thewp-config.phppath it answers a solved challenge at
plugins_loadedrather thanmuplugins_loaded. If mu-plugins is not writable,
Site Health says the loader is out of date and why; copy
mu-plugin/basic-firewall-loader.phpover it by hand. - Uninstall only removes directories carrying
.basic-firewall-owner,
which is written only by 1.0.0. A directory a pre-release build created
outside uploads — a filtered private path, orBASIC_FIREWALL_CACHE_DIR— is
emptied of the plugin's files and left in place. - A Composer install needs
kanopi/firewall^2.33.1.