lw-firewall-registration-guard
Integrate custom WordPress registration forms and signup REST endpoints with LW Firewall's registration honeypot, signed timing token, single-use storage, rejection tracking, and rate limiting. Use when code creates users outside the core `wp-login.php?action=register` flow or re
Install
npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/lw-plugins/lw-firewall-registration-guard
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
git clone https://github.com/Lonsdale201/wp-agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
LW Firewall registration guard
Use this skill when a companion plugin owns the signup UI or transport. LW Firewall automatically wires only the core WordPress registration form; a custom PHP, AJAX, headless, WooCommerce, CRM, LMS, or REST flow must opt in.
Read registration-and-rest-integration.md before implementing a JSON endpoint or copying the manual validation adapter.
Automatic coverage boundary
The plugin registers RegisterGuard only when all of these are true:
- the MU worker is installed and matches
LW_FIREWALL_VERSION; - the master
enabledoption is true; register_protect_enabledis true;- WordPress
users_can_registeris true.
It then hooks register_form for rendering and registration_errors for
validation. A custom route that calls wp_insert_user() does not pass through
this guard automatically. Decide separately whether the custom product should
honour users_can_register.
Verified public surface
| Contract | Current behavior |
|---|---|
RegisterGuard::render_fields() |
Echoes lw_fw_reg_token and, when enabled, lw_fw_url |
RegisterGuard::validate( $errors = null, $login = '', $email = '' ) |
Reads the current $_POST, records rejection, adds a generic error |
RegisterToken::issue( string $scope = 'reg' ) |
Returns a signed per-render token |
RegisterToken::make( int $issued, string $scope = 'reg', string $nonce = '' ) |
Deterministic seam for tests |
RegisterToken::verify( $token, $min, $max, ?$storage = null, $scope = 'reg' ) |
Checks signature, scope, age and optional atomic single use |
RegisterToken::check( $token, $now, $min, $max, ?$storage = null, $scope = 'reg' ) |
Same, against an explicit "now" |
validate() deliberately does not hard-type its first parameter (1.5.6):
another plugin on registration_errors returning a non-WP_Error used to cause
an uncatchable TypeError on a public form.
| RegisterTracker::record_reject() | Counts non-whitelisted rejected registrations and may write a shared ban |
RegisterGuard's field constants and spam predicate are private. Do not call
private methods or edit the copied MU worker.
Classic server-rendered form
Respect the plugin settings because render_fields() and validate() do not
self-check the master or registration toggles:
use LightweightPlugins\Firewall\Options;
use LightweightPlugins\Firewall\Rules\RegisterGuard;
$lw_guard_enabled = class_exists(RegisterGuard::class)
&& class_exists(Options::class)
&& (bool) Options::get('enabled', true)
&& (bool) Options::get('register_protect_enabled', true);
if ($lw_guard_enabled) {
RegisterGuard::render_fields();
}
Before creating the user:
$errors = new WP_Error();
if ($lw_guard_enabled) {
$errors = RegisterGuard::validate($errors);
}
if ($errors->has_errors()) {
return $errors;
}
// Validate the remaining business fields, then create the user.
This convenience path is valid only when the request data is in $_POST.
REST and headless registration
RegisterGuard::validate() does not read WP_REST_Request; a JSON body can
therefore fail even when it contains the fields. Extract request parameters and
call RegisterToken::verify() manually, using the fixed registration scope
reg and RegisterTracker::record_reject() on failure. The reference contains
a transport-independent implementation.
Mint a token as part of the form/bootstrap response or through a narrowly rate-limited bootstrap route. The token endpoint is public by necessity for an anonymous signup and is not an authentication boundary.
protect_rest_api is only a shared per-IP rate limiter for URLs containing
/wp-json/. It does not validate signup fields, authorize user creation, or
target registration routes specifically. WordPress core's wp/v2/users create
route requires create_users; a deliberately public custom registration route
must implement its own permission policy and abuse controls. Since 1.5.6 the
worker also classifies the bare /wp-json index and the ?rest_route=/...
form, so those no longer slip past the REST bucket.
Security meaning of the token
Keep normal CSRF, capability, authentication, validation, email-verification, and account-policy checks. The LW token is an anti-automation signal, not a WordPress nonce and not proof that a human submitted the form.
Since 1.5.6 the signed payload is v2.<issued>.<scope>.<nonce> with 16 random
bytes of per-render nonce:
- the scope is signed, so a token issued for one form is no longer accepted
by another — and
issue()andverify()must be given the same scope (both default to'reg'; a bareissue()verified against a custom scope fails every time); - tokens issued in the same second are distinct, so two legitimate same-scope renders no longer collide under single use, and a shared page cache no longer hands one token to every visitor;
- the format version is signed, so a token rendered by 1.5.4 fails on 1.5.6 — expect rejections from cached pages right after the upgrade;
- scope is normalized to
[a-z0-9_-]afterstrtolower(), somy form!andmyformare the same scope; - it is still not bound to route, user, IP or field name;
- the honeypot rejects a non-empty value, but an omitted honeypot is still treated as empty.
The token is now a per-render, form-bound proof of render. It is still not proof that a human submitted the form, and not a WordPress nonce.
Auto-ban (fixed in 1.5.5)
RegisterTracker writes ban_<ip> after register_ban_threshold failures.
The 1.5.4 defect — the worker reading shared ban keys only when
auto_ban_enabled or login_limit_enabled was on, so a registration-only
configuration listed a register_spam ban it never enforced — is fixed: the
worker now reads the ban key whenever the firewall is enabled. Still confirm
enforcement with a real follow-up request; the storage key, not the index row,
is the authority.
Review checklist
- Feature-detect LW Firewall and define fail-open or fail-closed behavior.
- Respect
enabledandregister_protect_enabledin custom rendering and validation. - Use
RegisterGuardonly for form-encoded$_POST; adapt JSON explicitly. - Validate the guard before every user/customer/contact/enrollment write.
- Keep field names server-owned and return one generic signup failure.
- Add route-local rate limiting; the global REST toggle is coarse and optional.
- Test valid, missing, filled honeypot, too-fast, expired, replayed, and two same-second token submissions.
- Test
/wp-json/, bare/wp-json, and?rest_route=transports separately. - Verify that a recorded registration ban is actually enforced.
Cross-references
- Use
lw-firewall-custom-form-adapterfor non-registration forms. - Use
lw-firewall-rate-limit-workerfor worker detection and local counters. - Use
lw-firewall-password-reset-protectionfor lost-password flows. - Use
wp-rest-apifor route permissions, schemas, authentication, and errors.
References
- Official project: https://github.com/lwplugins/lw-firewall
- Verified plugin-root-relative sources:
lw-firewall.phpincludes/Plugin.phpincludes/Options.phpincludes/Rules/RegisterGuard.phpincludes/Rules/RegisterToken.phpincludes/Rules/RegisterTracker.phpincludes/Rules/AutoBanner.phpworker/lw-firewall-worker.phpCHANGELOG.md
Files (wp-agent-skills)
-
agents
-
openai.yaml 334 B
interface: display_name: "LW Firewall Registration Guard" short_description: "Protect custom signup and REST registration flows" default_prompt: "Use $lw-firewall-registration-guard to design or audit my custom WordPress registration form or REST signup endpoint with LW Firewall token, honeypot, rate-limit, and ban behavior."
-
-
references
-
registration-and-rest-integration.md 6.4 KB
# Registration and REST integration contract This reference is verified against LW Firewall 1.5.6 and WordPress 7.1. ## Transport-independent validator Keep input extraction outside the validator so form POST, AJAX and REST use the same checks. Use server-owned field names and the registration replay scope. ```php use LightweightPlugins\Firewall\Options; use LightweightPlugins\Firewall\Rules\RegisterToken; use LightweightPlugins\Firewall\Rules\RegisterTracker; /** * @param array<string, mixed> $input */ function myplugin_check_lw_registration(array $input): true|WP_Error { if (!class_exists(RegisterToken::class) || !class_exists(Options::class)) { return true; // Replace with fail-closed if LW Firewall is required. } if (!(bool) Options::get('enabled', true) || !(bool) Options::get('register_protect_enabled', true) ) { return true; } $honeypot = sanitize_text_field((string) ($input['lw_fw_url'] ?? '')); $token = sanitize_text_field((string) ($input['lw_fw_reg_token'] ?? '')); $valid = !(bool) Options::get('register_honeypot', true) || $honeypot === ''; $storage = null; if ((bool) Options::get('register_single_use', true) && function_exists('lw_firewall_resolve_storage') ) { $storage = lw_firewall_resolve_storage((string) Options::get('storage', 'auto')); } $valid = $valid && RegisterToken::verify( $token, (int) Options::get('register_min_fill_time', 2), (int) Options::get('register_token_max_age', 3600), $storage, 'reg' ); if ($valid) { return true; } if (class_exists(RegisterTracker::class)) { RegisterTracker::record_reject(); } return new WP_Error( 'myplugin_registration_failed', __('Registration failed, please try again.', 'myplugin'), ['status' => 400] ); } ``` This mirrors current LW behavior: honeypot omission is accepted as empty. If a custom protocol wants a stricter present-and-empty honeypot, check `array_key_exists('lw_fw_url', $input)` as well; document that this is stricter than the built-in guard. ## REST route shape Register routes on `rest_api_init`. A public registration route still needs an explicit permission callback; make the public policy visible rather than using `__return_true` as an unexplained placeholder. ```php register_rest_route('myplugin/v1', '/registrations', [ 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => [MySignupPolicy::class, 'allows_public_registration'], 'callback' => static function (WP_REST_Request $request) { $guard = myplugin_check_lw_registration([ 'lw_fw_url' => $request->get_param('lw_fw_url'), 'lw_fw_reg_token' => $request->get_param('lw_fw_reg_token'), ]); if (is_wp_error($guard)) { return $guard; } // Validate email, password and product-owned enrollment policy here. // Create the user only after every check passes. }, 'args' => [ 'lw_fw_url' => ['type' => 'string', 'default' => ''], 'lw_fw_reg_token' => ['type' => 'string', 'required' => true], ], ]); ``` `MySignupPolicy::allows_public_registration()` may intentionally return `true` for anonymous visitors, but it should also own maintenance state, tenant/site policy, invitation requirements, or any product-level registration switch. A permission callback is authorization; token and rate-limit checks are abuse controls and belong in the execution path. ## Bootstrap response The server can supply a token alongside the client-visible form definition: ```php $bootstrap['lwFirewall'] = [ 'enabled' => class_exists(RegisterToken::class) && (bool) Options::get('enabled', true) && (bool) Options::get('register_protect_enabled', true), 'tokenName' => 'lw_fw_reg_token', // Scope is signed into the token since 1.5.6; issue and verify must agree. // 'reg' is the built-in registration scope used by verify() below. 'token' => class_exists(RegisterToken::class) ? RegisterToken::issue('reg') : '', 'honeypotName' => 'lw_fw_url', 'honeypotEnabled' => (bool) Options::get('register_honeypot', true), ]; ``` Do not cache a single token into a shared page or CDN response when single-use is enabled. 1.5.6's per-render nonce makes two *renders* distinct, but a cached page is one render served many times, so every visitor still contends for the same replay key. Mark the bootstrap private/no-store or fetch it per session/request. ## Route-local rate limit The worker's REST bucket is shared by every detected REST request from an IP. Use a separate key for signup-specific control: ```php use LightweightPlugins\Firewall\IpDetector; use LightweightPlugins\Firewall\Rules\RateLimiter; $storage = lw_firewall_resolve_storage((string) Options::get('storage', 'auto')); $ip = IpDetector::get_ip(); if (!(new RateLimiter($storage))->is_allowed_key('myplugin_registration_' . $ip, 5)) { return new WP_Error( 'myplugin_registration_limited', __('Registration failed, please try again later.', 'myplugin'), ['status' => 429] ); } ``` Run the rate check before expensive validation or external calls. Keep the key namespace unique; never reuse worker keys such as `rest_<ip>`. ## Required tests 1. Valid POST and valid JSON request. 2. Missing token and invalid signature. 3. Token younger than the fill-time floor and older than the maximum age. 4. First and second use with single use enabled. 5. Two tokens issued in the same second and submitted by different clients (both must succeed since 1.5.6). 5b. A token issued under a different scope (must fail), and a pre-1.5.6 token (must fail closed). 6. Filled honeypot; optionally missing honeypot if the adapter requires presence. 7. Plugin inactive, master disabled, registration guard disabled, worker outdated. 8. Route-local limit and generic error response. 9. `/wp-json/myplugin/v1/registrations`, bare `/wp-json`, and `?rest_route=/myplugin/v1/registrations`. 10. Shared-ban enforcement under the site's actual auto-ban/login settings. ## Source anchors - `includes/Rules/RegisterGuard.php` - `includes/Rules/RegisterToken.php` - `includes/Rules/RegisterTracker.php` - `includes/Rules/RateLimiter.php` - `includes/Options.php` - `worker/lw-firewall-worker.php` - WordPress core: `wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php`
-
-
SKILL.md 8.2 KB
--- name: lw-firewall-registration-guard description: Integrate custom WordPress registration forms and signup REST endpoints with LW Firewall's registration honeypot, signed timing token, single-use storage, rejection tracking, and rate limiting. Use when code creates users outside the core `wp-login.php?action=register` flow or references `RegisterGuard`, `RegisterToken`, `RegisterTracker`, `lw_fw_reg_token`, `lw_fw_url`, `registration_errors`, `wp_insert_user`, public registration REST routes, proof-of-render, honeypots, replay protection, or registration auto-bans. metadata: wp-skills-author: "Soczó Kristóf" wp-skills-contact: "mailto:lonsdale201@hotmail.com" wp-skills-plugin: "lw-firewall" wp-skills-plugin-version-tested: "1.5.6" wp-skills-wp-version-tested: "7.1" wp-skills-php-min: "8.2" wp-skills-last-updated: "2026-08-29" --- # LW Firewall registration guard Use this skill when a companion plugin owns the signup UI or transport. LW Firewall automatically wires only the core WordPress registration form; a custom PHP, AJAX, headless, WooCommerce, CRM, LMS, or REST flow must opt in. Read [registration-and-rest-integration.md](references/registration-and-rest-integration.md) before implementing a JSON endpoint or copying the manual validation adapter. ## Automatic coverage boundary The plugin registers `RegisterGuard` only when all of these are true: - the MU worker is installed and matches `LW_FIREWALL_VERSION`; - the master `enabled` option is true; - `register_protect_enabled` is true; - WordPress `users_can_register` is true. It then hooks `register_form` for rendering and `registration_errors` for validation. A custom route that calls `wp_insert_user()` does not pass through this guard automatically. Decide separately whether the custom product should honour `users_can_register`. ## Verified public surface | Contract | Current behavior | |---|---| | `RegisterGuard::render_fields()` | Echoes `lw_fw_reg_token` and, when enabled, `lw_fw_url` | | `RegisterGuard::validate( $errors = null, $login = '', $email = '' )` | Reads the current `$_POST`, records rejection, adds a generic error | | `RegisterToken::issue( string $scope = 'reg' )` | Returns a signed per-render token | | `RegisterToken::make( int $issued, string $scope = 'reg', string $nonce = '' )` | Deterministic seam for tests | | `RegisterToken::verify( $token, $min, $max, ?$storage = null, $scope = 'reg' )` | Checks signature, scope, age and optional atomic single use | | `RegisterToken::check( $token, $now, $min, $max, ?$storage = null, $scope = 'reg' )` | Same, against an explicit "now" | `validate()` deliberately does **not** hard-type its first parameter (1.5.6): another plugin on `registration_errors` returning a non-`WP_Error` used to cause an uncatchable `TypeError` on a public form. | `RegisterTracker::record_reject()` | Counts non-whitelisted rejected registrations and may write a shared ban | `RegisterGuard`'s field constants and spam predicate are private. Do not call private methods or edit the copied MU worker. ## Classic server-rendered form Respect the plugin settings because `render_fields()` and `validate()` do not self-check the master or registration toggles: ```php use LightweightPlugins\Firewall\Options; use LightweightPlugins\Firewall\Rules\RegisterGuard; $lw_guard_enabled = class_exists(RegisterGuard::class) && class_exists(Options::class) && (bool) Options::get('enabled', true) && (bool) Options::get('register_protect_enabled', true); if ($lw_guard_enabled) { RegisterGuard::render_fields(); } ``` Before creating the user: ```php $errors = new WP_Error(); if ($lw_guard_enabled) { $errors = RegisterGuard::validate($errors); } if ($errors->has_errors()) { return $errors; } // Validate the remaining business fields, then create the user. ``` This convenience path is valid only when the request data is in `$_POST`. ## REST and headless registration `RegisterGuard::validate()` does not read `WP_REST_Request`; a JSON body can therefore fail even when it contains the fields. Extract request parameters and call `RegisterToken::verify()` manually, using the fixed registration scope `reg` and `RegisterTracker::record_reject()` on failure. The reference contains a transport-independent implementation. Mint a token as part of the form/bootstrap response or through a narrowly rate-limited bootstrap route. The token endpoint is public by necessity for an anonymous signup and is not an authentication boundary. `protect_rest_api` is only a shared per-IP rate limiter for URLs containing `/wp-json/`. It does not validate signup fields, authorize user creation, or target registration routes specifically. WordPress core's `wp/v2/users` create route requires `create_users`; a deliberately public custom registration route must implement its own permission policy and abuse controls. Since 1.5.6 the worker also classifies the bare `/wp-json` index and the `?rest_route=/...` form, so those no longer slip past the REST bucket. ## Security meaning of the token Keep normal CSRF, capability, authentication, validation, email-verification, and account-policy checks. The LW token is an anti-automation signal, not a WordPress nonce and not proof that a human submitted the form. Since 1.5.6 the signed payload is `v2.<issued>.<scope>.<nonce>` with 16 random bytes of per-render nonce: - the **scope is signed**, so a token issued for one form is no longer accepted by another — and `issue()` and `verify()` must be given the **same** scope (both default to `'reg'`; a bare `issue()` verified against a custom scope fails every time); - tokens issued in the same second are distinct, so two legitimate same-scope renders no longer collide under single use, and a shared page cache no longer hands one token to every visitor; - the format version is signed, so a token rendered by 1.5.4 fails on 1.5.6 — expect rejections from cached pages right after the upgrade; - scope is normalized to `[a-z0-9_-]` after `strtolower()`, so `my form!` and `myform` are the same scope; - it is still not bound to route, user, IP or field name; - the honeypot rejects a non-empty value, but an omitted honeypot is still treated as empty. The token is now a per-render, form-bound proof of render. It is still not proof that a human submitted the form, and not a WordPress nonce. ## Auto-ban (fixed in 1.5.5) `RegisterTracker` writes `ban_<ip>` after `register_ban_threshold` failures. The 1.5.4 defect — the worker reading shared ban keys only when `auto_ban_enabled` or `login_limit_enabled` was on, so a registration-only configuration listed a `register_spam` ban it never enforced — is fixed: the worker now reads the ban key whenever the firewall is enabled. Still confirm enforcement with a real follow-up request; the storage key, not the index row, is the authority. ## Review checklist - Feature-detect LW Firewall and define fail-open or fail-closed behavior. - Respect `enabled` and `register_protect_enabled` in custom rendering and validation. - Use `RegisterGuard` only for form-encoded `$_POST`; adapt JSON explicitly. - Validate the guard before every user/customer/contact/enrollment write. - Keep field names server-owned and return one generic signup failure. - Add route-local rate limiting; the global REST toggle is coarse and optional. - Test valid, missing, filled honeypot, too-fast, expired, replayed, and two same-second token submissions. - Test `/wp-json/`, bare `/wp-json`, and `?rest_route=` transports separately. - Verify that a recorded registration ban is actually enforced. ## Cross-references - Use `lw-firewall-custom-form-adapter` for non-registration forms. - Use `lw-firewall-rate-limit-worker` for worker detection and local counters. - Use `lw-firewall-password-reset-protection` for lost-password flows. - Use `wp-rest-api` for route permissions, schemas, authentication, and errors. ## References - Official project: <https://github.com/lwplugins/lw-firewall> - Verified plugin-root-relative sources: - `lw-firewall.php` - `includes/Plugin.php` - `includes/Options.php` - `includes/Rules/RegisterGuard.php` - `includes/Rules/RegisterToken.php` - `includes/Rules/RegisterTracker.php` - `includes/Rules/AutoBanner.php` - `worker/lw-firewall-worker.php` - `CHANGELOG.md`
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.