Applies to
forms.server-side-validation, forms.anti-spam.
Covers forms.server-side-validation, forms.anti-spam. Related: security.public-post-rate-limit, a11y.form-errors, legal.terms-consent-checkbox.
Client-side validation is a convenience for honest people and nothing else, so every rule the browser runs has to run again on an endpoint that is public and takes a POST from anything that can reach it.
The browser check is a courtesy, not a control
required, type=email, pattern and the validation module are there so an honest visitor gets a
message beside the field instead of a round trip, which is forms.error-timing and
a11y.form-errors doing their job. None of it survives contact with curl.
In WordPress the endpoint is a REST route registered with permission_callback => '__return_true',
and it has to be: the sender is anonymous, so there is nobody to authorise. The nonce does not
close that. A wp_rest nonce is cross-site request forgery protection, and wp-starter hands one
out on a public GET /nonce with Cache-Control: no-store, because a nonce baked into cached HTML
403s every anonymous visitor while working for the logged-in developer. That is the right design, and
it means a script can fetch a fresh one at will. One solved nonce buys unlimited submissions, which
is why the third spam layer below is not optional.
Running every rule again, in WordPress
function site_contact_submit( WP_REST_Request $req ) { // the route is public, see above
$errors = [];
foreach ( site_contact_fields() as $name => $field ) { // the SAME map the markup renders from
$value = site_sanitize_by_type( $req->get_param( $name ), $field['type'] );
if ( $field['required'] && '' === $value ) $errors[ $name ] = 'required';
elseif ( 'email' === $field['type'] && ! is_email( $value ) ) $errors[ $name ] = 'format';
}
if ( $errors ) return new WP_Error( 'invalid', '', [ 'status' => 400, 'fields' => $errors ] );
// only now: store, then notify
}
Presence, declared type, length, format and every consent, before anything is stored or emailed. An
unticked terms box is one of these checks too, and a blocker of its own in
legal.terms-consent-checkbox. Reading the list off the same field map the markup renders from is
the whole point of wp-starter's form registry, and it is what stops the two drifting.
Both builds this standard was measured against pass this rule's acceptance test, and both still
have exactly one field the markup marks required and the server never checks - a different field in
each, and the phone pattern re-run in neither. The test names two fields, so it is a spot check: a
build passes it with a required field wide open. Do not over-correct either, because inventing checks
the browser never ran refuses real customers, which is what forms.value-formats exists to stop.
Three layers, and what each one misses
The client checklist behind this rule asks for a honeypot or a captcha, plus rate limiting. The
rule requires all three, and its citation reads in part for that reason: it is stricter than the
document it rests on, and says so rather than letting the client's name carry it.
| Layer | What it stops | What it does not |
|---|---|---|
| Honeypot | Bots that fill every input they find, which is most of the volume | Anything that reads your CSS, and any script written against your form |
| Scored gate | Cheap automation at volume, by scoring the session before you trust it | A person paid to fill it in, a solved-challenge farm, and nothing at all while its verifier is unreachable and it fails open |
| Per-IP rate limit | A script pointed at the endpoint, however well it handles the other two | A distributed source, and one determined person submitting slowly |
A refusal by any layer writes nothing: no message record, no consent record, no notification. And only a genuine validation failure may say what went wrong - wp-starter's honeypot answers a plain 200 that stores nothing, because a 400 naming the hidden field tells the script to leave it blank.
A captcha on its own is neither enough nor free
Incomplete. No clause in this standard's citation ledger names CAPTCHA, and since the move to ASVS 5.0.0 no clause in ASVS names it either - the word appears nowhere in the 5.0.0 text. The 4.0.3 release did name it, in company: "Such controls include blocking the most common breached passwords, soft lockouts, rate limiting, CAPTCHA, ever increasing delays between attempts". One item on a list, in a clause scoped to authentication. 5.0.0 dropped that list along with the rest of its mechanism naming and asks instead that the control be documented and then implemented as documented. Either way a captcha is one way of meeting the requirement and was never the requirement.
Fails open by default. wp-starter's reCAPTCHA v3 is optional, off by default, and returns a pass when the call to Google fails at the transport layer. Both delivered builds do the same. So on the day Google is unreachable from the host the gate is not there, nothing in the response says so, and the accept criterion tests exactly that case.
The cost lands on the visitor, and this part is our position rather than a citation. An interactive challenge is a task somebody must pass before they may send an enquiry, and those likeliest to fail are already having the hardest time: screen reader users, anyone at high magnification, anyone reading a machine translation. A scored gate shows no puzzle and trades that for a quieter failure - it refuses silently, with no route to succeed, and you cannot tell a false positive from a bot because nothing is recorded on the losing side.
The rate limit nobody ships
It is absent from wp-starter and from both delivered builds; the searches for it returned unrelated prose and, in one theme, an SVG path coordinate. A transient counter is enough: there is no CDN and no WAF in front of these sites, so if the application does not count, nothing does.
$key = 'rl_contact_' . hash( 'sha256', $ip . wp_salt() ); // a counter, not a record
$hits = (int) get_transient( $key ) + 1; // count THIS one before deciding
if ( $hits >= 20 ) return new WP_Error( 'too_many', '', [ 'status' => 429 ] );
set_transient( $key, $hits, MINUTE_IN_SECONDS );
Count the current request before deciding, or the twentieth POST is the one that gets through and only the twenty-first is refused - which is not what the acceptance test asks for. It reads "20 POSTs from one IP inside a minute get a 429 before the last one", so nineteen succeed and the twentieth is the refusal.
set_transient re-arms the expiry on every write, so that is a sliding window: nineteen inside any
sixty seconds with no gap. Stricter than a fixed window, which is the right direction here, but do
not sell it to a client as "twenty a minute". It is also not double-submit protection - twenty is a
long way above two, and both copies of a replayed POST land inside the window, which is
forms.submit-lifecycle's problem and needs an idempotency key.
Hash the address and let it expire. security.public-post-rate-limit needs the IP in order to
count, while this standard refuses to store one in a consent record, since that record is kept for
years and an address is directly identifying personal data. A salted key that dies in sixty seconds
is a control; a column in a table you keep is a data set.