Skip to content

REST API: Validate and sanitize every consumed route parameter - #738

Open
obenland wants to merge 4 commits into
WordPress:trunkfrom
obenland:fix/rest-route-arg-schemas
Open

REST API: Validate and sanitize every consumed route parameter#738
obenland wants to merge 4 commits into
WordPress:trunkfrom
obenland:fix/rest-route-arg-schemas

Conversation

@obenland

@obenland obenland commented Jul 25, 2026

Copy link
Copy Markdown
Member

An audit of every `register_rest_route()` call in the repo found 22 route registrations whose callbacks consume request parameters that were never declared in an `args` schema, leaving them without validation or sanitization at the route layer. The wporg-login routes were already fixed in r15003; this PR closes out the rest.

Two validation postures are used deliberately:

  • Coerce, don't reject on routes that api.wordpress.org proxies raw client input into (`query-plugins`, plugin info `locale`): custom sanitize callbacks (`absint`, `sanitize_text_field`, `wp_parse_list`, locale character filtering) replace strict schema validation, so today's tolerated garbage (`per_page=`, unserialized ints for string params) keeps returning 200 instead of cascading into a `Query Failed.` 500 with a negative cache on api.wordpress.org.
  • Validate strictly everywhere else (internal, capability-gated, or zero-caller routes), where a 400 for malformed input is the desired contract.

Plugin Directory

  • query-plugins (public, backs api.wordpress.org): declared all 11 consumed parameters with coercing sanitizers per the above.
  • update-stats: `plugins` is a required object, so a scalar body returns a 400 instead of a PHP warning from `foreach` — but entries are unconstrained and the handler now skips malformed ones, so one bad entry cannot reject a whole cron batch.
  • committers/support-reps POST: `committer`/`support_rep` are now required strings, matching the validation the DELETE routes already had. Route-placeholder args moved to shared route-level `args`.
  • blueprint.json: declared `zip_hash`, `url_hash`, `type`, and `lang` — the hash parameters act as the endpoint's authorization and were previously undeclared.
  • release-confirmation: `confirmations_required` typed as integer; `rollout_strategy` constrained to the enum from `Template::get_rollout_strategies()` instead of being stored raw.
  • gandalf-scan: declared the callback body schema. Fields are typed but not required and tolerate nulls, so malformed callbacks still reach the handler, which records them to the `_gandalf_scan_last_error` audit trail and clears pending scans — rejecting at the REST layer would silently destroy that trail. The handler now guards its dereferences accordingly.
  • Also declared: `plugin-review` (`plugin_id`, `token`), `plugin` info `locale`, `upload` `ID`, `self-toggle-preview` `dismiss`.

