Hardening anonymous writes
When an entity lists public in its can_create_roles column, unauthenticated visitors can POST records to it. Three layers of defence apply, in order from cheapest to most invasive:
1. Honeypot field (pfm_hp)
Every [pfm_form] render emits a hidden <input name="pfm_hp"> inside a .pfm-hp-wrap block (CSS-hidden via display:none + off-screen). Real visitors never see or fill it. Simple bots eagerly fill every input — the server rejects with HTTP 422 (pfm_honeypot_tripped) when the value is non-empty.
Cost: one server-side trim() per submission. No JS, no third-party dependency.
2. Per-IP rate limit
AnonymousGuard::check_rate_limit() uses a WordPress transient keyed pfm_arl_<sha1(entity|ip)> to count anonymous submissions per entity per IP per window. Defaults: 30 submissions / 3600 s.
Customise per-entity via the pfm_anonymous_rate_limit filter:
add_filter('pfm_anonymous_rate_limit', function ($cfg, $slug) {
if ($slug === 'lead') {
return ['window_seconds' => 60, 'max_per_window' => 5];
}
return $cfg;
}, 10, 2);
Customise the global defaults via pfm_anonymous_default_rate_limit.
Excess submissions get HTTP 429 (pfm_rate_limited) with the configured limit + window in the error data so the front-end can show a friendly retry message.
IP detection walks X-Forwarded-For (first value), X-Real-IP, then $_SERVER['REMOTE_ADDR'].
3. CAPTCHA hook
The pfm/captcha/verify filter runs after honeypot + rate-limit. Default chain accepts. Wire in Cloudflare Turnstile, hCaptcha or any other provider:
add_filter('pfm/captcha/verify', function ($ok, $entity, $payload, $request) {
$token = $payload['cf_token'] ?? '';
return $ok && my_turnstile_verify($token, $request);
}, 10, 4);
A failure here returns HTTP 422 (pfm_captcha_failed).
Logged-in bypass
All three layers skip when get_current_user_id() > 0. Logged-in users have already passed the WordPress nonce check at the REST layer, so the anti-spam layers are unnecessary friction.