Skip to content

💥 feat: requests that change something carry a nonce (3.0) - #129

Merged
gfazioli merged 5 commits into
v3from
feat/v3-csrf
Oct 6, 2026
Merged

gfazioli merged 5 commits into
v3from
feat/v3-csrf

Conversation

@gfazioli

@gfazioli gfazioli commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

For 3.0, on v3: requests that change something carry a nonce (audit S4, S5).

💥 Admin pages

Up to 2.x, a WP Bones admin page mapped the HTTP verb to store(), update() and destroy(), and ran its load callbacks for any request. A form posted from another site with an administrator's cookies did both.

Since 3.0, every request to a WP Bones admin page that is not a GET or a HEAD carries the plugin's nonce. That covers menu items, route pages and pages/ classes.

  • The form prints it: <?php echo $plugin->csrfField(); ?>, or {!! $plugin->csrfField() !!} in Blade. The field is _wpbones_nonce, not _wpnonce, so a form can keep a wp_nonce_field() of its own beside it.
  • The check: Routing\Csrf runs on load-{hook} at PHP_INT_MIN, before any load callback, and again before the page renders.
  • A missing or wrong nonce: WordPress's own wp_nonce_ays(), "The link you followed has expired." with a 403.
  • Where the nonce can travel: the form field, the query string, or an X-WPBones-Nonce header, for a fetch() with a JSON body.
  • WordPress's own forms are let through, because each carries a nonce of its own: Screen Options and the filesystem credentials form.
  • Opting out, for a page that checks a nonce of its own: 'csrf' => false in a route or menu item's route, or a public, argument-free csrf() returning false on a pages/ class. It is not a door for requests from other sites: wp-admin requires a logged-in browser first.

💥 Ajax

  • No nonce, no action. A logged action of a provider without $nonceHash (or $nonceKey) refuses every request with a 403, and a _doing_it_wrong() names the provider. Up to 2.x its nonce check returned true.
  • The nonce is read from $_REQUEST, as check_ajax_referer() does.
  • make:ajax sets $nonceHash = '{ClassName}'. It used to write '', which 3.0 refuses.
  • trusted and notLogged are public by design and unchanged.
  • useHTTPPost() returns unslashed values.
  • Request::verifyNonce() reads a missing _wpnonce as a failed check, not an undefined index.

✨ migrate:to-v3

It also lists:

  • every view with a POST form that does not print csrfField();
  • every direct child of WordPressAjaxServiceProvider, also under the name a use … as gives it, with logged actions and no $nonceHash. A child of the plugin's own base class may inherit one, so it is not listed;
  • every stripslashes() or wp_unslash() in a file that calls useHTTPPost(). That value now comes unslashed, and a second pass corrupts quotes and backslashes: Scotty's preferences JSON would decode to null.

Each form is checked on its own, in the views and in pages/ classes. A page that opts out is not listed, and neither is a form printed as an example inside a PHP string.

Results on the real code:

  • Options and Packages boilerplates: their 4 forms are listed. They change in the 3.0 cascade.
  • Scotty: nothing. Its providers inherit $nonceHash from its own base class.

Tests

  • composer test: 321 tests and 851 assertions, against 307 and 824 on v3. New: CsrfTest, AjaxProviderTest, and two MigrateToV3AccessTest cases that fail on v3's bones.
  • Live, in WPKirk-Developing, with csrf-live-smoke.sh:
    • Setup: an administrator logged in over HTTPS; a route page with a form, a 'csrf' => false route, the bench's menu page, and a logged Ajax action without $nonceHash.
    • v2.1.3: 7 ✗ of 11. A POST without a nonce, or with a forged one, runs store() on the route and the menu page, and the Ajax action answers.
    • This branch: 12 ✓, including the POST with the nonce the form printed.

Review

  • Copilot: the account's quota is exhausted.
  • Codex (gpt-6-sol, high), round 1, three findings:
    • a csrf($token) of the page's own was called;
    • the form check ran file by file instead of form by form;
    • pages/ forms were not checked.
  • Codex, round 2, two false positives in the report: opted-out pages, and printed examples.
  • An independent review, fixed in the last commit:
    • the Ajax alias form;
    • the double unslash;
    • nonces in JSON requests;
    • WordPress's own POSTs;
    • the opt-out's description;
    • the hook-name consistency;
    • two mutations that left the suite green.
  • Out of scope: the first-party wpbones/wptables package runs bulk actions on GET, with no nonce. That is its own repository.
  • composer test: 329 tests and 863 assertions. csrf-live-smoke.sh: 12 ✓. v3-access-live-smoke.sh: 18 ✓.

