Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,81 @@ 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<string, true>
*/
private const INTERNAL_SCHEMA_KEYWORDS = array(
'sanitize_callback' => true,
'validate_callback' => true,
'arg_options' => true,
);

/**
* 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
* removes those specific keys so they are not exposed in REST responses.
*
* @since 6.9.0
*
* @param array<string, mixed> $schema The schema array.
* @return array<string, mixed> The schema without WordPress-internal keywords.
*/
private function strip_internal_schema_keywords( array $schema ): array {
$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
// (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 ) && ! wp_is_numeric_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 );
}
}
}
}

return $schema;
}

/**
* Prepares an ability for response.
*
Expand All @@ -230,8 +305,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(),
);

Expand Down
240 changes: 240 additions & 0 deletions tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php
Original file line number Diff line number Diff line change
Expand Up @@ -776,4 +776,244 @@ 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 );

$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'];
$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'] );
}

/**
* 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',
),
),
'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',
'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(
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 ) {
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 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 tuple-style items sub-schemas.
$this->assertArrayHasKey( 'items', $data['output_schema'] );
$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'] );
}
}
Loading