From a73ffcb4c6a0c33b538d65db5b00ba187f2d1e88 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 28 May 2026 13:24:20 +0200 Subject: [PATCH 1/6] REST API: Allow-list ability schema response keywords. --- ...s-wp-rest-abilities-v1-list-controller.php | 42 ++++++++++++------- .../wpRestAbilitiesV1ListController.php | 2 + 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 85464fd9dd302..5beb7389aaeae 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -189,15 +189,21 @@ public function get_item_permissions_check( $request ) { } /** - * WordPress-internal schema keywords to strip from REST responses. + * Additional schema keywords to preserve in REST responses. * - * @since 7.0.0 - * @var array + * These are not included in rest_get_allowed_schema_keywords(), but are + * still recognized as schema traversal locations for ability schemas. + * + * @since 7.1.0 + * @var string[] */ - private const INTERNAL_SCHEMA_KEYWORDS = array( - 'sanitize_callback' => true, - 'validate_callback' => true, - 'arg_options' => true, + private const ADDITIONAL_ALLOWED_SCHEMA_KEYWORDS = array( + 'required', + 'allOf', + 'not', + 'definitions', + 'dependencies', + 'additionalItems', ); /** @@ -217,12 +223,11 @@ private function is_associative_array( $value ): bool { /** * Transforms an ability schema for REST response output. * - * Ability schemas may include WordPress-internal properties like - * `sanitize_callback`, `validate_callback`, and `arg_options` that are - * used server-side but are not valid JSON Schema keywords. This method - * removes those specific keys so they are not exposed in REST responses. - * It also converts empty array defaults to objects when the schema type is - * 'object' to ensure proper JSON serialization as {} instead of []. + * Ability schemas may include WordPress-internal properties or unsupported + * schema keywords that should not be exposed in REST responses. This method + * strips keys not recognized by the REST API schema handling. It also + * converts empty array defaults to objects when the schema type is 'object' + * to ensure proper JSON serialization as {} instead of []. * * @since 7.1.0 * @@ -237,7 +242,16 @@ private function prepare_schema_for_response( array $schema ): array { } } - $schema = array_diff_key( $schema, self::INTERNAL_SCHEMA_KEYWORDS ); + $schema = array_intersect_key( + $schema, + array_fill_keys( + array_merge( + rest_get_allowed_schema_keywords(), + self::ADDITIONAL_ALLOWED_SCHEMA_KEYWORDS + ), + true + ) + ); // Sub-schema maps: keys are user-defined, values are sub-schemas. // Note: 'dependencies' values can also be property-dependency arrays diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 5ba688cb57c79..9a1c95e6589fd 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -846,6 +846,7 @@ public function test_internal_schema_keywords_stripped_from_response(): void { 'content' => array( 'type' => 'string', 'description' => 'The content value.', + 'examples' => array( 'example content' ), 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'is_string', 'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ), @@ -880,6 +881,7 @@ public function test_internal_schema_keywords_stripped_from_response(): void { $this->assertArrayNotHasKey( 'sanitize_callback', $content_schema ); $this->assertArrayNotHasKey( 'validate_callback', $content_schema ); $this->assertArrayNotHasKey( 'arg_options', $content_schema ); + $this->assertArrayNotHasKey( 'examples', $content_schema ); // Verify valid JSON Schema keywords are preserved. $this->assertSame( 'string', $content_schema['type'] ); From 7d7328f08fb2458e103890225865e6e7bd304d15 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 28 May 2026 14:13:58 +0200 Subject: [PATCH 2/6] REST API: Preserve client-side ability schema keywords --- .../class-wp-rest-abilities-v1-list-controller.php | 6 ++++-- .../tests/rest-api/wpRestAbilitiesV1ListController.php | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 5beb7389aaeae..faf712e212ae7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -191,8 +191,9 @@ public function get_item_permissions_check( $request ) { /** * Additional schema keywords to preserve in REST responses. * - * These are not included in rest_get_allowed_schema_keywords(), but are - * still recognized as schema traversal locations for ability schemas. + * Ability schemas are exposed to clients as JSON Schema. Preserve additional + * draft-04 keywords so clients can validate richer schemas, even when some + * of those keywords are not enforced by the server-side REST schema validator. * * @since 7.1.0 * @var string[] @@ -201,6 +202,7 @@ public function get_item_permissions_check( $request ) { 'required', 'allOf', 'not', + '$ref', 'definitions', 'dependencies', 'additionalItems', diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 9a1c95e6589fd..38f251b00ef2f 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -962,6 +962,7 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() 'category' => 'general', 'input_schema' => array( 'type' => 'object', + '$ref' => '#/definitions/address', 'anyOf' => array( array( 'type' => 'object', @@ -1063,6 +1064,7 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() $data = $response->get_data(); // Verify internal keywords are stripped from anyOf sub-schemas. + $this->assertSame( '#/definitions/address', $data['input_schema']['$ref'] ); $this->assertArrayHasKey( 'anyOf', $data['input_schema'] ); $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['anyOf'][0] ); $this->assertSame( 'object', $data['input_schema']['anyOf'][0]['type'] ); From 4145fcec200145d7eb62737cb3105bd7ce8c9d7e Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 28 May 2026 18:14:17 +0200 Subject: [PATCH 3/6] REST API: Expand ability schema keyword tests --- .../wpRestAbilitiesV1ListController.php | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 38f251b00ef2f..0a34f9a40f8d5 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -842,11 +842,15 @@ public function test_internal_schema_keywords_stripped_from_response(): void { 'category' => 'general', 'input_schema' => array( 'type' => 'object', + 'required' => array( 'content' ), 'properties' => array( 'content' => array( 'type' => 'string', 'description' => 'The content value.', + 'example' => 'example content', 'examples' => array( 'example content' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'is_string', 'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ), @@ -855,7 +859,13 @@ public function test_internal_schema_keywords_stripped_from_response(): void { ), 'output_schema' => array( 'type' => 'string', + 'example' => 'example output', + 'examples' => array( 'example output' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'is_string', + 'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ), ), 'execute_callback' => static function ( $input ) { return $input['content']; @@ -876,19 +886,29 @@ public function test_internal_schema_keywords_stripped_from_response(): void { $this->assertArrayHasKey( 'content', $data['input_schema']['properties'] ); $this->assertArrayHasKey( 'output_schema', $data ); - // Verify internal keywords are stripped from input_schema properties. + // Verify unsupported schema keywords are stripped from input_schema properties. $content_schema = $data['input_schema']['properties']['content']; $this->assertArrayNotHasKey( 'sanitize_callback', $content_schema ); $this->assertArrayNotHasKey( 'validate_callback', $content_schema ); $this->assertArrayNotHasKey( 'arg_options', $content_schema ); + $this->assertArrayNotHasKey( 'example', $content_schema ); $this->assertArrayNotHasKey( 'examples', $content_schema ); + $this->assertArrayNotHasKey( 'context', $content_schema ); + $this->assertArrayNotHasKey( 'readonly', $content_schema ); // Verify valid JSON Schema keywords are preserved. $this->assertSame( 'string', $content_schema['type'] ); $this->assertSame( 'The content value.', $content_schema['description'] ); + $this->assertSame( array( 'content' ), $data['input_schema']['required'] ); // Verify internal keywords are stripped from output_schema. $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'arg_options', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'example', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'examples', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'context', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'readonly', $data['output_schema'] ); $this->assertSame( 'string', $data['output_schema']['type'] ); } From 6f76e1c86671c3334a79762d0f0c07811697043f Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 2 Jun 2026 13:13:03 +0200 Subject: [PATCH 4/6] REST API: Cache allow-listed ability schema keywords across recursion `prepare_schema_for_response()` recurses over every node of an ability schema, and each call rebuilt the allow-list lookup via `array_fill_keys( array_merge( ... ) )`. Compute it once into a static and reuse it across the recursive calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...s-wp-rest-abilities-v1-list-controller.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index faf712e212ae7..c1c20b3052350 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -244,17 +244,18 @@ private function prepare_schema_for_response( array $schema ): array { } } - $schema = array_intersect_key( - $schema, - array_fill_keys( - array_merge( - rest_get_allowed_schema_keywords(), - self::ADDITIONAL_ALLOWED_SCHEMA_KEYWORDS - ), - true - ) + // Computed once and reused across the recursive calls for every schema node. + static $allowed_keywords = null; + $allowed_keywords ??= array_fill_keys( + array_merge( + rest_get_allowed_schema_keywords(), + self::ADDITIONAL_ALLOWED_SCHEMA_KEYWORDS + ), + true ); + $schema = array_intersect_key( $schema, $allowed_keywords ); + // Sub-schema maps: keys are user-defined, values are sub-schemas. // Note: 'dependencies' values can also be property-dependency arrays // (numeric arrays of strings) which are skipped via wp_is_numeric_array(). From 102a051d75c97d46f887c27de090608bcf7f0032 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 2 Jun 2026 13:13:03 +0200 Subject: [PATCH 5/6] REST API: Rename ability schema keyword stripping tests The controller now allow-lists supported schema keywords, so the tests verify that unsupported (non-allow-listed) keywords are stripped rather than only WordPress-internal ones. Rename them accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/rest-api/wpRestAbilitiesV1ListController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 0a34f9a40f8d5..8248d65537c2e 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -829,11 +829,11 @@ public function test_filter_by_namespace_still_respects_show_in_rest(): void { } /** - * Test that WordPress-internal schema keywords are stripped from ability schemas in REST response. + * Test that schema keywords outside the allow-list are stripped from ability schemas in REST response. * * @ticket 65035 */ - public function test_internal_schema_keywords_stripped_from_response(): void { + public function test_unsupported_schema_keywords_stripped_from_response(): void { $this->register_test_ability( 'test/with-internal-keywords', array( @@ -969,11 +969,11 @@ public function test_nested_empty_object_schema_defaults_prepared_for_response() } /** - * Test that internal schema keywords are stripped from nested sub-schema locations. + * Test that schema keywords outside the allow-list are stripped from nested sub-schema locations. * * @ticket 64098 */ - public function test_internal_schema_keywords_stripped_from_nested_sub_schemas(): void { + public function test_unsupported_schema_keywords_stripped_from_nested_sub_schemas(): void { $this->register_test_ability( 'test/nested-internal-keywords', array( From e600a20d1f7db19afa54c750dd9cc81ab0505463 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 2 Jun 2026 13:17:25 +0200 Subject: [PATCH 6/6] REST API: Align test ability names with renamed keyword tests Rename the test abilities (and their labels/descriptions) registered by the keyword-stripping tests from `internal` to `unsupported`, matching the allow-list framing of the test method names. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rest-api/wpRestAbilitiesV1ListController.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 8248d65537c2e..9513d372b16d8 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -835,10 +835,10 @@ public function test_filter_by_namespace_still_respects_show_in_rest(): void { */ public function test_unsupported_schema_keywords_stripped_from_response(): void { $this->register_test_ability( - 'test/with-internal-keywords', + 'test/with-unsupported-keywords', array( - 'label' => 'Test Internal Keywords', - 'description' => 'Tests stripping of internal schema keywords', + 'label' => 'Test Unsupported Keywords', + 'description' => 'Tests stripping of unsupported schema keywords', 'category' => 'general', 'input_schema' => array( 'type' => 'object', @@ -875,7 +875,7 @@ public function test_unsupported_schema_keywords_stripped_from_response(): void ) ); - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-internal-keywords' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-unsupported-keywords' ); $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -975,9 +975,9 @@ public function test_nested_empty_object_schema_defaults_prepared_for_response() */ public function test_unsupported_schema_keywords_stripped_from_nested_sub_schemas(): void { $this->register_test_ability( - 'test/nested-internal-keywords', + 'test/nested-unsupported-keywords', array( - 'label' => 'Test Nested Keywords', + 'label' => 'Test Nested Unsupported Keywords', 'description' => 'Tests stripping from all sub-schema locations', 'category' => 'general', 'input_schema' => array( @@ -1076,7 +1076,7 @@ public function test_unsupported_schema_keywords_stripped_from_nested_sub_schema ) ); - $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-internal-keywords' ); + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-unsupported-keywords' ); $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status() );