Skip to content

fix(rest): gate per-user REST routes on desktop mode enabled#285

Merged
epeicher merged 6 commits into
trunkfrom
fix/rest-permission-desktop-mode-gate
Jun 3, 2026
Merged

fix(rest): gate per-user REST routes on desktop mode enabled#285
epeicher merged 6 commits into
trunkfrom
fix/rest-permission-desktop-mode-gate

Conversation

@epeicher

@epeicher epeicher commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

What

Hardens the permission gate on Desktop Mode's per-user REST routes so they require the caller to have actually entered Desktop Mode, not merely to be logged in.

Previously these routes gated only on is_user_logged_in() plus current_user_can( 'read' ):

  • GET/POST /desktop-mode/v1/os-settings
  • GET/POST/DELETE /desktop-mode/v1/session
  • POST /desktop-mode/v1/default-window
  • POST /desktop-mode/v1/intros/seen, DELETE /desktop-mode/v1/intros
  • GET/POST /desktop-mode/v1/pwa-state

Every authenticated role carries read, so any logged-in user (including a Subscriber who never opened the desktop) could read and write through these endpoints.

Why this is a hardening, not a data-exposure fix

Each handler is hard-scoped to get_current_user_id() and accepts no target-user parameter, so a caller can only ever touch their own per-user state. There is no cross-user read or write, and no path to another account's data (verified, see below). This is a missing-gate / defense-in-depth issue (CWE-862, broken access control): accounts that never use Desktop Mode should not be able to write Desktop Mode state.

Change

  • New shared gate desktop_mode_rest_require_enabled() in includes/helpers.php: logged in and desktop_mode_is_enabled(), returning 401 when logged out and 403 when desktop mode is off.
  • All six per-user permission callbacks route through it. The presence callback already used this exact shape, so it now delegates to the shared helper (one implementation instead of seven copies).

The gate intentionally keys on "desktop mode enabled" rather than a role/capability floor. That is the documented extension point: sites that want to deny Desktop Mode to specific roles use the desktop_mode_mode_enabled filter (docs/examples/gate-by-role.md), which now also denies these routes for those users.

Tests

  • Unit tests for the new gate, including a regression test that a logged-in user without desktop mode is denied (403).
  • Affected REST tests now opt their users into desktop mode so they exercise the route body rather than stopping at the gate; logged-out assertions updated to 401.
  • Full PHPUnit suite green: 900 tests, 2119 assertions.

Manual verification (local wp-env)

Against a Subscriber account:

Scenario Result
Subscriber, desktop mode not enabled, all six route families 403 (was 200)
Logged out 401
Subscriber with desktop mode enabled 200, but only their own data
Admin's stored AI key readable by the Subscriber No (planted a secret on the admin, confirmed it never appears in the Subscriber's response)

Docs

  • includes/rest/README.md route table + conventions updated.
  • docs/architecture.md session-routes permission note updated.

Out of scope (flagged for separate decision)

  • /my-wordpress/terms/* (term-stats.php) has the same loose read gate, but its batch sibling /term-counts already uses edit_posts, so the right fix there is likely edit_posts rather than the desktop-mode gate.
  • /ai/search is gated on login plus the AI feature flag and has a documented programmatic surface; left untouched here.

No plugin version bump in this PR (release step). New code is tagged @since 0.8.10.

Open WordPress Playground Preview

Several per-user REST routes (os-settings, session, default-window,
seen-intros, pwa-state) gated only on is_user_logged_in() plus the
'read' capability. Because every authenticated role carries 'read',
any logged-in user could reach these endpoints even without having
entered Desktop Mode. The routes are already scoped to the current
user via get_current_user_id(), so this is a missing-gate hardening
(defense in depth) rather than cross-user data exposure.

Introduce a shared desktop_mode_rest_require_enabled() permission gate
(logged in plus desktop_mode_is_enabled(), returning 401 when logged
out and 403 when desktop mode is off) and route all six per-user
callbacks through it, converging the presence callback that already
used this shape onto the shared helper.

Add unit tests for the gate (including a regression test that a
logged-in user without desktop mode is denied) and update the affected
REST tests to opt their users into desktop mode. Update the REST route
index and architecture docs to describe the new requirement.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens Desktop Mode’s per-user REST endpoints so they can only be used by authenticated users who have actually enabled Desktop Mode (desktop_mode_is_enabled()), rather than any logged-in user with the ubiquitous read capability.

Changes:

  • Introduces a shared REST permission helper desktop_mode_rest_require_enabled() that returns 401 when logged out and 403 when Desktop Mode is not enabled.
  • Updates the permission callbacks for the affected per-user REST route families to delegate to the shared helper.
  • Updates PHPUnit tests and documentation to reflect the new gate behavior and status codes.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
includes/helpers.php Adds shared REST permission gate desktop_mode_rest_require_enabled() returning `true
includes/session.php Routes session REST permission callback through the shared gate and updates docblock.
includes/seen-intros.php Routes seen-intros REST permission callback through the shared gate and updates docblock.
includes/default-window.php Switches default-window route permission callback to the shared gate.
includes/os-settings.php Routes OS settings permission callback through the shared gate and updates docblock.
includes/pwa.php Routes PWA state REST permission callback through the shared gate and updates docblock.
includes/presence.php Replaces the local presence permission implementation with the shared gate.
tests/phpunit/tests/desktopMode.php Adds unit tests for the new shared REST gate (401/403/allow).
tests/phpunit/tests/desktopModeSession.php Updates session REST permission tests for WP_Error and adds a regression test for 403 when not enabled; opts user into Desktop Mode in setup.
tests/phpunit/tests/desktopModeDefaultWindow.php Opts the test user into Desktop Mode in setup so tests reach the route body.
tests/phpunit/tests/seenIntros.php Opts the test user into Desktop Mode in setup so tests reach the route body.
includes/rest/README.md Updates documented permissions for affected routes (but still needs route/verb accuracy fixes).
docs/architecture.md Updates session route permission documentation to reflect the new shared gate and status codes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread includes/rest/README.md Outdated
Comment thread includes/rest/README.md Outdated
Comment thread includes/os-settings.php Outdated
@epeicher epeicher force-pushed the fix/rest-permission-desktop-mode-gate branch from 3fe8f51 to 5688ebf Compare June 2, 2026 18:22
epeicher added 2 commits June 2, 2026 20:24
Fix pre-existing inaccuracies flagged in PR review:
- /default-window is POST only (not GET/POST)
- seen-intros routes are POST /intros/seen and DELETE /intros (no /seen-intros)
- pwa route is /pwa-state (GET/POST), not /pwa/*
- /presence is GET and POST (not GET only)
@epeicher epeicher enabled auto-merge (squash) June 3, 2026 08:12
@epeicher epeicher merged commit b0736ad into trunk Jun 3, 2026
5 checks passed
@epeicher epeicher deleted the fix/rest-permission-desktop-mode-gate branch June 3, 2026 08:14
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.

2 participants