From c22d026e87ffce4eb27a46ebad36fa3218e5cd8c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 13:37:25 +0100 Subject: [PATCH 1/9] REST API: Strip internal schema keywords from ability REST responses. Ability `input_schema` and `output_schema` may contain WordPress-internal properties like `sanitize_callback`, `validate_callback`, and `arg_options` that are not valid JSON Schema keywords. These cause client-side JSON Schema validators to fail. Strip non-standard keywords recursively using the same allowlist approach (`rest_get_allowed_schema_keywords()`) that `WP_REST_Server::get_data_for_route()` already uses for endpoint arguments. --- ...s-wp-rest-abilities-v1-list-controller.php | 72 ++++++++++++++++++- .../wpRestAbilitiesV1ListController.php | 55 ++++++++++++++ 2 files changed, 125 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 6dfc54003863e..a5709005e1f2d 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 @@ -215,6 +215,70 @@ private function normalize_schema_empty_object_defaults( array $schema ): array return $schema; } + /** + * Recursively removes non-JSON-Schema keywords from a schema. + * + * 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 + * strips any key not in the list returned by rest_get_allowed_schema_keywords(), + * plus `required`. + * + * @since 6.9.0 + * + * @param array $schema The schema array. + * @return array The schema with only valid JSON Schema keywords. + */ + private function strip_internal_schema_keywords( array $schema ): array { + $allowed_keywords = rest_get_allowed_schema_keywords(); + $allowed_keywords[] = 'required'; + $allowed_keywords = array_flip( $allowed_keywords ); + + $schema = array_intersect_key( $schema, $allowed_keywords ); + + // Sub-schema maps: keys are user-defined, values are sub-schemas. + foreach ( array( 'properties', 'patternProperties' ) as $keyword ) { + if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { + foreach ( $schema[ $keyword ] as $key => $child_schema ) { + if ( is_array( $child_schema ) ) { + $schema[ $keyword ][ $key ] = $this->strip_internal_schema_keywords( $child_schema ); + } + } + } + } + + // Single sub-schema: items. + if ( isset( $schema['items'] ) ) { + if ( wp_is_numeric_array( $schema['items'] ) ) { + foreach ( $schema['items'] as $index => $item_schema ) { + if ( is_array( $item_schema ) ) { + $schema['items'][ $index ] = $this->strip_internal_schema_keywords( $item_schema ); + } + } + } elseif ( is_array( $schema['items'] ) ) { + $schema['items'] = $this->strip_internal_schema_keywords( $schema['items'] ); + } + } + + // Single sub-schema: additionalProperties (when not boolean). + if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) { + $schema['additionalProperties'] = $this->strip_internal_schema_keywords( $schema['additionalProperties'] ); + } + + // Array-of-schemas keywords. + foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) { + if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { + foreach ( $schema[ $keyword ] as $index => $sub_schema ) { + if ( is_array( $sub_schema ) ) { + $schema[ $keyword ][ $index ] = $this->strip_internal_schema_keywords( $sub_schema ); + } + } + } + } + + return $schema; + } + /** * Prepares an ability for response. * @@ -230,8 +294,12 @@ public function prepare_item_for_response( $ability, $request ) { 'label' => $ability->get_label(), 'description' => $ability->get_description(), 'category' => $ability->get_category(), - 'input_schema' => $this->normalize_schema_empty_object_defaults( $ability->get_input_schema() ), - 'output_schema' => $this->normalize_schema_empty_object_defaults( $ability->get_output_schema() ), + 'input_schema' => $this->strip_internal_schema_keywords( + $this->normalize_schema_empty_object_defaults( $ability->get_input_schema() ) + ), + 'output_schema' => $this->strip_internal_schema_keywords( + $this->normalize_schema_empty_object_defaults( $ability->get_output_schema() ) + ), 'meta' => $ability->get_meta(), ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 9ee564ef00069..c08ac6062bed5 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -776,4 +776,59 @@ public function test_filter_by_nonexistent_category(): void { $this->assertIsArray( $data ); $this->assertEmpty( $data, 'Should return empty array for non-existent category' ); } + + /** + * Test that WordPress-internal schema keywords are stripped from ability schemas in REST response. + * + * @ticket 64098 + */ + public function test_internal_schema_keywords_stripped_from_response(): void { + $this->register_test_ability( + 'test/with-internal-keywords', + array( + 'label' => 'Test Internal Keywords', + 'description' => 'Tests stripping of internal schema keywords', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'description' => 'The content value.', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'is_string', + 'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'execute_callback' => static function ( $input ) { + return $input['content']; + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-internal-keywords' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + // Verify internal 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 ); + + // Verify valid JSON Schema keywords are preserved. + $this->assertSame( 'string', $content_schema['type'] ); + $this->assertSame( 'The content value.', $content_schema['description'] ); + + // Verify internal keywords are stripped from output_schema. + $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema'] ); + $this->assertSame( 'string', $data['output_schema']['type'] ); + } } From 8e209b7d2be0115980e8e7778999095470632755 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 14:10:13 +0100 Subject: [PATCH 2/9] REST API: Switch to denylist approach for stripping internal schema keywords. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a targeted denylist of the three known WordPress-internal keys (sanitize_callback, validate_callback, arg_options) instead of an allowlist based on rest_get_allowed_schema_keywords(). The allowlist approach was too aggressive — it stripped legitimate JSON Schema keywords like $schema, $ref, allOf, not, and definitions that are valid but not in WordPress's supported subset. --- ...s-wp-rest-abilities-v1-list-controller.php | 53 +++---------------- 1 file changed, 7 insertions(+), 46 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 a5709005e1f2d..e15872e08713e 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 @@ -216,63 +216,24 @@ private function normalize_schema_empty_object_defaults( array $schema ): array } /** - * Recursively removes non-JSON-Schema keywords from a schema. + * Recursively removes WordPress-internal keywords from a schema. * * 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 - * strips any key not in the list returned by rest_get_allowed_schema_keywords(), - * plus `required`. + * removes those specific keys so they are not exposed in REST responses. * * @since 6.9.0 * * @param array $schema The schema array. - * @return array The schema with only valid JSON Schema keywords. + * @return array The schema without WordPress-internal keywords. */ private function strip_internal_schema_keywords( array $schema ): array { - $allowed_keywords = rest_get_allowed_schema_keywords(); - $allowed_keywords[] = 'required'; - $allowed_keywords = array_flip( $allowed_keywords ); - - $schema = array_intersect_key( $schema, $allowed_keywords ); - - // Sub-schema maps: keys are user-defined, values are sub-schemas. - foreach ( array( 'properties', 'patternProperties' ) as $keyword ) { - if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { - foreach ( $schema[ $keyword ] as $key => $child_schema ) { - if ( is_array( $child_schema ) ) { - $schema[ $keyword ][ $key ] = $this->strip_internal_schema_keywords( $child_schema ); - } - } - } - } - - // Single sub-schema: items. - if ( isset( $schema['items'] ) ) { - if ( wp_is_numeric_array( $schema['items'] ) ) { - foreach ( $schema['items'] as $index => $item_schema ) { - if ( is_array( $item_schema ) ) { - $schema['items'][ $index ] = $this->strip_internal_schema_keywords( $item_schema ); - } - } - } elseif ( is_array( $schema['items'] ) ) { - $schema['items'] = $this->strip_internal_schema_keywords( $schema['items'] ); - } - } + unset( $schema['sanitize_callback'], $schema['validate_callback'], $schema['arg_options'] ); - // Single sub-schema: additionalProperties (when not boolean). - if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) { - $schema['additionalProperties'] = $this->strip_internal_schema_keywords( $schema['additionalProperties'] ); - } - - // Array-of-schemas keywords. - foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) { - if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { - foreach ( $schema[ $keyword ] as $index => $sub_schema ) { - if ( is_array( $sub_schema ) ) { - $schema[ $keyword ][ $index ] = $this->strip_internal_schema_keywords( $sub_schema ); - } - } + foreach ( $schema as $key => $value ) { + if ( is_array( $value ) ) { + $schema[ $key ] = $this->strip_internal_schema_keywords( $value ); } } From 3d2429d503c36b9498338693a83b90db30df7ff7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 14:14:11 +0100 Subject: [PATCH 3/9] REST API: Only recurse into JSON Schema sub-schema locations. The blind recursion into all array values would incorrectly strip denylist keys from data-holding keywords like `default` and `enum`. Restrict recursion to known sub-schema locations: properties, patternProperties, definitions, items, additionalProperties, additionalItems, not, anyOf, oneOf, allOf. --- ...s-wp-rest-abilities-v1-list-controller.php | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 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 e15872e08713e..e95f521e64ff5 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 @@ -231,9 +231,45 @@ private function normalize_schema_empty_object_defaults( array $schema ): array private function strip_internal_schema_keywords( array $schema ): array { unset( $schema['sanitize_callback'], $schema['validate_callback'], $schema['arg_options'] ); - foreach ( $schema as $key => $value ) { - if ( is_array( $value ) ) { - $schema[ $key ] = $this->strip_internal_schema_keywords( $value ); + // Sub-schema maps: keys are user-defined, values are sub-schemas. + foreach ( array( 'properties', 'patternProperties', 'definitions' ) as $keyword ) { + if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { + foreach ( $schema[ $keyword ] as $key => $child_schema ) { + if ( is_array( $child_schema ) ) { + $schema[ $keyword ][ $key ] = $this->strip_internal_schema_keywords( $child_schema ); + } + } + } + } + + // Single sub-schema keywords. + foreach ( array( 'not', 'additionalProperties', 'additionalItems' ) as $keyword ) { + if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { + $schema[ $keyword ] = $this->strip_internal_schema_keywords( $schema[ $keyword ] ); + } + } + + // Items: single schema or tuple array of schemas. + if ( isset( $schema['items'] ) ) { + if ( wp_is_numeric_array( $schema['items'] ) ) { + foreach ( $schema['items'] as $index => $item_schema ) { + if ( is_array( $item_schema ) ) { + $schema['items'][ $index ] = $this->strip_internal_schema_keywords( $item_schema ); + } + } + } elseif ( is_array( $schema['items'] ) ) { + $schema['items'] = $this->strip_internal_schema_keywords( $schema['items'] ); + } + } + + // Array-of-schemas keywords. + foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) { + if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { + foreach ( $schema[ $keyword ] as $index => $sub_schema ) { + if ( is_array( $sub_schema ) ) { + $schema[ $keyword ][ $index ] = $this->strip_internal_schema_keywords( $sub_schema ); + } + } } } From 892a13f8a350e13289b1d87996ddb0c14083fa5f Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 14:15:00 +0100 Subject: [PATCH 4/9] Tests: Assert response status and structure before accessing nested keys. --- .../tests/rest-api/wpRestAbilitiesV1ListController.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index c08ac6062bed5..81eec1071daac 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -815,7 +815,14 @@ public function test_internal_schema_keywords_stripped_from_response(): void { $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-internal-keywords' ); $response = $this->server->dispatch( $request ); - $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'input_schema', $data ); + $this->assertArrayHasKey( 'properties', $data['input_schema'] ); + $this->assertArrayHasKey( 'content', $data['input_schema']['properties'] ); + $this->assertArrayHasKey( 'output_schema', $data ); // Verify internal keywords are stripped from input_schema properties. $content_schema = $data['input_schema']['properties']['content']; From ad43a1345f661bc15196fb19f47eaaf3d9f336c5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 14:26:00 +0100 Subject: [PATCH 5/9] REST API: Extract internal schema keywords constant and add sub-schema test coverage. --- ...s-wp-rest-abilities-v1-list-controller.php | 15 +- .../wpRestAbilitiesV1ListController.php | 141 ++++++++++++++++++ 2 files changed, 154 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 e95f521e64ff5..2056e38006ade 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 @@ -215,6 +215,14 @@ private function normalize_schema_empty_object_defaults( array $schema ): array return $schema; } + /** + * WordPress-internal schema keywords to strip from REST responses. + * + * @since 6.9.0 + * @var array + */ + private const INTERNAL_SCHEMA_KEYWORDS = array( 'sanitize_callback', 'validate_callback', 'arg_options' ); + /** * Recursively removes WordPress-internal keywords from a schema. * @@ -229,10 +237,13 @@ private function normalize_schema_empty_object_defaults( array $schema ): array * @return array The schema without WordPress-internal keywords. */ private function strip_internal_schema_keywords( array $schema ): array { - unset( $schema['sanitize_callback'], $schema['validate_callback'], $schema['arg_options'] ); + $schema = array_diff_key( $schema, array_flip( self::INTERNAL_SCHEMA_KEYWORDS ) ); // Sub-schema maps: keys are user-defined, values are sub-schemas. - foreach ( array( 'properties', 'patternProperties', 'definitions' ) as $keyword ) { + // Note: 'dependencies' values can also be property-dependency arrays + // (numeric arrays of strings), which is_array() will pass through + // harmlessly since array_diff_key won't match any denylist keys. + foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) { if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { foreach ( $schema[ $keyword ] as $key => $child_schema ) { if ( is_array( $child_schema ) ) { diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 81eec1071daac..702eb612a4639 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -838,4 +838,145 @@ public function test_internal_schema_keywords_stripped_from_response(): void { $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema'] ); $this->assertSame( 'string', $data['output_schema']['type'] ); } + + /** + * Test that internal schema keywords are stripped from nested sub-schema locations. + * + * @ticket 64098 + */ + public function test_internal_schema_keywords_stripped_from_nested_sub_schemas(): void { + $this->register_test_ability( + 'test/nested-internal-keywords', + array( + 'label' => 'Test Nested Keywords', + 'description' => 'Tests stripping from all sub-schema locations', + 'category' => 'general', + 'input_schema' => array( + 'type' => 'object', + 'anyOf' => array( + array( + 'type' => 'object', + 'sanitize_callback' => 'sanitize_text_field', + 'properties' => array( + 'value' => array( + 'type' => 'string', + 'validate_callback' => 'is_string', + ), + ), + ), + array( + 'type' => 'number', + 'arg_options' => array( 'sanitize_callback' => 'absint' ), + ), + ), + 'oneOf' => array( + array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'allOf' => array( + array( + 'type' => 'object', + 'validate_callback' => 'rest_validate_request_arg', + ), + ), + 'not' => array( + 'type' => 'null', + 'arg_options' => array( 'sanitize_callback' => 'absint' ), + ), + 'patternProperties' => array( + '^S_' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'dependencies' => array( + 'bar' => array( + 'type' => 'object', + 'validate_callback' => 'rest_validate_request_arg', + 'properties' => array( + 'baz' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ), + 'qux' => array( 'bar' ), + ), + 'additionalProperties' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + 'output_schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'validate_callback' => 'is_string', + ), + ), + 'execute_callback' => static function ( $input ) { + return array(); + }, + 'permission_callback' => '__return_true', + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/nested-internal-keywords' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // Verify internal keywords are stripped from anyOf sub-schemas. + $this->assertArrayHasKey( 'anyOf', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['anyOf'][0] ); + $this->assertSame( 'object', $data['input_schema']['anyOf'][0]['type'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['anyOf'][0]['properties']['value'] ); + $this->assertSame( 'string', $data['input_schema']['anyOf'][0]['properties']['value']['type'] ); + $this->assertArrayNotHasKey( 'arg_options', $data['input_schema']['anyOf'][1] ); + $this->assertSame( 'number', $data['input_schema']['anyOf'][1]['type'] ); + + // Verify internal keywords are stripped from oneOf sub-schemas. + $this->assertArrayHasKey( 'oneOf', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['oneOf'][0] ); + $this->assertSame( 'string', $data['input_schema']['oneOf'][0]['type'] ); + + // Verify internal keywords are stripped from allOf sub-schemas. + $this->assertArrayHasKey( 'allOf', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['allOf'][0] ); + $this->assertSame( 'object', $data['input_schema']['allOf'][0]['type'] ); + + // Verify internal keywords are stripped from not sub-schema. + $this->assertArrayHasKey( 'not', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'arg_options', $data['input_schema']['not'] ); + $this->assertSame( 'null', $data['input_schema']['not']['type'] ); + + // Verify internal keywords are stripped from patternProperties sub-schemas. + $this->assertArrayHasKey( 'patternProperties', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['patternProperties']['^S_'] ); + $this->assertSame( 'string', $data['input_schema']['patternProperties']['^S_']['type'] ); + + // Verify internal keywords are stripped from dependencies schema values. + $this->assertArrayHasKey( 'dependencies', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['dependencies']['bar'] ); + $this->assertSame( 'object', $data['input_schema']['dependencies']['bar']['type'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['dependencies']['bar']['properties']['baz'] ); + $this->assertSame( 'string', $data['input_schema']['dependencies']['bar']['properties']['baz']['type'] ); + // Property dependencies (numeric arrays) should pass through unchanged. + $this->assertSame( array( 'bar' ), $data['input_schema']['dependencies']['qux'] ); + + // Verify internal keywords are stripped from additionalProperties sub-schema. + $this->assertArrayHasKey( 'additionalProperties', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['additionalProperties'] ); + $this->assertSame( 'string', $data['input_schema']['additionalProperties']['type'] ); + + // Verify internal keywords are stripped from items sub-schema. + $this->assertArrayHasKey( 'items', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['output_schema']['items'] ); + $this->assertSame( 'string', $data['output_schema']['items']['type'] ); + } } From 8f3d4a4924208797ed54c7db428fb85a63d16864 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 14:45:40 +0100 Subject: [PATCH 6/9] REST API: Use associative constant for internal keywords and add missing test coverage. Defines INTERNAL_SCHEMA_KEYWORDS as an associative array so array_flip() is no longer called on every recursive invocation. Adds test coverage for additionalItems, definitions, and tuple-style items sub-schema locations. --- ...s-wp-rest-abilities-v1-list-controller.php | 10 ++-- .../wpRestAbilitiesV1ListController.php | 51 ++++++++++++++++--- 2 files changed, 51 insertions(+), 10 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 2056e38006ade..6b98f7ce444f5 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 @@ -219,9 +219,13 @@ private function normalize_schema_empty_object_defaults( array $schema ): array * WordPress-internal schema keywords to strip from REST responses. * * @since 6.9.0 - * @var array + * @var array */ - private const INTERNAL_SCHEMA_KEYWORDS = array( 'sanitize_callback', 'validate_callback', 'arg_options' ); + private const INTERNAL_SCHEMA_KEYWORDS = array( + 'sanitize_callback' => true, + 'validate_callback' => true, + 'arg_options' => true, + ); /** * Recursively removes WordPress-internal keywords from a schema. @@ -237,7 +241,7 @@ private function normalize_schema_empty_object_defaults( array $schema ): array * @return array The schema without WordPress-internal keywords. */ private function strip_internal_schema_keywords( array $schema ): array { - $schema = array_diff_key( $schema, array_flip( self::INTERNAL_SCHEMA_KEYWORDS ) ); + $schema = array_diff_key( $schema, self::INTERNAL_SCHEMA_KEYWORDS ); // 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 702eb612a4639..a6e02efe2c8eb 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -891,6 +891,18 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() 'sanitize_callback' => 'sanitize_text_field', ), ), + 'definitions' => array( + 'address' => array( + 'type' => 'object', + 'validate_callback' => 'rest_validate_request_arg', + 'properties' => array( + 'street' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ), + ), 'dependencies' => array( 'bar' => array( 'type' => 'object', @@ -910,10 +922,20 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() ), ), 'output_schema' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'string', - 'validate_callback' => 'is_string', + 'type' => 'array', + 'items' => array( + array( + 'type' => 'string', + 'validate_callback' => 'is_string', + ), + array( + 'type' => 'number', + 'arg_options' => array( 'sanitize_callback' => 'absint' ), + ), + ), + 'additionalItems' => array( + 'type' => 'boolean', + 'sanitize_callback' => 'rest_sanitize_boolean', ), ), 'execute_callback' => static function ( $input ) { @@ -969,14 +991,29 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() // Property dependencies (numeric arrays) should pass through unchanged. $this->assertSame( array( 'bar' ), $data['input_schema']['dependencies']['qux'] ); + // Verify internal keywords are stripped from definitions sub-schemas. + $this->assertArrayHasKey( 'definitions', $data['input_schema'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['input_schema']['definitions']['address'] ); + $this->assertSame( 'object', $data['input_schema']['definitions']['address']['type'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['definitions']['address']['properties']['street'] ); + $this->assertSame( 'string', $data['input_schema']['definitions']['address']['properties']['street']['type'] ); + // Verify internal keywords are stripped from additionalProperties sub-schema. $this->assertArrayHasKey( 'additionalProperties', $data['input_schema'] ); $this->assertArrayNotHasKey( 'sanitize_callback', $data['input_schema']['additionalProperties'] ); $this->assertSame( 'string', $data['input_schema']['additionalProperties']['type'] ); - // Verify internal keywords are stripped from items sub-schema. + // Verify internal keywords are stripped from tuple-style items sub-schemas. $this->assertArrayHasKey( 'items', $data['output_schema'] ); - $this->assertArrayNotHasKey( 'validate_callback', $data['output_schema']['items'] ); - $this->assertSame( 'string', $data['output_schema']['items']['type'] ); + $this->assertCount( 2, $data['output_schema']['items'] ); + $this->assertArrayNotHasKey( 'validate_callback', $data['output_schema']['items'][0] ); + $this->assertSame( 'string', $data['output_schema']['items'][0]['type'] ); + $this->assertArrayNotHasKey( 'arg_options', $data['output_schema']['items'][1] ); + $this->assertSame( 'number', $data['output_schema']['items'][1]['type'] ); + + // Verify internal keywords are stripped from additionalItems sub-schema. + $this->assertArrayHasKey( 'additionalItems', $data['output_schema'] ); + $this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema']['additionalItems'] ); + $this->assertSame( 'boolean', $data['output_schema']['additionalItems']['type'] ); } } From 34a0291dc5c5a35d2bb89f84d94ab79bde5081f3 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 14:51:33 +0100 Subject: [PATCH 7/9] lint fixes --- .../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 a6e02efe2c8eb..46f53969f334b 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -852,8 +852,8 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() 'description' => 'Tests stripping from all sub-schema locations', 'category' => 'general', 'input_schema' => array( - 'type' => 'object', - 'anyOf' => array( + 'type' => 'object', + 'anyOf' => array( array( 'type' => 'object', 'sanitize_callback' => 'sanitize_text_field', @@ -869,29 +869,29 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() 'arg_options' => array( 'sanitize_callback' => 'absint' ), ), ), - 'oneOf' => array( + 'oneOf' => array( array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), - 'allOf' => array( + 'allOf' => array( array( 'type' => 'object', 'validate_callback' => 'rest_validate_request_arg', ), ), - 'not' => array( + 'not' => array( 'type' => 'null', 'arg_options' => array( 'sanitize_callback' => 'absint' ), ), - 'patternProperties' => array( + 'patternProperties' => array( '^S_' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), - 'definitions' => array( + 'definitions' => array( 'address' => array( 'type' => 'object', 'validate_callback' => 'rest_validate_request_arg', From 90bac7ba3e1ce159a6d51253f24175690baa109e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 14:59:07 +0100 Subject: [PATCH 8/9] REST API: Skip property-dependency arrays in recursive schema keyword stripping. Add wp_is_numeric_array() guard to the dependencies handler so property-dependency arrays (numeric arrays of strings) are explicitly skipped rather than relying on array_diff_key being a no-op. --- .../endpoints/class-wp-rest-abilities-v1-list-controller.php | 5 ++--- 1 file changed, 2 insertions(+), 3 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 6b98f7ce444f5..294bfb5f7be3f 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 @@ -245,12 +245,11 @@ private function strip_internal_schema_keywords( array $schema ): array { // 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 is_array() will pass through - // harmlessly since array_diff_key won't match any denylist keys. + // (numeric arrays of strings) which are skipped via wp_is_numeric_array(). foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) { if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) { foreach ( $schema[ $keyword ] as $key => $child_schema ) { - if ( is_array( $child_schema ) ) { + if ( is_array( $child_schema ) && ! wp_is_numeric_array( $child_schema ) ) { $schema[ $keyword ][ $key ] = $this->strip_internal_schema_keywords( $child_schema ); } } From 5291a900dac5545cc066af9be7847ba494095b6b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 6 Apr 2026 15:21:42 +0100 Subject: [PATCH 9/9] lint fixes --- .../rest-api/wpRestAbilitiesV1ListController.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 46f53969f334b..6fe5e82cca769 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -852,8 +852,8 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() 'description' => 'Tests stripping from all sub-schema locations', 'category' => 'general', 'input_schema' => array( - 'type' => 'object', - 'anyOf' => array( + 'type' => 'object', + 'anyOf' => array( array( 'type' => 'object', 'sanitize_callback' => 'sanitize_text_field', @@ -869,29 +869,29 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() 'arg_options' => array( 'sanitize_callback' => 'absint' ), ), ), - 'oneOf' => array( + 'oneOf' => array( array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), - 'allOf' => array( + 'allOf' => array( array( 'type' => 'object', 'validate_callback' => 'rest_validate_request_arg', ), ), - 'not' => array( + 'not' => array( 'type' => 'null', 'arg_options' => array( 'sanitize_callback' => 'absint' ), ), - 'patternProperties' => array( + 'patternProperties' => array( '^S_' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), - 'definitions' => array( + 'definitions' => array( 'address' => array( 'type' => 'object', 'validate_callback' => 'rest_validate_request_arg', @@ -903,7 +903,7 @@ public function test_internal_schema_keywords_stripped_from_nested_sub_schemas() ), ), ), - 'dependencies' => array( + 'dependencies' => array( 'bar' => array( 'type' => 'object', 'validate_callback' => 'rest_validate_request_arg',