Fix plugins window stale-nonce 'Cookie check failed' on long-running sessions (#250)#253
Conversation
The desktop shell is a long-running SPA whose per-window config
blobs bake `wp_create_nonce()` into the page at render time. Once
a tab passes WordPress's 24-hour `nonce_life` boundary, the cached
`restNonce` is rejected by `rest_cookie_check_errors()` and the
REST call to `/wp/v2/plugins` fails with `rest_cookie_invalid_nonce`
— surfaced to the user as "Could not load plugins: Cookie check
failed". A hard refresh re-mints the nonce, which is why the
report says "if I refresh it then works".
Fix:
- `includes/nonce-refresh.php` adds a `heartbeat_received` filter
that ships a fresh `{ action: nonce }` map under
`desktop_mode_nonces` on every tick for logged-in users.
Default actions cover `wp_rest`, `desktop-mode-plugins`, and
`updates`. A new `desktop_mode_nonce_refresh_actions` filter
lets other native windows / third-party plugins extend the set.
- `src/nonce-refresh.ts` subscribes via the existing heartbeat
bus and rewrites the cached values in place: shell-wide
`desktopModeConfig.restNonce` plus every per-window blob's
`restNonce`, plus the Plugins window's `ajaxNonce` /
`updatesNonce`. A `registerNonceTarget()` API lets plugin
authors hook additional updaters.
`wp_create_nonce()` is stable within a 12-hour tick, so within
one heartbeat the cached value always reflects the current tick
— well before the 24-hour hard expiry catches it.
Tests:
- `tests/vitest/nonce-refresh.test.ts` — 9 tests covering shell +
per-window refresh, idempotent boot, payload sanity, custom
targets, and throw isolation.
- `tests/phpunit/tests/nonceRefresh.php` — 7 tests covering
anonymous skip, default action set, payload preservation, and
the filter's add / remove / sanitize behavior.
Docs:
- `docs/hooks-reference.md` documents the new
`desktop_mode_nonce_refresh_actions` filter.
- `docs/javascript-reference.md` documents `registerNonceTarget()`.
|
@claude Can you please review this PR with a focus on security implications? |
|
Claude finished @epeicher's task in 3m 2s —— View job Security Review of PR #253 — Stale Nonce RefreshTasks:
Overall assessmentThe fix is mechanically sound and follows the established Gutenberg/Core pattern for keeping cached nonces alive across a long-running SPA. The data flow is server-controlled, the standard WordPress nonce API is used throughout, and neither PHP nor JS introduces a new exploitable surface. The findings below are graded Medium, Low, and Positive — nothing blocks the merge. 🟡 Medium —
|
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
- includes/nonce-refresh.php: gate the heartbeat handler on desktop_mode_is_enabled() so users on classic admin screens who never opted into Desktop Mode don't carry the payload around. Adds a PHPUnit case covering the opted-out path. - src/nonce-refresh.ts: reclassify registerNonceTarget() as @internal. Third-party plugin bundles can't import from the main bundle's module graph, so documenting it as a public API was a dead-end trail. The public extension surface is the existing wp.desktop.heartbeat.subscribe('desktop_mode_nonces', cb) which is already exposed on the public facade. - docs/javascript-reference.md: rewrite the section to show the heartbeat-subscribe recipe as the supported pattern, and note that the shell-wide + per-window restNonces are refreshed automatically with no plugin-author work needed. - docs/hooks-reference.md: redirect the client-side note to the heartbeat subscription doc rather than the internal helper.
|
Addressed the two flagged items in b976264: 🟡 Medium — Reclassified as 🟡 Low — heartbeat handler fires for non-shell users Gated on 🟢 Low/Info — console.error reveals action strings Left as-is — agreed it's an acceptable trade-off and the action names in our default set ( Test status: |
Summary
nonce_lifeboundary.heartbeat_receivedfilter ships{ action: nonce }underdesktop_mode_nonces. Filterdesktop_mode_nonce_refresh_actionslets other windows / plugins extend the set.desktopModeConfig.restNonce, every per-window blob'srestNonce, and the Plugins window'sajaxNonce/updatesNoncein place. NewregisterNonceTarget()API for plugin authors.Fixes #250.
Root cause
includes/plugins-window/window.php:134bakeswp_create_nonce('wp_rest')into the per-window config blob at page render. The desktop shell is a long-running SPA — once the tab is open pastnonce_life(24h), the cachedrestNonceis rejected byrest_cookie_check_errors()and/wp/v2/pluginsreturnsrest_cookie_invalid_nonce, surfaced as "Could not load plugins: Cookie check failed". A hard refresh re-mints the nonce, which is exactly what the bug report describes. The same shape exists in every window that stashes arestNonce(Posts, Pages, Users, Comments, …); thewp_resttarget in this fix refreshes all of them generically.Why heartbeat refresh
wp_create_nonce()returns a stable value within a 12h tick window, so refreshing on every tick (≤60s in the background) means the cached value always reflects the current tick — well before the 24h hard expiry catches it. Same mechanism Gutenberg/Core already use for their own nonces.Test plan
npm run lint— clean.tsc --noEmit— clean.npm run build— clean.npm run test:js— 1464/1464 pass (9 new tests intests/vitest/nonce-refresh.test.ts).npm run test:php -- --filter=Tests_DesktopMode_NonceRefresh— 7 new tests; could not run locally (port collision with another worktree's wp-env). CI will exercise.window.desktopModeWindowConfig['desktop-mode-plugins'].restNonce = 'broken', try opening Plugins → fails with "Cookie check failed". Wait ~15s for one Heartbeat tick. Reopening Plugins succeeds without a page reload.Docs updated
docs/hooks-reference.md— newdesktop_mode_nonce_refresh_actionsfilter.docs/javascript-reference.md—registerNonceTarget()surface.