Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-lw-plugins_lw-firewall-registration-guard-52f6020.zip · 6 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/lw-plugins/lw-firewall-registration-guard
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git 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 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:

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() 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
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.

No comments yet.

Reviews (0)

No reviews yet.

Related