lw-firewall-custom-form-adapter
Design or audit an LW Firewall adapter for a custom public form that is not the built-in registration or lost-password form, or specify a future generic form-guard extension contract without claiming it already exists. Covers the absence of a generic guard, reusable `RegisterToke
Install
npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/lw-plugins/lw-firewall-custom-form-adapter
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 custom-form adapter
LW Firewall 1.5.6 has dedicated guards for core registration and password reset. It does not expose a generic “attach this firewall to any form” service, field descriptor, render/validate interface, or form registry.
Use this skill only when a companion plugin deliberately builds an adapter from the lower-level public classes. Do not present the adapter as a built-in generic LW Firewall feature.
Choose the correct contract
| Form | Current LW integration |
|---|---|
| Core WordPress registration | automatic RegisterGuard when its activation conditions pass |
| Custom user registration | use lw-firewall-registration-guard |
| Core lost password | automatic proof plus limits |
| Woo/custom lost password using canonical hook | rate limits; proof only on core wp-login.php |
| Arbitrary contact/lead/application form | no automatic honeypot or token |
/wp-json/ form endpoint |
optional shared REST rate bucket only |
Do not invoke RegisterGuard::validate() for an unrelated form: it reads fixed
registration fields from $_POST, increments registration-specific rejection
counters, and can create a misleading register_spam ban.
Reusable primitives and their limits
RegisterToken::issue() and verify() are public static methods, and
RateLimiter accepts a custom storage key. They are reusable only with a
version-pinned adapter and explicit fallback policy; their class names and
settings remain registration-oriented.
The token format changed in 1.5.6 — this is a breaking change for adapters.
The payload is now v2.<issued>.<scope>.<nonce>, HMAC-signed as a whole, where
the nonce is 16 random bytes per render. Three consequences:
- The scope is signed, not just a storage-key prefix. A token issued with
one scope can no longer be presented to another form — but it also means
issue()andverify()must be given the same scope.issue()defaults to'reg', so an adapter that calls a bareissue()and verifies with its own scope now fails every submission. Always pass the scope on both sides. - Same-second renders are distinct. The 1.5.4 collision (identical tokens within one second, single-use rejecting all but the first, a shared page cache handing one token to everybody) is gone.
- Scope is normalized to
[a-z0-9_-]afterstrtolower(), and other characters are stripped —my form!andmyformare the same scope. Pick a scope that is already in that alphabet.
The format version is signed too, so a token rendered by 1.5.4 does not verify on 1.5.6. Expect a burst of rejections from cached pages immediately after the upgrade; that is the intended fail-closed behaviour, not a defect.
The primitive is now a per-render, signed, form-bound proof of render. It is still not identity or a CAPTCHA: it proves a form was rendered, not who rendered it. For high-assurance proof, use a purpose-built protocol or an external challenge; do not hide the limitation behind an adapter name.
Minimal adapter pattern
Own the field names and require the honeypot to be present and empty. This is stricter than the built-in LW guards, where omission is accepted.
use LightweightPlugins\Firewall\Options;
use LightweightPlugins\Firewall\Rules\RegisterToken;
final class MyFormProof
{
private const TOKEN = 'myplugin_form_proof';
private const HONEYPOT = 'myplugin_company_url';
private const SCOPE = 'myplugin_contact_v1';
public static function issue(): array
{
if (!class_exists(RegisterToken::class)
|| !class_exists(Options::class)
|| !(bool) Options::get('enabled', true)
) {
return [];
}
return [
// Pass the scope: it is signed into the token in 1.5.6, and
// verify() below must be given the same one.
self::TOKEN => RegisterToken::issue(self::SCOPE),
self::HONEYPOT => '',
];
}
/** @param array<string, mixed> $input */
public static function validate(array $input): true|WP_Error
{
if (!class_exists(RegisterToken::class)
|| !class_exists(Options::class)
|| !function_exists('lw_firewall_resolve_storage')
) {
return true; // Change deliberately if this integration is mandatory.
}
if (!(bool) Options::get('enabled', true)) {
return true;
}
if (!array_key_exists(self::HONEYPOT, $input)
|| (string) $input[self::HONEYPOT] !== ''
) {
return self::failure();
}
$storage = lw_firewall_resolve_storage((string) Options::get('storage', 'auto'));
$token = sanitize_text_field((string) ($input[self::TOKEN] ?? ''));
$valid = RegisterToken::verify($token, 2, 3600, $storage, self::SCOPE);
return $valid ? true : self::failure();
}
private static function failure(): WP_Error
{
return new WP_Error(
'myplugin_form_rejected',
__('The form could not be submitted. Please try again.', 'myplugin'),
['status' => 400]
);
}
}
SCOPE must be a developer-owned constant, never request input. Since 1.5.6 it
is signed into the token as well as namespacing the atomic replay counter, so it
genuinely binds the proof to this form — which is also why issue() and
verify() must receive the identical value. Own timing values in the companion
plugin; borrowing register_* options silently couples unrelated forms to
registration policy.
Rendering and transport
- Server-rendered form: render both fields per response and escape attributes.
- REST/headless: deliver the proof in a private/no-store bootstrap response and
extract it from
WP_REST_Request; never useRegisterGuard::validate(). - AJAX: validate before any database, mail, CRM, or remote side effect.
- Full-page/CDN cache: do not cache one single-use token for all visitors.
An anonymous token-mint route is necessarily public. Give it a dedicated rate limit and do not treat successful minting as authentication or CSRF proof.
Separate rate limit
The token is not a request-volume control. Use RateLimiter::is_allowed_key()
with a form-specific key and IpDetector::get_ip(). Return the transport's own
generic 429 contract rather than calling RateLimiter::too_many() when a JSON
envelope is required.
The global protect_rest_api toggle is shared across all detected REST traffic
— since 1.5.6 that includes the pretty /wp-json/ prefix, the bare /wp-json
index and the ?rest_route= form — but it is one shared bucket and does not
know which route submits this form. It is not a substitute for a form-specific
limit.
RateLimiter::is_allowed_key() overrides the count limit only. Its window
still comes from the global rate_window option. A companion that needs an
independent window must call the selected storage's increment() with its own
bounded TTL or own a dedicated limiter abstraction.
Upstream extension proposal
For a first-class LW Firewall feature, do not turn the private, $_POST-bound
registration guard into a growing list of form-specific hooks. Add a
transport-neutral form-guard contract with:
- a registered, developer-owned form ID and server-owned policy;
- a random per-render nonce signed together with version, form ID and issue time;
- strict honeypot presence plus emptiness;
- atomic, form-bound replay consumption;
- array/request input instead of direct superglobal reads;
- a structured verdict that callers translate into their own HTML/JSON error;
- independent per-form limit and window settings;
- explicit storage-failure and full-page-cache behavior.
Read generic-form-guard-proposal.md for the proposed PHP contract, threat boundaries, hooks and acceptance tests. That document is a design proposal for a later LW Firewall version, not an API available in 1.5.6.
Layers the adapter must not replace
- capability/authentication checks for non-public actions;
- CSRF nonce for cookie-authenticated state changes;
- server-side schema, sanitization, and business validation;
- upload controls and output escaping;
- duplicate/idempotency controls for expensive side effects;
- email verification, CAPTCHA/bot scoring, or provider abuse controls where required.
Test matrix
- Valid, missing, filled, and omitted honeypot.
- Valid, tampered, too-young, expired, and replayed token.
- Two tokens issued in the same second for the same scope (must both succeed).
- Token minted for one adapter and submitted to another scope (must fail).
- A token issued without an explicit scope and verified with one (must fail — this is the common adapter bug after the 1.5.6 format change).
- A token in the pre-1.5.6 format (must fail closed).
- Shared-cache delivery to multiple anonymous clients.
- Plugin inactive, helper unavailable, master disabled, and chosen fail policy.
- Form-specific rate threshold and atomic concurrency.
- Form-specific window behavior; do not assume the current
RateLimiterowns it. - POST, AJAX, pretty REST, and
?rest_route=where supported. - One generic public error with no check-specific oracle.
Cross-references
- Use
lw-firewall-registration-guardfor user creation. - Use
lw-firewall-password-reset-protectionfor lost-password flows. - Use
lw-firewall-rate-limit-workerfor request detection and local limits. - Use
wp-rest-apiandwp-security-auditfor endpoint security.
References
- Official LW project: https://github.com/lwplugins/lw-firewall
- Verified LW plugin-root-relative sources:
includes/Rules/RegisterToken.phpincludes/Rules/RegisterGuard.phpincludes/Rules/PasswordResetGuard.phpincludes/Rules/RateLimiter.phpincludes/Storage/StorageInterface.phpincludes/Options.phpworker/lw-firewall-worker.php
Files (wp-agent-skills)
-
agents
-
openai.yaml 312 B
interface: display_name: "LW Firewall custom form adapter" short_description: "Adapt LW proof and limits to custom public forms" default_prompt: "Use $lw-firewall-custom-form-adapter to design or audit a custom form adapter with explicit honeypot, token, caching, rate-limit, REST, and fallback behavior."
-
-
references
-
generic-form-guard-proposal.md 6.3 KB
# Proposed generic form-guard contract This is an upstream design proposal for a future LW Firewall release. It is not part of LW Firewall 1.5.6, and companion plugins must not feature-detect or call the names below until the plugin ships a documented equivalent. ## Goal and boundary Let a companion plugin attach LW's honeypot, timing, replay and rate controls to a public form without depending on fixed field names, `$_POST`, private methods, registration counters or a particular HTML renderer. The guard is an abuse-control layer. It must not replace route authorization, cookie-CSRF protection, schema validation, upload policy, business rules, idempotency, email verification or a high-assurance challenge where one is required. ## Registration contract A companion should register a stable, namespaced form ID during an explicit LW registration hook: ```php add_action( 'lw_firewall_register_form_guards', static function (FormGuardRegistry $registry): void { $registry->register( 'myplugin/contact-v1', new FormGuardPolicy( minimumAge: 2, maximumAge: 3600, singleUse: true, honeypot: true, rateLimit: 5, rateWindow: 300, storageFailure: StorageFailurePolicy::FailOpen, ) ); } ); ``` The registry must reject an empty/non-namespaced ID, duplicates, unknown policy keys, invalid enum values and out-of-range integers. Policy is server-owned and must never be selected from request input. ## Render-neutral issue result Do not echo markup from the core service. Return an immutable descriptor: ```php $proof = FormGuard::issue('myplugin/contact-v1'); $proof->tokenField(); $proof->token(); $proof->honeypotField(); $proof->expiresAt(); ``` The caller decides whether to render escaped HTML, place the values in a REST bootstrap response or pass them to a headless client. Anonymous bootstrap responses must use `Cache-Control: private, no-store`; a single-use proof must not be shared by a full-page/CDN cache. ## Token payload Sign a versioned canonical payload containing at least: ```text version form_id issued_at random_nonce_id ``` Generate at least 128 bits of cryptographic randomness per issue. Bind the form ID inside the signed payload and derive the replay key from both the form ID and nonce ID. Do not bind the client IP by default: mobile networks, proxies and privacy relays can legitimately change it between render and submit. As of 1.5.6 `RegisterToken` already meets most of this: the payload is `v2.<issued>.<scope>.<nonce>` with a 16-byte per-render nonce, HMAC-signed, so same-second renders no longer collide and the caller's scope IS signed. What a generic guard would still add on top is a public, versioned contract (the class name and its options remain registration-oriented), a caller-supplied form ID distinct from the replay scope, transport-neutral validation, and a documented fail policy. Treat the wire format as adequate and the API surface as the gap. ## Transport-neutral validation Accept caller-supplied data and request context rather than reading `$_POST`: ```php $result = FormGuard::validate( 'myplugin/contact-v1', $requestData, RequestContext::fromWordPressRequest() ); if (!$result->allowed()) { return new WP_Error( 'myplugin_form_rejected', __('The form could not be submitted. Please try again.', 'myplugin'), ['status' => $result->retryAfter() ? 429 : 400] ); } ``` Require the honeypot field to exist and be empty. Verify signature, exact form binding, timing and atomic replay before any expensive or irreversible side effect. Return a typed internal reason (`missing`, `honeypot`, `signature`, `too_fast`, `expired`, `replay`, `rate_limited`, `storage_unavailable`) plus an optional retry time; callers should normally expose one generic public error. ## Rate and storage contract - Keep the form's counter key and window independent from global REST/filter buckets and the global `rate_window` option. - Use one atomic create/increment primitive with consistent TTL semantics on APCu, Redis and file storage. - Namespace backend keys by WordPress installation/network as well as form ID. - Define the storage-outage policy per form. A public contact form may fail open; a high-impact action may choose a controlled fail-closed response. - Expose backend health so adapters can log degradation without creating a write per hostile request. ## Extension hooks Keep hooks observational or policy-bounded: ```text lw_firewall_register_form_guards lw_firewall_form_guard_request_context lw_firewall_form_guard_result ``` Do not provide a filter that can replace the signed form ID, nonce ID, replay key or server-owned policy with raw request values. Document callback timing, argument shapes and whether a hook may change the verdict. ## Compatibility behavior - The API must be additive; core registration and reset guards keep their existing contracts until an explicit migration release. - Publish a capability/version feature check, not a check for private classes. - Never call `RegisterTracker` for unrelated forms or record a generic failure as `register_spam`. - Let each transport own its response format; the guard must not print or exit. ## Acceptance tests 1. Two proofs issued in the same second are different and both can succeed. 2. A proof issued for form A fails for form B before consuming either replay key. 3. Missing and filled honeypots fail; a present empty honeypot passes. 4. Too-young, expired, tampered and replayed proofs fail deterministically. 5. Parallel replay attempts yield exactly one success on every backend. 6. The same per-form limit/window behavior is observed on APCu, Redis and file. 7. HTML POST, admin-ajax, pretty REST, `?rest_route=` and headless input all use the same validation service without superglobal mutation. 8. Shared-cache responses cannot distribute one proof to several visitors. 9. Missing plugin, disabled master, unavailable storage and backend recovery follow the documented policy. 10. Public errors do not reveal which anti-bot check failed. ## Verified 1.5.6 source boundary - `includes/Rules/RegisterToken.php` - `includes/Rules/RegisterGuard.php` - `includes/Rules/PasswordResetGuard.php` - `includes/Rules/RateLimiter.php` - `includes/Storage/StorageInterface.php` - `includes/Options.php`
-
-
SKILL.md 10.7 KB
--- name: lw-firewall-custom-form-adapter description: Design or audit an LW Firewall adapter for a custom public form that is not the built-in registration or lost-password form, or specify a future generic form-guard extension contract without claiming it already exists. Covers the absence of a generic guard, reusable `RegisterToken` and `RateLimiter` primitives, strict honeypot presence, developer-owned replay scopes, REST/headless transport, shared-cache safety, fail-open policy, and route-local abuse limits. Use for contact, lead, quote, application, custom signup, AJAX, or REST forms mentioning LW Firewall honeypots, timing tokens, proof fields, custom anti-bot adapters, or a generic form-protection API. 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 custom-form adapter LW Firewall 1.5.6 has dedicated guards for core registration and password reset. It does not expose a generic “attach this firewall to any form” service, field descriptor, render/validate interface, or form registry. Use this skill only when a companion plugin deliberately builds an adapter from the lower-level public classes. Do not present the adapter as a built-in generic LW Firewall feature. ## Choose the correct contract | Form | Current LW integration | |---|---| | Core WordPress registration | automatic `RegisterGuard` when its activation conditions pass | | Custom user registration | use `lw-firewall-registration-guard` | | Core lost password | automatic proof plus limits | | Woo/custom lost password using canonical hook | rate limits; proof only on core `wp-login.php` | | Arbitrary contact/lead/application form | no automatic honeypot or token | | `/wp-json/` form endpoint | optional shared REST rate bucket only | Do not invoke `RegisterGuard::validate()` for an unrelated form: it reads fixed registration fields from `$_POST`, increments registration-specific rejection counters, and can create a misleading `register_spam` ban. ## Reusable primitives and their limits `RegisterToken::issue()` and `verify()` are public static methods, and `RateLimiter` accepts a custom storage key. They are reusable only with a version-pinned adapter and explicit fallback policy; their class names and settings remain registration-oriented. **The token format changed in 1.5.6 — this is a breaking change for adapters.** The payload is now `v2.<issued>.<scope>.<nonce>`, HMAC-signed as a whole, where the nonce is 16 random bytes per render. Three consequences: - **The scope is signed, not just a storage-key prefix.** A token issued with one scope can no longer be presented to another form — but it also means `issue()` and `verify()` **must be given the same scope**. `issue()` defaults to `'reg'`, so an adapter that calls a bare `issue()` and verifies with its own scope now fails every submission. Always pass the scope on both sides. - **Same-second renders are distinct.** The 1.5.4 collision (identical tokens within one second, single-use rejecting all but the first, a shared page cache handing one token to everybody) is gone. - **Scope is normalized** to `[a-z0-9_-]` after `strtolower()`, and other characters are stripped — `my form!` and `myform` are the same scope. Pick a scope that is already in that alphabet. The format version is signed too, so a token rendered by 1.5.4 does not verify on 1.5.6. Expect a burst of rejections from cached pages immediately after the upgrade; that is the intended fail-closed behaviour, not a defect. The primitive is now a per-render, signed, form-bound proof of render. It is still not identity or a CAPTCHA: it proves a form was rendered, not who rendered it. For high-assurance proof, use a purpose-built protocol or an external challenge; do not hide the limitation behind an adapter name. ## Minimal adapter pattern Own the field names and require the honeypot to be present and empty. This is stricter than the built-in LW guards, where omission is accepted. ```php use LightweightPlugins\Firewall\Options; use LightweightPlugins\Firewall\Rules\RegisterToken; final class MyFormProof { private const TOKEN = 'myplugin_form_proof'; private const HONEYPOT = 'myplugin_company_url'; private const SCOPE = 'myplugin_contact_v1'; public static function issue(): array { if (!class_exists(RegisterToken::class) || !class_exists(Options::class) || !(bool) Options::get('enabled', true) ) { return []; } return [ // Pass the scope: it is signed into the token in 1.5.6, and // verify() below must be given the same one. self::TOKEN => RegisterToken::issue(self::SCOPE), self::HONEYPOT => '', ]; } /** @param array<string, mixed> $input */ public static function validate(array $input): true|WP_Error { if (!class_exists(RegisterToken::class) || !class_exists(Options::class) || !function_exists('lw_firewall_resolve_storage') ) { return true; // Change deliberately if this integration is mandatory. } if (!(bool) Options::get('enabled', true)) { return true; } if (!array_key_exists(self::HONEYPOT, $input) || (string) $input[self::HONEYPOT] !== '' ) { return self::failure(); } $storage = lw_firewall_resolve_storage((string) Options::get('storage', 'auto')); $token = sanitize_text_field((string) ($input[self::TOKEN] ?? '')); $valid = RegisterToken::verify($token, 2, 3600, $storage, self::SCOPE); return $valid ? true : self::failure(); } private static function failure(): WP_Error { return new WP_Error( 'myplugin_form_rejected', __('The form could not be submitted. Please try again.', 'myplugin'), ['status' => 400] ); } } ``` `SCOPE` must be a developer-owned constant, never request input. Since 1.5.6 it is signed into the token as well as namespacing the atomic replay counter, so it genuinely binds the proof to this form — which is also why `issue()` and `verify()` must receive the identical value. Own timing values in the companion plugin; borrowing `register_*` options silently couples unrelated forms to registration policy. ## Rendering and transport - Server-rendered form: render both fields per response and escape attributes. - REST/headless: deliver the proof in a private/no-store bootstrap response and extract it from `WP_REST_Request`; never use `RegisterGuard::validate()`. - AJAX: validate before any database, mail, CRM, or remote side effect. - Full-page/CDN cache: do not cache one single-use token for all visitors. An anonymous token-mint route is necessarily public. Give it a dedicated rate limit and do not treat successful minting as authentication or CSRF proof. ## Separate rate limit The token is not a request-volume control. Use `RateLimiter::is_allowed_key()` with a form-specific key and `IpDetector::get_ip()`. Return the transport's own generic 429 contract rather than calling `RateLimiter::too_many()` when a JSON envelope is required. The global `protect_rest_api` toggle is shared across all detected REST traffic — since 1.5.6 that includes the pretty `/wp-json/` prefix, the bare `/wp-json` index and the `?rest_route=` form — but it is one shared bucket and does not know which route submits this form. It is not a substitute for a form-specific limit. `RateLimiter::is_allowed_key()` overrides the count limit only. Its window still comes from the global `rate_window` option. A companion that needs an independent window must call the selected storage's `increment()` with its own bounded TTL or own a dedicated limiter abstraction. ## Upstream extension proposal For a first-class LW Firewall feature, do not turn the private, `$_POST`-bound registration guard into a growing list of form-specific hooks. Add a transport-neutral form-guard contract with: - a registered, developer-owned form ID and server-owned policy; - a random per-render nonce signed together with version, form ID and issue time; - strict honeypot presence plus emptiness; - atomic, form-bound replay consumption; - array/request input instead of direct superglobal reads; - a structured verdict that callers translate into their own HTML/JSON error; - independent per-form limit and window settings; - explicit storage-failure and full-page-cache behavior. Read [generic-form-guard-proposal.md](references/generic-form-guard-proposal.md) for the proposed PHP contract, threat boundaries, hooks and acceptance tests. That document is a design proposal for a later LW Firewall version, not an API available in 1.5.6. ## Layers the adapter must not replace - capability/authentication checks for non-public actions; - CSRF nonce for cookie-authenticated state changes; - server-side schema, sanitization, and business validation; - upload controls and output escaping; - duplicate/idempotency controls for expensive side effects; - email verification, CAPTCHA/bot scoring, or provider abuse controls where required. ## Test matrix - Valid, missing, filled, and omitted honeypot. - Valid, tampered, too-young, expired, and replayed token. - Two tokens issued in the same second for the same scope (must both succeed). - Token minted for one adapter and submitted to another scope (must fail). - A token issued without an explicit scope and verified with one (must fail — this is the common adapter bug after the 1.5.6 format change). - A token in the pre-1.5.6 format (must fail closed). - Shared-cache delivery to multiple anonymous clients. - Plugin inactive, helper unavailable, master disabled, and chosen fail policy. - Form-specific rate threshold and atomic concurrency. - Form-specific window behavior; do not assume the current `RateLimiter` owns it. - POST, AJAX, pretty REST, and `?rest_route=` where supported. - One generic public error with no check-specific oracle. ## Cross-references - Use `lw-firewall-registration-guard` for user creation. - Use `lw-firewall-password-reset-protection` for lost-password flows. - Use `lw-firewall-rate-limit-worker` for request detection and local limits. - Use `wp-rest-api` and `wp-security-audit` for endpoint security. ## References - Official LW project: <https://github.com/lwplugins/lw-firewall> - Verified LW plugin-root-relative sources: - `includes/Rules/RegisterToken.php` - `includes/Rules/RegisterGuard.php` - `includes/Rules/PasswordResetGuard.php` - `includes/Rules/RateLimiter.php` - `includes/Storage/StorageInterface.php` - `includes/Options.php` - `worker/lw-firewall-worker.php`
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.