Theme Directory

  • themes/1.x query/info/tags (public; note api.wordpress.org's theme API calls `Themes_API` directly and does not pass through these REST routes): declared every parameter `Themes_API` accepts, with multi-type schemas (`string|array`) where the API tolerates both. `wp_version` is declared only on the features endpoint, the one action that consumes it; `cache_buster` declared on commercial-shops.
  • stats endpoints (public): declared `startDate`, which reaches `strtotime()` and a prepared SQL query.
  • update-stats: `themes` is a required object with unconstrained entries; the handler skips malformed entries (same batch fail-open behavior as the plugin directory).
  • github auto-review: declared `theme_slug` and `ticket_id`. Note: typing `ticket_id` as integer changes the XML-RPC wire type from `` to `` on the Trac call — int is the documented Trac RPC type, but worth a one-ticket smoke test after deploy.
  • preview-blueprint POST: declared the `blueprint` payload as required; also fixed two `'required' => 'true'` string-literal typos.
  • Added a shared `sanitize_locale()` helper in the REST namespace.

Also Viewing (support forums)

  • `page` (an unconstrained `.+` URL capture) is now a required, route-level arg sanitized with the plugin's own `sanitize_page_url_for_db()`; `isTyping` is typed as boolean on the POST handler.

Coding standards

New test files pass the full `--standard=WordPress` ruleset with one structural exception: PHPUnit derives test class names from file basenames and class names cannot contain hyphens, so test files can never satisfy the hyphenated-lowercase filename sniff (renaming them makes PHPUnit silently skip every test). `phpcs.xml.dist` now carries the same tests-scoped exemption WordPress core uses.

Tests

One functional test suite per endpoint file (18 test files plus a shared base class per directory, loaded from each suite's bootstrap), testing what the endpoints do: querying finds published plugins and honors the `slug:` bypass and pagination, the info endpoints return plugin/theme data, update-stats writes whitelisted meta and skips unknown slugs and malformed entries, the tags endpoint lists and slices tags, slug changes work for plugin authors and reject invalid slugs, blueprints are generated for themes, gandalf callbacks are recorded, and every gated endpoint enforces its bearer token or capability check. Malformed-input behavior is pinned per posture: coercion on the proxied routes, 400s on the strict ones.

Writing these surfaced two dormant bugs, fixed here: the theme info endpoint's 404 branch read a nonexistent `$api->error` property so unknown themes returned 200 (now reads the error from the API response), and the plugin info endpoint emitted PHP warnings when review data is unavailable (now guarded in `get_plugin_reviews_markup()`).

Known limitations, deliberately out of scope: the upload endpoint's real inputs (`$_FILES['zip_file']`, `$_POST['comment']`, `$_REQUEST['upload_token']`) are read from superglobals inside `Upload_Handler` and are invisible to route schemas — declaring them without changing the read path would be decorative; and api.wordpress.org's 48-hour negative caching of failed internal `plugin_information` dispatches (`class-plugins-info-api.php`) predates this PR and deserves its own change.

Both suites pass under wp-env with no warnings: plugin-directory 191 tests, theme-directory 40 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2

Copilot AI review requested due to automatic review settings July 25, 2026 14:10

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props obenland.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@obenland obenland changed the title REST API: Stop consuming request parameters that were never declared or validated REST API: Validate and sanitize every consumed route parameter Jul 25, 2026
@obenland
obenland force-pushed the fix/rest-route-arg-schemas branch from 95aaea7 to 4fbee73 Compare July 25, 2026 14:41
@obenland

Copy link
Copy Markdown
Member Author

All new test files now pass the full --standard=WordPress ruleset (docblocks, formatting, and structure), with one deliberate exception: the file-name hyphenation sniff. PHPUnit 11 discovers test classes by deriving the class name from the file basename, and PHP class names cannot contain hyphens, so a hyphenated test file can never contain the class PHPUnit looks for — renaming the files to class-*-test.php silently dropped every new test from the run. WordPress core carries the same exemption for its test suite, so this PR adds the equivalent scoped rule to phpcs.xml.dist (WordPress.Files.FileName.NotHyphenatedLowercase excluded for */tests/* only), which also unblocks the failing PHPCS check.

🤖 Generated with Claude Code

@obenland
obenland force-pushed the fix/rest-route-arg-schemas branch from 4fbee73 to 048f135 Compare July 25, 2026 14:52
PHPUnit discovers test classes by deriving the class name from the
file basename, and PHP class names cannot contain hyphens, so test
files can never satisfy the hyphenated-lowercase file name sniff.
WordPress core carries the same exemption for its test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2
@obenland
obenland force-pushed the fix/rest-route-arg-schemas branch 2 times, most recently from 94a04cf to c38c4ca Compare July 25, 2026 14:59
obenland and others added 3 commits July 25, 2026 11:28
Several plugins/v1 routes consumed request parameters that were never
declared in their route registration, leaving them without validation
or sanitization: query-plugins fed eleven undeclared parameters into
WP_Query, update-stats iterated an untyped body parameter, and locale
values reached switch_to_locale() unchecked. Declares every consumed
parameter with types, requirements, and validate/sanitize callbacks,
moving URL placeholder args to shared route-level declarations.

Adds functional test suites for each endpoint, exercising queries,
stat updates, slug changes, scan callbacks, and access control
end-to-end. The tests also surfaced that the plugin information
endpoint emitted warnings when review data is unavailable, now
guarded in get_plugin_reviews_markup().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2
The themes/1.x query, info, and tags endpoints passed all request
parameters into Themes_API without route-level declarations, the
public stats endpoints consumed an undeclared startDate, update-stats
iterated an untyped body parameter, and the preview-blueprint POST
route persisted an undeclared blueprint payload. Declares every
consumed parameter with types and validate/sanitize callbacks, and
fixes two 'required' => 'true' string typos.

Adds functional test suites for each endpoint, exercising theme
queries, information lookups, tags, stats, stat updates, blueprints,
and access control end-to-end. The tests also surfaced that the info
endpoint's 404 handling checked a nonexistent property and never
fired, now reading the error from the API response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2
The route consumed the page placeholder and the isTyping flag without
declaring them, leaving the unconstrained URL capture without a
sanitizer at the route layer. Declares page as a required, sanitized
route-level argument shared by all three methods, and types isTyping
as a boolean on the POST handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2
@obenland
obenland force-pushed the fix/rest-route-arg-schemas branch from c38c4ca to b929d15 Compare July 25, 2026 16:28
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