Applies to security.rest-route-authorisation, security.submission-store-private.

Covers security.rest-route-authorisation, security.submission-store-private. Related: security.wp-attack-surface, forms.anti-spam, admin.capability-boundaries.

WordPress is a publishing platform and its defaults publish. Before anyone logs in it hands out a list of your users, the whole media library, and any post type somebody registered without thinking about it, all with a 200 and nothing written to a log. These two rules are about the gap between what a site shows and what it serves.

Read it back

# Every route this install registers, plugins included, then what the built-ins give an anonymous GET.
curl -s https://example.com/wp-json/ | python3 -c 'import json,sys;print(*sorted(json.load(sys.stdin)["routes"]),sep="\n")'
for c in users media posts pages categories comments; do curl -so /dev/null -w "$c %{http_code}\n" "https://example.com/wp-json/wp/v2/$c"; done

media is the library, not the images on a page: an upload with no parent post counts as published, so the mockup sent for one review and never used is public. Both delivered reference builds answer 200 on users with a real login slug in it, in production, today. That half belongs to security.wp-attack-surface, and the lazy probe flatters them: ?author=1 404s on both only because neither has a user with id 1.

A nonce is not an authorisation check

The handbook is unambiguous about the callback:

You must also register a permissions callback for the endpoint. This is a function that checks if the user can perform the action (reading, updating, etc) before the real callback is called.

It then blesses 'permission_callback' => '__return_true' for public routes with no bound of any kind. That is the exact distance between the source and this rule: the callback is the handbook's, the bound is ours. 99 of this standard's 100 external citations are partial the same way.

A wp_rest nonce does not close it, because it answers a different question. It is a CSRF token: it says the request came through a page this site rendered recently. wp_create_nonce() hashes the action, the tick, the user id and the session token, and for a logged-out visitor the last two are 0 and an empty string, so every anonymous visitor holds the same value, it stays good for hours, and anything that can read the page can have one.

nonce=$(curl -s https://example.com/contact/ | grep -om1 '"nonce":"[a-f0-9]*"' | cut -d'"' -f4)
curl -s -X POST https://example.com/wp-json/acme/v1/contact \
  -H "X-WP-Nonce: $nonce" --data 'email=a@b.c&message=hello'

Two requests instead of one. A real bar for a spray bot, none at all for a script written against this site. So the rule takes either answer: a callback that checks a capability, or a route that stays open because it has to, listed in the handover with the thing that caps the damage named beside it.

The worked example ships in both builds. The CSP violation collector has to accept posts from strangers, so it dedupes, stops at 200 stored keys, and stores them with autoload off. The cap is the bound. The autoload flag is why an uncapped version is worse than it sounds: an autoloaded option is read on every request the site serves, so a storage problem becomes everyone's page speed. Both builds enforce the cap. Neither ships the list.

Do not settle this with a grep. current_user_can appears in seven files in wp-starter's inc/ and on none of its three public routes, so a repository-wide search returns hits and proves nothing. Rate limiting is a different rule and no substitute for either answer: security.public-post-rate-limit for the login form, forms.anti-spam for the form endpoints.

Where the submissions live

Storing what a form took means a custom post type, and post ids are one counter shared by every type, so a stored message gets an id sitting between two of your pages. ?p=1819 resolves with no pretty permalink involved, which is what makes the URL guessable rather than secret. Six flags close it, and both builds get all six right on three submission types each - messages, subscribers, consent records - checked on the live boxes rather than in the code: real ids pulled with wp-cli, then the permalink and all three REST routes fetched with no cookie. Every one 404s.

Flag What it closes The trap
public => false The umbrella: front-end defaults for a type nobody browses It does not reach show_in_rest, which is read on its own
publicly_queryable => false, rewrite => false ?p=1819 and ?post_type=message stop resolving, and no permalink is minted to leak
exclude_from_search => true The site's own search box stops returning other people's messages It only defaults the right way when public is false
show_in_rest => false /wp-json/wp/v2/<base> returns 404 It costs the block editor for that type, which is the pressure that turns it back on
map_meta_cap => true Meta capabilities map, so read_post means something It says nothing about who holds them

show_in_rest is the one that catches people, because it is not derived from public. The posts controller admits a type on that flag alone and then decides each row on its post status, and submissions are stored as publish. A type registered public => false, show_in_rest => true - what you do when a field UI wants it - hands its rows to an anonymous GET.

The other direction is louder. Register the type public => true so the admin screens look normal and core's sitemap picks it up, because it enumerates post types registered public, and the SEO plugin keys off the same viewability. Now the URLs are not guessed. They are published in an index and submitted to Search Console. wp post-type list --public=1 should return post, page, attachment and nothing else. crawl.indexability-decided is where the per-template decision gets recorded, seo.sitemap is what catches the leak afterwards, and all of it runs against every host crawl.sibling-hosts turns up: a staging copy holds the same rows, typed in during UAT.

One thing the acceptance test cannot see, because it runs logged out and passes. map_meta_cap maps the capabilities but capability_type decides who holds them, and both builds left it at the default post, so any role that can edit posts reads every submission from inside wp-admin. Moving a type to its own capability_type later grants nobody anything by default, administrators included, so the caps have to be added to roles in the same change. Decide it at registration, and see admin.capability-boundaries for proving a refusal at the URL rather than at the menu.

What Article 32 actually says

Article 32(1) asks a controller for measures appropriate to the risk, and names this among them at 32(1)(b):

the ability to ensure the ongoing confidentiality, integrity, availability and resilience of processing systems and services

That is why this one blocks rather than advises: confidentiality of a processing system is black letter, and the failure is a personal-data breach the client has to report. But Article 32 is a risk-based duty. It names no post type flag, no REST route, no 401 and no 404. Every mechanism on this page is ours, which is the honest line when a client argues about one of the flags: the obligation is theirs, the six flags are this standard's.