Opened 2 days ago
Closed 29 hours ago
#65594 closed defect (bug) (fixed)
Abilities API: REST run requests pass untyped input to ability callbacks
| Reported by: | jorgefilipecosta | Owned by: | jorgefilipecosta |
|---|---|---|---|
| Priority: | normal | Milestone: | 7.1 |
| Component: | Abilities API | Version: | |
| Severity: | normal | Keywords: | has-patch has-unit-tests commit |
| Cc: | Focuses: |
Description
The Abilities API run endpoint (WP_REST_Abilities_V1_Run_Controller) hands an ability's
permission_callback and execute_callback the input exactly as it came off the request,
without coercing it to the types declared in the ability's input_schema.
Over GET and DELETE, PHP delivers every scalar in the query string as a string, and a
comma-separated list as a single string. WP_Ability::validate_input() is lenient enough to
accept "10" where the schema declares integer, but validation does not mutate the
value, so the callback still receives "10" and not 10.
Steps to reproduce
- Register an ability with
show_in_rest, areadonlyannotation, and this input schema:
<?php 'input_schema' => array( 'type' => 'object', 'properties' => array( 'count' => array( 'type' => 'integer' ), 'flag' => array( 'type' => 'boolean' ), 'tags' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), ), ),
- Request it over
GET:
/wp-json/wp-abilities/v1/abilities/test/typed-input/run?input[count]=10&input[flag]=true&input[tags]=a,b
- Inspect the
$inputtheexecute_callbackreceives.
Expected: 10 (integer), true (boolean), array( 'a', 'b' ) (array).
Actual: "10", "true", "a,b" — all strings.
Consequences
- Strict comparisons in ability callbacks silently fail:
true === $input['flag']is never true when the value arrives as"true". - Behavior depends on transport. The same ability invoked over
POST(JSON body, native types) and overGET(query string, strings) receives different PHP types for the same logical input. - Every read ability has to hand-roll coercion —
(int),wp_parse_list(),'true' === $value
Proposed fix
Coerce the extracted input against the ability's registered input schema at the run controller
boundary, before validation and execution, on both the permission and the execution path.
Coercion belongs in the controller rather than in WP_Ability: it is a REST-transport artifact,
and direct PHP callers already pass typed values. Sanitizing against get_input_schema() — the
same registered snapshot validate_input() runs against — keeps coercion and validation in
agreement.
Coercion must be non-destructive with respect to validation:
- Only coerce input that already validates. Sanitizing invalid input would otherwise rescue
it:
additionalProperties: falseerrors inrest_validate_value_from_schema()but only strips inrest_sanitize_value_from_schema(), and a non-numeric string casts to0. - If sanitizing still surfaces an error the lenient validation did not — for example
["1", "01"]underuniqueItemswithitems: integer, unique as strings but colliding once cast — including an error nested inside the returned array, fall back to the raw input unchanged.
validate_input() therefore remains the single authority on what is accepted, and still emits the
user-facing ability_invalid_input (400). null input and abilities without an input schema pass
through untouched.
Values injected by a wp_ability_normalize_input filter run after this coercion and are not
coerced. Centralizing that in WP_Ability::normalize_input() would also change behavior for direct
PHP callers, so it is left out of scope.
Follow-up
Once this lands, the per-ability coercion in core/read-users and core/read-content (AI plugin, and core PR's) can be removed and their coercion tests re-pointed at the controller.
PR: https://github.com/WordPress/wordpress-develop/pull/12437
Change History (3)
This ticket was mentioned in PR #12437 on WordPress/wordpress-develop by @jorgefilipecosta.
2 days ago
#1
- Keywords has-unit-tests added
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
## What
Coerce the input of an Abilities REST _run_ request to the types declared in the ability's
input_schemabefore the ability runs, sopermission_callbackandexecute_callbackreceive natively typed input (10not"10",truenot"true",["a","b"]not"a,b") regardless of transport.## Why
Over
GET/DELETE, PHP delivers every scalar in the query string as a string, and a comma-separated list as a single string.WP_Ability::validate_input()is lenient enough to _accept_"10"as an integer but does not mutate it, so the ability callback received raw strings. Strict checks such astrue === $input['flag']silently failed, and every read ability had to hand-roll its own coercion ((int),wp_parse_list(),'true'→bool). This centralizes that at the REST boundary.## Approach
Coercion runs in
WP_REST_Abilities_V1_Run_Controller— a REST-transport artifact, so it stays out of the transport-agnosticWP_Ability(direct PHP callers already pass typed values). It sanitizes againstget_input_schema(), the same registered snapshotvalidate_input()uses, so coercion and validation always agree. It is applied on both the permission and execution paths through the sharedget_input_from_request(), which now optionally receives the resolved ability.Coercion is non-destructive with respect to validation:
additionalProperties: falsewould otherwise silently strip unknown keys (it errors inrest_validate_value_from_schema()but only strips inrest_sanitize_value_from_schema()), and a non-numeric string would cast to0.uniqueItems— including one nested inside the returned array, the raw input is returned unchanged.validate_input()therefore remains the single authority on what is accepted and still emits the user-facingability_invalid_inputerror.nullinput and abilities without an input schema pass through untouched.Values _injected_ by a
wp_ability_normalize_inputfilter run after this coercion and are not coerced; a future framework-level centralization inWP_Ability::normalize_input()could cover that, at the cost of changing behavior for direct PHP callers.## Testing instructions
New cases in
tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.phpcover:GETscalar→int/bool and comma-separated→array coerciononeOfbranch resolution with per-branch coercion (id mode vs collection mode)400 ability_invalid_input(unknown property underadditionalProperties: false, and a non-numeric integer)POSTinput left unchangeduniqueItemsfall-back to raw inputReverting only the controller change makes the two coercion assertions fail (
stringvsinteger), demonstrating the fix.## Follow-up (separate PR)
Once this lands, the per-ability coercion in
core/read-usersandcore/read-contentcan be simplified/removed and their coercion tests re-pointed at the controller.