gfazioli added a commit that referenced this pull request Oct 6, 2026
- a pages/ csrf() is the opt-out only when declared, public and callable
  with no arguments: a csrf($token) of the page's own threw during menu setup;
- migrate:to-v3 checks each POST form on its own (one form with the field
  hid another without it), and the pages/ classes that print their own forms.
gfazioli added a commit that referenced this pull request Oct 6, 2026
…ings

A pages/ class that opts out with a public csrf() returning false is not
listed, and in a view only its HTML counts: a POST form printed as an example
inside a PHP string (htmlentities(), as the Options boilerplate does) is not
one a browser submits. A pages/ class's returned strings still count.
gfazioli added a commit that referenced this pull request Oct 6, 2026
- the nonce may come in the query string or, for a fetch() with a JSON body
  (no $_POST), in an X-WPBones-Nonce header; a logged Ajax action reads it
  from $_REQUEST, as check_ajax_referer() does;
- WordPress's own forms that post to the page they are on, each with a nonce
  of its own, go through: Screen Options and the filesystem credentials form;
- migrate:to-v3 finds the framework's Ajax provider under the name a use
  statement gives it (make:ajax and the boilerplates import it as
  ServiceProvider), and lists every stripslashes()/wp_unslash() in a file
  that calls useHTTPPost(), which now unslashes (Scotty decodes JSON after
  stripslashes: a quote would come back null);
- make:ajax writes $nonceHash = '{ClassName}' (it wrote '', which 3.0 refuses);
- the opt-out is described as what it is, for a page that checks a nonce of
  its own: wp-admin asks for a logged-in browser first, so it is no door for
  requests from elsewhere;
- route and pages/ load callbacks hang on the same hook name as the guard;
- tests: every menu item is guarded, a pages/ POST without the nonce is
  refused (removing either guard used to pass).
Breaking, for 3.0 (audit S4, S5):
- every request to a WP Bones admin page (menu item, route page, pages/
  class) that is not a GET or a HEAD carries the plugin's nonce, in the
  _wpbones_nonce field $plugin->csrfField() prints; Routing\Csrf checks it on
  load, before any load callback, and again before the page renders, and
  answers wp_nonce_ays(). A route that takes requests from elsewhere says
  'csrf' => false; a pages/ class, a public csrf() returning false.
  Up to 2.x store()/update()/destroy() and the load callbacks ran for a
  form posted from another site.
- a logged Ajax action of a provider without $nonceHash (or $nonceKey)
  refuses every request, with a notice; up to 2.x its nonce check returned
  true. useHTTPPost() returns unslashed values.
- Request::verifyNonce() reads a missing _wpnonce as a failed check, not an
  undefined index.
… open Ajax providers

The views whose POST forms do not print $plugin->csrfField(), and the
direct children of WordPressAjaxServiceProvider with logged actions and no
$nonceHash (a child of the plugin's own base class may inherit one, so it is
not listed).
- a pages/ csrf() is the opt-out only when declared, public and callable
  with no arguments: a csrf($token) of the page's own threw during menu setup;
- migrate:to-v3 checks each POST form on its own (one form with the field
  hid another without it), and the pages/ classes that print their own forms.
…ings

A pages/ class that opts out with a public csrf() returning false is not
listed, and in a view only its HTML counts: a POST form printed as an example
inside a PHP string (htmlentities(), as the Options boilerplate does) is not
one a browser submits. A pages/ class's returned strings still count.
- the nonce may come in the query string or, for a fetch() with a JSON body
  (no $_POST), in an X-WPBones-Nonce header; a logged Ajax action reads it
  from $_REQUEST, as check_ajax_referer() does;
- WordPress's own forms that post to the page they are on, each with a nonce
  of its own, go through: Screen Options and the filesystem credentials form;
- migrate:to-v3 finds the framework's Ajax provider under the name a use
  statement gives it (make:ajax and the boilerplates import it as
  ServiceProvider), and lists every stripslashes()/wp_unslash() in a file
  that calls useHTTPPost(), which now unslashes (Scotty decodes JSON after
  stripslashes: a quote would come back null);
- make:ajax writes $nonceHash = '{ClassName}' (it wrote '', which 3.0 refuses);
- the opt-out is described as what it is, for a page that checks a nonce of
  its own: wp-admin asks for a logged-in browser first, so it is no door for
  requests from elsewhere;
- route and pages/ load callbacks hang on the same hook name as the guard;
- tests: every menu item is guarded, a pages/ POST without the nonce is
  refused (removing either guard used to pass).
@gfazioli
gfazioli merged commit 5da20ad into v3 Oct 6, 2026
4 checks passed
@gfazioli
gfazioli deleted the feat/v3-csrf branch October 6, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant