Commit 9615999e8e for wordpress.org
commit 9615999e8ef408ed8c42221c6184eff8a3580e1a
Author: gziolo <gziolo@git.wordpress.org>
Date: Fri Oct 30 08:51:56 2026 +0000
Abilities API: Share input coercion across execution paths
Introduce WP_Ability::coerce_input() to apply schema-based type coercion and format sanitization after validation and before permission checks and execution.
Reuse the shared implementation in the REST controller, including for normalized defaults and filter-provided input. Return ability_invalid_input on coercion failures, including nested errors, instead of falling back to raw input.
Direct execution now follows REST type semantics, including converting number values to floats. Add regression coverage for coercion, lifecycle ordering, and REST error handling.
Developed in https://github.com/WordPress/wordpress-develop/pull/14882.
Props gziolo, jorgefilipecosta.
Fixes #66383.
Built from https://develop.svn.wordpress.org/trunk@65044
git-svn-id: http://core.svn.wordpress.org/trunk@64194 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-includes/abilities-api/class-wp-ability.php b/wp-includes/abilities-api/class-wp-ability.php
index 8b007de170..a9f53586d4 100644
--- a/wp-includes/abilities-api/class-wp-ability.php
+++ b/wp-includes/abilities-api/class-wp-ability.php
@@ -562,6 +562,76 @@ class WP_Ability {
return apply_filters( 'wp_ability_normalize_input', $input, $this->name, $this );
}
+ /**
+ * Coerces validated input to the types declared in the input schema.
+ *
+ * Also applies schema format sanitization, such as stripping tags for
+ * `text-field` and sanitizing URLs for `uri`, using rest_sanitize_value_from_schema().
+ *
+ * Call validate_input() before this method. Coercing unvalidated input can
+ * remove unknown properties or cast invalid values, hiding validation errors.
+ * This method does not apply defaults or run input validation filters.
+ *
+ * If sanitization produces an error, including in a nested value, an invalid
+ * input error is returned to prevent execution with uncoerced values.
+ *
+ * @since 7.2.0
+ *
+ * @see validate_input()
+ *
+ * @param mixed $input Validated ability input.
+ * @return mixed|WP_Error Coerced input, or WP_Error when it cannot be safely coerced.
+ */
+ public function coerce_input( $input ) {
+ $schema = $this->get_input_schema();
+ if ( null === $input || empty( $schema ) ) {
+ return $input;
+ }
+
+ $sanitized = rest_sanitize_value_from_schema( $input, $schema, 'input' );
+
+ // Values unique before coercion can collide after casting, even in nested arrays.
+ $error = self::get_input_error( $sanitized );
+ if ( is_wp_error( $error ) ) {
+ return new WP_Error(
+ 'ability_invalid_input',
+ sprintf(
+ /* translators: %1$s ability name, %2$s error message. */
+ __( 'Ability "%1$s" has invalid input. Reason: %2$s' ),
+ $this->name,
+ $error->get_error_message()
+ )
+ );
+ }
+
+ return $sanitized;
+ }
+
+ /**
+ * Retrieves the first WP_Error in a sanitized value.
+ *
+ * @since 7.2.0
+ *
+ * @param mixed $value The value to inspect.
+ * @return WP_Error|null The first error found, or null if the value contains no errors.
+ */
+ private static function get_input_error( $value ): ?WP_Error {
+ if ( is_wp_error( $value ) ) {
+ return $value;
+ }
+
+ if ( is_array( $value ) ) {
+ foreach ( $value as $item ) {
+ $error = self::get_input_error( $item );
+ if ( is_wp_error( $error ) ) {
+ return $error;
+ }
+ }
+ }
+
+ return null;
+ }
+
/**
* Validates input data against the input schema.
*
@@ -860,12 +930,13 @@ class WP_Ability {
}
/**
- * Executes the ability after input validation and running a permission check.
+ * Executes the ability after input validation, coercion, and a permission check.
* Before returning the return value, it also validates the output.
*
* @since 6.9.0
* @since 7.1.0 Added the `wp_ability_invoked` action.
* @since 7.1.0 Added the `wp_pre_execute_ability` filter.
+ * @since 7.2.0 Coerces validated input before permission checks and execution.
*
* @param mixed $input Optional. The input data for the ability. Default `null`.
* @return mixed|WP_Error The result of the ability execution, or WP_Error on failure.
@@ -925,6 +996,11 @@ class WP_Ability {
return $is_valid;
}
+ $input = $this->coerce_input( $input );
+ if ( is_wp_error( $input ) ) {
+ return $input;
+ }
+
$has_permissions = $this->check_permissions( $input );
if ( true !== $has_permissions ) {
if ( is_wp_error( $has_permissions ) ) {
@@ -950,7 +1026,7 @@ class WP_Ability {
* @since 7.1.0 Added the `$ability` parameter.
*
* @param string $ability_name The name of the ability.
- * @param mixed $input The input data for the ability.
+ * @param mixed $input The validated and coerced input data for the ability.
* @param WP_Ability $ability The ability instance.
*/
do_action( 'wp_before_execute_ability', $this->name, $input, $this );
@@ -972,7 +1048,7 @@ class WP_Ability {
* @since 7.1.0 Added the `$ability` parameter.
*
* @param string $ability_name The name of the ability.
- * @param mixed $input The input data for the ability.
+ * @param mixed $input The validated and coerced input data for the ability.
* @param mixed $result The result of the ability execution.
* @param WP_Ability $ability The ability instance.
*/
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php
index 12f6a75083..ac3f8eb5c6 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php
@@ -183,6 +183,11 @@ class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller {
return $this->ensure_error_status( $is_valid, 400 );
}
+ $input = $ability->coerce_input( $input );
+ if ( is_wp_error( $input ) ) {
+ return $this->ensure_error_status( $input, 400 );
+ }
+
$result = $ability->check_permissions( $input );
if ( is_wp_error( $result ) ) {
$result->add_data( array( 'status' => rest_authorization_required_code() ) );
@@ -240,17 +245,18 @@ class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller {
/**
* Sanitizes the run input by coercing it to the ability's input schema.
*
- * Registered as the `input` argument `sanitize_callback` so that both
- * `check_ability_permissions()` and `execute_ability()` receive natively typed input
- * regardless of transport.
+ * Registered as the `input` argument `sanitize_callback` so request input is coerced
+ * before any route callback runs, regardless of transport. `check_ability_permissions()`
+ * and `WP_Ability::execute()` coerce the input again after normalization and validation,
+ * so schema defaults and values from normalization filters are coerced too.
*
* @since 7.1.0
*
- * @see WP_REST_Abilities_V1_Run_Controller::coerce_input_to_schema()
+ * @see WP_Ability::coerce_input()
*
* @param mixed $input Raw input extracted from the request.
* @param WP_REST_Request $request The request object.
- * @return mixed Coerced input, or the raw input when it cannot be safely coerced.
+ * @return mixed|WP_Error Coerced input, raw input when validation fails, or WP_Error when coercion fails.
*/
public function sanitize_input_for_ability( $input, $request ) {
$ability = wp_has_ability( $request['name'] ) ? wp_get_ability( $request['name'] ) : null;
@@ -258,89 +264,12 @@ class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller {
return $input;
}
- return $this->coerce_input_to_schema( $input, $ability );
- }
-
- /**
- * Coerces raw request input to the types declared in the ability input schema.
- *
- * GET and DELETE deliver every scalar as a string ("10", "true") and a list as a single
- * comma-separated string, so without coercion an ability receives raw strings where its
- * schema declares integers, booleans, or arrays.
- *
- * Coercion never changes what validation accepts. Input is coerced only when
- * {@see WP_Ability::validate_input()} already accepts it, and any error surfaced while
- * sanitizing falls back to the raw input, so `validate_input()` stays the single authority
- * on what is rejected.
- *
- * @since 7.1.0
- *
- * @param mixed $input Raw input extracted from the request.
- * @param WP_Ability $ability The ability being executed.
- * @return mixed Coerced input, or the raw input when it cannot be safely coerced.
- */
- private function coerce_input_to_schema( $input, WP_Ability $ability ) {
- if ( null === $input ) {
- return $input;
- }
-
- $schema = $ability->get_input_schema();
- if ( empty( $schema ) ) {
- return $input;
- }
-
- /*
- * Only coerce input that already validates. Sanitizing invalid input can silently
- * change which values are accepted -- `additionalProperties: false` strips unknown
- * keys, and a non-numeric string casts to 0 -- so leaving invalid input untouched
- * lets validate_input() reject it exactly as it does without coercion.
- *
- * validate_input() is asked rather than rest_validate_value_from_schema() so that the
- * `wp_ability_validate_input` filter decides what counts as valid here as well. A filter
- * that overrides a schema failure accepts the input, so the input is coerced; a filter
- * that rejects otherwise valid input leaves it untouched for validate_input() to report.
- */
- if ( is_wp_error( $ability->validate_input( $input ) ) ) {
- return $input;
- }
-
- $sanitized = rest_sanitize_value_from_schema( $input, $schema, 'input' );
-
- /*
- * Sanitizing can still surface an error the lenient validation above did not, such as
- * items that are unique as strings but collide once cast to integers (`uniqueItems`).
- * The error may be returned at the top level or nested inside the returned array, so
- * scan recursively and fall back to the raw input on any error.
- */
- if ( $this->input_contains_error( $sanitized ) ) {
+ // Validate before coercion so sanitization cannot rescue invalid input.
+ if ( null === $input || empty( $ability->get_input_schema() ) || is_wp_error( $ability->validate_input( $input ) ) ) {
return $input;
}
- return $sanitized;
- }
-
- /**
- * Determines whether a sanitized value is, or contains, a WP_Error.
- *
- * @since 7.1.0
- *
- * @param mixed $value The value to inspect.
- * @return bool True if the value is, or contains, a WP_Error.
- */
- private function input_contains_error( $value ): bool {
- if ( is_wp_error( $value ) ) {
- return true;
- }
-
- if ( is_array( $value ) ) {
- foreach ( $value as $item ) {
- if ( $this->input_contains_error( $item ) ) {
- return true;
- }
- }
- }
-
- return false;
+ return $ability->coerce_input( $input );
}
/**
diff --git a/wp-includes/version.php b/wp-includes/version.php
index ec98be8014..c9ae58e037 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-beta3-65043';
+$wp_version = '7.2-beta3-65044';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.