LW Firewall rate-limit worker compatibility
SkillMonitoring & opsBuild or audit companion endpoints against LW Firewall's early MU-plugin worker, URI classification, shared and route-local rate limits, logged-in buckets, IP/geo rules, automatic bans, backend consistency, configuration overrides, logging load, and worker lifecycle. Use when code exposes REST, AJAX, login, cron, XML-RPC, WooCommerce filter, webhook, or custom endpoints or references `protect_rest_api`, `filter_params`, `RateLimiter`, `AutoBanner`, `IpDetector`, `lw_firewall_resolve_storage`, `LW_FIREWALL_*`, worker installation, 429 responses, whitelists, blacklists, geo blocking, storage, or ban enforcement.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the LW Firewall rate-limit worker compatibility skill
What this skill tells your AI
The instructions your AI receives, as published by lonsdale201/wp-agent-skills in lw-plugins/lw-firewall-rate-limit-worker/SKILL.md and read by ahel’s review.
Use this skill when an endpoint may be blocked by LW Firewall or needs a
dedicated limit. The copied MU worker runs on muplugins_loaded priority 1,
before normal plugin callbacks.
Worker decision order
After bootstrap/version checks, the request path is:
LW_FIREWALL_DISABLE_WORKERand masterenabledexits.- Worker heartbeat transient (
lw_firewall_worker_alive), written before any decision so the Status tab can tell "installed" from "actually running". - Server/localhost IP exemption.
ip_whitelistexemption.ip_blacklist403.- Geo blocking.
- Storage resolution (memoized per request).
- Shared-ban lookup — unconditional whenever the firewall is on since 1.5.5.
- 404-flood lookup.
- User-Agent bot blocking.
- Endpoint detection.
- Per-IP counter, 429/redirect, and optional violation escalation.
Whitelisting bypasses every later worker check. A blacklist or geo block runs before endpoint-specific code.
The server-IP exemption covers 127.0.0.1, ::1 and SERVER_ADDR only. Since
1.5.6 the site's own hostname is deliberately not resolved into an
exemption: SERVER_NAME comes from the client's Host header under Apache's
default UseCanonicalName Off, so resolving it was a full bypass for any
address an attacker could point a hostname at.
Exact endpoint detection
Since 1.5.6 classification runs on the decoded path, never the raw URI.
lw_firewall_parse_uri() splits path from query, rawurldecode()s the path,
collapses repeated slashes and strips a trailing one; lw_firewall_path_is()
then compares with str_ends_with so a subdirectory install's
/blog/wp-login.php still matches.
| Reason | v1.5.6 condition |
|---|---|
cron | decoded path is/ends with /wp-cron.php, protect_cron; exempt only when doing_wp_cron is a query arg on that path |
xmlrpc | decoded path is/ends with /xmlrpc.php, protect_xmlrpc |
login | decoded path is/ends with /wp-login.php, protect_login |
rest | decoded path contains /wp-json/ (bare /wp-json included) or a rest_route query arg is present, protect_rest_api |
filter | raw query string contains a configured filter_params substring |
This closes the 1.5.4 substring hole: /wp-json/x?next=/wp-cron.php&doing_wp_cron=1
no longer escapes rate limiting, and ?redirect=/wp-login.php is no longer
billed to the login quota. Both REST shapes are now covered — the pretty
/wp-json/ prefix, the bare /wp-json index, and ?rest_route=/namespace/path.
The cron loopback marker is still not authenticated — it is only a path-scoped
doing_wp_cron presence check. It can no longer be used to skip classification
on another endpoint, but do not treat it as proof that WordPress originated the
request.
The worker still does not detect arbitrary pretty URLs, normal admin-ajax.php
actions, or route/method combinations. A global REST toggle is coarse
protection for a shared bucket, not registration blocking or route
authorization.
Logged-in REST/filter bucket
On rest and filter only, a cookie whose name starts with
wordpress_logged_in_ and whose value has the expected four-part shape moves
the request into <reason>_li_<ip> with a default 10× allowance. The worker
cannot authenticate the cookie this early, so this is deliberate headroom, not
a trusted-user exemption. Override the multiplier with the integer constant
LW_FIREWALL_LOGGEDIN_MULTIPLIER.
Application Password, bearer-token, and other stateless REST authentication do not normally carry that cookie and therefore use the anonymous bucket. Login, XML-RPC, and cron never receive the higher allowance.
Any client can forge the expected cookie shape; the worker does not validate its HMAC. That cannot remove the limit, but it gives an anonymous REST/filter request the separate default 10× bucket. Do not use the worker bucket as the only limit for an expensive public route.
Query parameter filters
filter_params uses case-sensitive substring matching against the raw query
string. Entries may carry a per-match limit:
filter_|30
query_type_|30
add-to-cart|10
my_expensive_filter|5
When several entries match, the lowest explicit limit wins. Avoid broad pieces
such as s, id, or page; substrings are not parsed parameter names.
Route-local protection
Use a unique key when the worker cannot identify the endpoint narrowly:
use LightweightPlugins\Firewall\IpDetector;
use LightweightPlugins\Firewall\Options;
use LightweightPlugins\Firewall\Rules\AutoBanner;
use LightweightPlugins\Firewall\Rules\RateLimiter;
if (function_exists('lw_firewall_resolve_storage')
&& class_exists(RateLimiter::class)
) {
$ip = IpDetector::get_ip();
$storage = lw_firewall_resolve_storage((string) Options::get('storage', 'auto'));
$allowed = (new RateLimiter($storage))->is_allowed_key(
'myplugin_public_signup_' . $ip,
5
);
if (!$allowed) {
if ((bool) Options::get('auto_ban_enabled', false)) {
(new AutoBanner($storage))->record_violation($ip);
}
RateLimiter::too_many();
}
}
Run this before expensive database or remote work. Do not reuse worker keys
such as rest_<ip>, filter_<ip>, or their _li_ forms. too_many() prints a
plain 429 response and exits; return a WP_Error instead when the endpoint
must preserve a JSON error contract.
IP identity and lists
Use IpDetector::get_ip() so companion counters agree with the worker.
CF-Connecting-IP is trusted only when REMOTE_ADDR belongs to a known
Cloudflare network. Never independently trust X-Forwarded-For.
Since 1.5.6 that same trust test also gates geo: GeoDetector calls
IpDetector::is_cloudflare_request() before reading CF-IPCountry, and the
value must be exactly two letters (XX/T1 fall through to the CIDR index).
The 1.5.4 hole — any non-empty country header believed straight at the origin —
is closed. Geo is still a signal, not authorization.
Reverse proxies (new in 1.5.6, opt-in). Behind the common "nginx in front
of Apache on the same host" layout every request arrived as 127.0.0.1, which
the server-IP exemption treated as the server itself: a silent, total bypass
while the Status tab reported health. Configure trusted proxies under
IP Rules → Reverse Proxy (trusted_proxies, proxy_header). ProxyTrust
reads the forwarded chain right to left, skipping hops that are themselves
trusted, and only after REMOTE_ADDR matches a configured proxy; the header
name is restricted to x-forwarded-for / x-real-ip / forwarded. It stays
opt-in because a forwarded header is client-controlled until the hop that set
it is known. If a companion computes its own client IP, it must honour the same
configuration or its counters will disagree with the worker's.
Manual whitelist/blacklist values support individual IPv4/IPv6 addresses and CIDR ranges. Whitelist payment/webhook providers only when their published source ranges are stable and verified; whitelisting bypasses bot, geo, rate, 404, and shared-ban checks too.
Ban enforcement (fixed in 1.5.5)
Both 1.5.4 ban defects are gone:
- The worker now reads
ban_<ip>unconditionally whenever the firewall is on. Gating it onauto_ban_enabled/login_limit_enabledhad left every other producer — registration spam, password-reset floods, a manual CLI or admin ban — writing a key nothing ever read, so the admin screen listed an address as banned while it browsed the site freely. AutoBanner::unban()now also clears the worker's per-endpoint counters, so a released address is actually released instead of staying 429 untilrate_windowaged out.- Ban durations are clamped: a zero duration used to mean "no TTL" to every backend, i.e. an accidentally permanent ban.
Still verify a real follow-up request rather than an index row — the storage key remains the sole authority on whether an IP is blocked.
Configuration and backend boundaries
Since 1.5.5 wp-config.php constants reach the runtime. Options::get_all()
layers LW_FIREWALL_<KEY> constants over the stored values, so the worker,
Plugin::init_runtime_hooks(), the .htaccess sync and the status screen all
see the pinned configuration — including the master enabled switch. The new
Options::get_stored() is the editing/persistence view with no constant
overlay, so saving never writes a pinned value into the database, and
Options::overridden() lists the keys a constant currently pins (the settings
screen labels those fields). LW_FIREWALL_DISABLE_WORKER is still checked
directly and remains a real worker kill switch.
The 1.5.4 storage-semantics divergences are fixed:
- The file backend counts in a fixed window like Redis and APCu.
Re-stamping expiry on every increment made it a sliding window, so the same
traffic banned on one backend and never banned on another.
increment()is an atomic read-modify-write under an exclusive lock. - APCu uses
apcu_add()for the first hit, so concurrent first requests can no longer overwrite one another and undercount the start of a burst. - Expired cache files are swept probabilistically with a batch cap
(
CacheDirectory::sweep()), instead of only when the identical hashed key was read again. - Guard files are written unconditionally and cover the geo sub-directory
(
CacheDirectory::protect()). - Keys are namespaced per installation:
StorageDetector::key_prefix()returnslw_fw_<md5(ABSPATH) first 8>_. Two installs sharing one APCu/Redis pool no longer collide. Note the seed isABSPATH, so a multisite network shares one prefix — buckets are per network, not per subsite. - The CIDR cache is written to a temp file and renamed, so a reader cannot see a half-written include and fail open.
Remaining backend caveat: file storage is node-local unless the directory is truly shared. It cannot enforce one cluster-wide quota by itself.
For a security-sensitive companion, verify atomicity, TTL semantics, backend health, installation isolation and multi-node behavior in the deployment. Do not advertise backend-independent quotas until those checks pass.
lw_firewall_resolve_storage() is memoized per request since 1.5.6 (a static
map keyed by preference, delegating to lw_firewall_build_storage()), so
repeated calls no longer re-run the availability probes or open a second Redis
connection. A companion may call it freely; still never add its own probe loop
on every page view.
When log_enabled is on, Logger collapses repeated IP/reason pairs for five
minutes (DEDUPE_WINDOW = 300) into one counted entry, so a flood no longer
rewrites the 100-row lw_firewall_log option on every blocked request. The
write amplification is bounded, not eliminated: distinct IPs still each write.
High-volume sites should still prefer an external append-oriented sink.
Worker lifecycle
- Activation copies
worker/lw-firewall-worker.phpto the MU-plugin directory. - Upgrade hooks replace it; deactivation removes it.
- Version drift makes the worker bail and the main plugin attempts one repair.
- Since 1.5.6
Activator::is_worker_outdated()also compares content, not only the version constant: an installed copy older thanworker/lw-firewall-worker.phpbyfilemtimeis replaced. A worker edited without a version bump used to leave the stale copy running against new plugin classes, which can fatal the site on a duplicate declaration. - The copied worker still resolves classes from the literal
WP_PLUGIN_DIR . '/lw-firewall/'directory, so renaming the plugin directory makes it return before registering. Since 1.5.6 that is visible: the worker writes alw_firewall_worker_alivetransient before any decision, and the Status tab reports a worker that is installed but has never run (Activator::worker_last_seen()). The version constant is defined before the worker proves it can load anything, so it alone never was evidence. wp lw-firewall worker install|removedrives the lifecycle from the CLI.- If the worker remains missing/outdated, the plugin does not register its normal runtime hooks, including registration, reset, 404 tracking, and security headers. Administrator monitoring is initialized separately.
LW_FIREWALL_DISABLE_WORKERneutralizes the worker but does not by itself remove the file.
Never edit the installed copy: lifecycle operations overwrite it.
Response and release checklist
- Test anonymous and cookie-bearing REST/filter requests separately.
- Test
/wp-json/, bare/wp-json, and?rest_route=separately. - Test a REST URI carrying
/wp-cron.phpplusdoing_wp_cron=in its query, and percent-encoded / doubled-slash / trailing-slash spellings of each endpoint. - Test from behind a reverse proxy with and without
trusted_proxiesset. - Test limits with
protect_rest_apion and off. - Exercise 429/
Retry-After, filter redirect, and the endpoint's JSON contract. - Test current, missing, outdated, and emergency-disabled worker states.
- Test whitelist/blacklist/CDN proxy identity from a non-local address.
- Verify automatic-ban enforcement, listing, removal, and post-unban behavior.
- Exercise the same first-hit burst and quiet/steady TTL sequence on every selectable storage backend.
- Inspect expired file count and database writes during a distributed smoke load.
- Verify constant overrides against a real worker request, not only an option read.
- Verify the worker heartbeat after a plugin-directory rename.
Cross-references
- Use
lw-firewall-registration-guardfor signup proof fields. - Use
lw-firewall-password-reset-protectionfor lost-password limits. - Use
lw-firewall-management-abilitiesfor CLI, options, bans, and logs. - Use
wp-rest-apifor REST permission and schema design.
References
- Official project: https://github.com/lwplugins/lw-firewall
- Verified plugin-root-relative sources:
worker/lw-firewall-worker.phpincludes/helpers.phpincludes/Plugin.phpincludes/Activator.phpincludes/Options.phpincludes/IpDetector.phpincludes/Rules/RateLimiter.phpincludes/Rules/AutoBanner.phpincludes/Rules/IpMatcher.phpincludes/Storage/StorageInterface.phpincludes/Storage/FileStorage.phpincludes/Storage/ApcuStorage.phpincludes/Storage/StorageDetector.phpincludes/Storage/CacheDirectory.phpincludes/ProxyTrust.phpincludes/OptionSchema.phpincludes/Logger.phpincludes/Geo/GeoDetector.phpincludes/CLI/WorkerCommand.phpCHANGELOG.md
Signals
- GitHub stars
- 22
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
lw-firewall-rate-limit-worker- Source
- github.com/lonsdale201/wp-agent-skills