Skip to content

Commit c22d026

Browse files
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.
1 parent 8510818 commit c22d026

2 files changed

Lines changed: 125 additions & 2 deletions

File tree

src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,70 @@ private function normalize_schema_empty_object_defaults( array $schema ): array
215215
return $schema;
216216
}
217217

218+
/**
219+
* Recursively removes non-JSON-Schema keywords from a schema.
220+
*
221+
* Ability schemas may include WordPress-internal properties like
222+
* `sanitize_callback`, `validate_callback`, and `arg_options` that are
223+
* used server-side but are not valid JSON Schema keywords. This method
224+
* strips any key not in the list returned by rest_get_allowed_schema_keywords(),
225+
* plus `required`.
226+
*
227+
* @since 6.9.0
228+
*
229+
* @param array<string, mixed> $schema The schema array.
230+
* @return array<string, mixed> The schema with only valid JSON Schema keywords.
231+
*/
232+
private function strip_internal_schema_keywords( array $schema ): array {
233+
$allowed_keywords = rest_get_allowed_schema_keywords();
234+
$allowed_keywords[] = 'required';
235+
$allowed_keywords = array_flip( $allowed_keywords );
236+
237+
$schema = array_intersect_key( $schema, $allowed_keywords );
238+
239+
// Sub-schema maps: keys are user-defined, values are sub-schemas.
240+
foreach ( array( 'properties', 'patternProperties' ) as $keyword ) {
241+
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
242+
foreach ( $schema[ $keyword ] as $key => $child_schema ) {
243+
if ( is_array( $child_schema ) ) {
244+
$schema[ $keyword ][ $key ] = $this->strip_internal_schema_keywords( $child_schema );
245+
}
246+
}
247+
}
248+
}
249+
250+
// Single sub-schema: items.
251+
if ( isset( $schema['items'] ) ) {
252+
if ( wp_is_numeric_array( $schema['items'] ) ) {
253+
foreach ( $schema['items'] as $index => $item_schema ) {
254+
if ( is_array( $item_schema ) ) {
255+
$schema['items'][ $index ] = $this->strip_internal_schema_keywords( $item_schema );
256+
}
257+
}
258+
} elseif ( is_array( $schema['items'] ) ) {
259+
$schema['items'] = $this->strip_internal_schema_keywords( $schema['items'] );
260+
}
261+
}
262+
263+
// Single sub-schema: additionalProperties (when not boolean).
264+
if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
265+
$schema['additionalProperties'] = $this->strip_internal_schema_keywords( $schema['additionalProperties'] );
266+
}
267+
268+
// Array-of-schemas keywords.
269+
foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
270+
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
271+
foreach ( $schema[ $keyword ] as $index => $sub_schema ) {
272+
if ( is_array( $sub_schema ) ) {
273+
$schema[ $keyword ][ $index ] = $this->strip_internal_schema_keywords( $sub_schema );
274+
}
275+
}
276+
}
277+
}
278+
279+
return $schema;
280+
}
281+
218282
/**
219283
* Prepares an ability for response.
220284
*
@@ -230,8 +294,12 @@ public function prepare_item_for_response( $ability, $request ) {
230294
'label' => $ability->get_label(),
231295
'description' => $ability->get_description(),
232296
'category' => $ability->get_category(),
233-
'input_schema' => $this->normalize_schema_empty_object_defaults( $ability->get_input_schema() ),
234-
'output_schema' => $this->normalize_schema_empty_object_defaults( $ability->get_output_schema() ),
297+
'input_schema' => $this->strip_internal_schema_keywords(
298+
$this->normalize_schema_empty_object_defaults( $ability->get_input_schema() )
299+
),
300+
'output_schema' => $this->strip_internal_schema_keywords(
301+
$this->normalize_schema_empty_object_defaults( $ability->get_output_schema() )
302+
),
235303
'meta' => $ability->get_meta(),
236304
);
237305

tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,4 +776,59 @@ public function test_filter_by_nonexistent_category(): void {
776776
$this->assertIsArray( $data );
777777
$this->assertEmpty( $data, 'Should return empty array for non-existent category' );
778778
}
779+
780+
/**
781+
* Test that WordPress-internal schema keywords are stripped from ability schemas in REST response.
782+
*
783+
* @ticket 64098
784+
*/
785+
public function test_internal_schema_keywords_stripped_from_response(): void {
786+
$this->register_test_ability(
787+
'test/with-internal-keywords',
788+
array(
789+
'label' => 'Test Internal Keywords',
790+
'description' => 'Tests stripping of internal schema keywords',
791+
'category' => 'general',
792+
'input_schema' => array(
793+
'type' => 'object',
794+
'properties' => array(
795+
'content' => array(
796+
'type' => 'string',
797+
'description' => 'The content value.',
798+
'sanitize_callback' => 'sanitize_text_field',
799+
'validate_callback' => 'is_string',
800+
'arg_options' => array( 'sanitize_callback' => 'wp_kses_post' ),
801+
),
802+
),
803+
),
804+
'output_schema' => array(
805+
'type' => 'string',
806+
'sanitize_callback' => 'sanitize_text_field',
807+
),
808+
'execute_callback' => static function ( $input ) {
809+
return $input['content'];
810+
},
811+
'permission_callback' => '__return_true',
812+
'meta' => array( 'show_in_rest' => true ),
813+
)
814+
);
815+
816+
$request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/with-internal-keywords' );
817+
$response = $this->server->dispatch( $request );
818+
$data = $response->get_data();
819+
820+
// Verify internal keywords are stripped from input_schema properties.
821+
$content_schema = $data['input_schema']['properties']['content'];
822+
$this->assertArrayNotHasKey( 'sanitize_callback', $content_schema );
823+
$this->assertArrayNotHasKey( 'validate_callback', $content_schema );
824+
$this->assertArrayNotHasKey( 'arg_options', $content_schema );
825+
826+
// Verify valid JSON Schema keywords are preserved.
827+
$this->assertSame( 'string', $content_schema['type'] );
828+
$this->assertSame( 'The content value.', $content_schema['description'] );
829+
830+
// Verify internal keywords are stripped from output_schema.
831+
$this->assertArrayNotHasKey( 'sanitize_callback', $data['output_schema'] );
832+
$this->assertSame( 'string', $data['output_schema']['type'] );
833+
}
779834
}

0 commit comments

Comments
 (0)