From a86bc6f5650e400f57df8cb9a4ab581345afbb0b Mon Sep 17 00:00:00 2001 From: Gary Pendergast Date: Mon, 31 Oct 2016 01:47:36 +0000 Subject: [PATCH] REST API: Add support for arrays in schema validation and sanitization. By allowing more fine-grained validation and sanitisation of endpoint args, we can ensure the correct data is being passed to endpoints. This can easily be extended to support new data types, such as CSV fields or objects. Props joehoyle, rachelbaker, pento. Fixes #38531. git-svn-id: https://develop.svn.wordpress.org/trunk@39046 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/rest-api.php | 255 +++++++++++------- .../endpoints/class-wp-rest-controller.php | 2 +- .../class-wp-rest-posts-controller.php | 3 + .../class-wp-rest-settings-controller.php | 22 ++ .../class-wp-rest-users-controller.php | 3 + .../tests/rest-api/rest-schema-validation.php | 106 ++++++++ 6 files changed, 288 insertions(+), 103 deletions(-) create mode 100644 tests/phpunit/tests/rest-api/rest-schema-validation.php diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 91b7343f56..dc359aec50 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -820,80 +820,7 @@ function rest_validate_request_arg( $value, $request, $param ) { } $args = $attributes['args'][ $param ]; - if ( ! empty( $args['enum'] ) ) { - if ( ! in_array( $value, $args['enum'], true ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: list of valid values */ __( '%1$s is not one of %2$s.' ), $param, implode( ', ', $args['enum'] ) ) ); - } - } - - if ( 'integer' === $args['type'] && ! is_numeric( $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $param, 'integer' ) ); - } - - if ( 'boolean' === $args['type'] && ! rest_is_boolean( $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $value, 'boolean' ) ); - } - - if ( 'string' === $args['type'] && ! is_string( $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $param, 'string' ) ); - } - - if ( isset( $args['format'] ) ) { - switch ( $args['format'] ) { - case 'date-time' : - if ( ! rest_parse_date( $value ) ) { - return new WP_Error( 'rest_invalid_date', __( 'The date you provided is invalid.' ) ); - } - break; - - case 'email' : - if ( ! is_email( $value ) ) { - return new WP_Error( 'rest_invalid_email', __( 'The email address you provided is invalid.' ) ); - } - break; - case 'ipv4' : - if ( ! rest_is_ip_address( $value ) ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%s is not a valid IP address.' ), $value ) ); - } - break; - } - } - - if ( in_array( $args['type'], array( 'numeric', 'integer' ), true ) && ( isset( $args['minimum'] ) || isset( $args['maximum'] ) ) ) { - if ( isset( $args['minimum'] ) && ! isset( $args['maximum'] ) ) { - if ( ! empty( $args['exclusiveMinimum'] ) && $value <= $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be greater than %2$d (exclusive)' ), $param, $args['minimum'] ) ); - } elseif ( empty( $args['exclusiveMinimum'] ) && $value < $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be greater than %2$d (inclusive)' ), $param, $args['minimum'] ) ); - } - } elseif ( isset( $args['maximum'] ) && ! isset( $args['minimum'] ) ) { - if ( ! empty( $args['exclusiveMaximum'] ) && $value >= $args['maximum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be less than %2$d (exclusive)' ), $param, $args['maximum'] ) ); - } elseif ( empty( $args['exclusiveMaximum'] ) && $value > $args['maximum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be less than %2$d (inclusive)' ), $param, $args['maximum'] ) ); - } - } elseif ( isset( $args['maximum'] ) && isset( $args['minimum'] ) ) { - if ( ! empty( $args['exclusiveMinimum'] ) && ! empty( $args['exclusiveMaximum'] ) ) { - if ( $value >= $args['maximum'] || $value <= $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (exclusive) and %3$d (exclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); - } - } elseif ( empty( $args['exclusiveMinimum'] ) && ! empty( $args['exclusiveMaximum'] ) ) { - if ( $value >= $args['maximum'] || $value < $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (inclusive) and %3$d (exclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); - } - } elseif ( ! empty( $args['exclusiveMinimum'] ) && empty( $args['exclusiveMaximum'] ) ) { - if ( $value > $args['maximum'] || $value <= $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (exclusive) and %3$d (inclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); - } - } elseif ( empty( $args['exclusiveMinimum'] ) && empty( $args['exclusiveMaximum'] ) ) { - if ( $value > $args['maximum'] || $value < $args['minimum'] ) { - return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (inclusive) and %3$d (inclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); - } - } - } - } - - return true; + return rest_validate_value_from_schema( $value, $args, $param ); } /** @@ -913,34 +840,7 @@ function rest_sanitize_request_arg( $value, $request, $param ) { } $args = $attributes['args'][ $param ]; - if ( 'integer' === $args['type'] ) { - return (int) $value; - } - - if ( 'boolean' === $args['type'] ) { - return rest_sanitize_boolean( $value ); - } - - if ( isset( $args['format'] ) ) { - switch ( $args['format'] ) { - case 'date-time' : - return sanitize_text_field( $value ); - - case 'email' : - /* - * sanitize_email() validates, which would be unexpected - */ - return sanitize_text_field( $value ); - - case 'uri' : - return esc_url_raw( $value ); - - case 'ipv4' : - return sanitize_text_field( $value ); - } - } - - return $value; + return rest_sanitize_value_from_schema( $value, $args, $param ); } /** @@ -1084,3 +984,154 @@ function rest_get_avatar_sizes() { */ return apply_filters( 'rest_avatar_sizes', array( 24, 48, 96 ) ); } + +/** + * Validate a value based on a schema. + * + * @param mixed $value The value to validate. + * @param array $args Schema array to use for validation. + * @param string $param The parameter name, used in error messages. + * @return true|WP_Error + */ +function rest_validate_value_from_schema( $value, $args, $param = '' ) { + if ( 'array' === $args['type'] ) { + if ( ! is_array( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $param, 'array' ) ); + } + foreach ( $value as $index => $v ) { + $is_valid = rest_validate_value_from_schema( $v, $args['items'], $param . '[' . $index . ']' ); + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } + } + } + if ( ! empty( $args['enum'] ) ) { + if ( ! in_array( $value, $args['enum'], true ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: list of valid values */ __( '%1$s is not one of %2$s.' ), $param, implode( ', ', $args['enum'] ) ) ); + } + } + + if ( in_array( $args['type'], array( 'integer', 'number' ) ) && ! is_numeric( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $param, $args['type'] ) ); + } + + if ( 'integer' === $args['type'] && round( floatval( $value ) ) !== floatval( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $param, 'integer' ) ); + } + + if ( 'boolean' === $args['type'] && ! rest_is_boolean( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $value, 'boolean' ) ); + } + + if ( 'string' === $args['type'] && ! is_string( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: type name */ __( '%1$s is not of type %2$s.' ), $param, 'string' ) ); + } + + if ( isset( $args['format'] ) ) { + switch ( $args['format'] ) { + case 'date-time' : + if ( ! rest_parse_date( $value ) ) { + return new WP_Error( 'rest_invalid_date', __( 'The date you provided is invalid.' ) ); + } + break; + + case 'email' : + if ( ! is_email( $value ) ) { + return new WP_Error( 'rest_invalid_email', __( 'The email address you provided is invalid.' ) ); + } + break; + case 'ipv4' : + if ( ! rest_is_ip_address( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( '%s is not a valid IP address.' ), $value ) ); + } + break; + } + } + + if ( in_array( $args['type'], array( 'number', 'integer' ), true ) && ( isset( $args['minimum'] ) || isset( $args['maximum'] ) ) ) { + if ( isset( $args['minimum'] ) && ! isset( $args['maximum'] ) ) { + if ( ! empty( $args['exclusiveMinimum'] ) && $value <= $args['minimum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be greater than %2$d (exclusive)' ), $param, $args['minimum'] ) ); + } elseif ( empty( $args['exclusiveMinimum'] ) && $value < $args['minimum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be greater than %2$d (inclusive)' ), $param, $args['minimum'] ) ); + } + } elseif ( isset( $args['maximum'] ) && ! isset( $args['minimum'] ) ) { + if ( ! empty( $args['exclusiveMaximum'] ) && $value >= $args['maximum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be less than %2$d (exclusive)' ), $param, $args['maximum'] ) ); + } elseif ( empty( $args['exclusiveMaximum'] ) && $value > $args['maximum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( __( '%1$s must be less than %2$d (inclusive)' ), $param, $args['maximum'] ) ); + } + } elseif ( isset( $args['maximum'] ) && isset( $args['minimum'] ) ) { + if ( ! empty( $args['exclusiveMinimum'] ) && ! empty( $args['exclusiveMaximum'] ) ) { + if ( $value >= $args['maximum'] || $value <= $args['minimum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (exclusive) and %3$d (exclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); + } + } elseif ( empty( $args['exclusiveMinimum'] ) && ! empty( $args['exclusiveMaximum'] ) ) { + if ( $value >= $args['maximum'] || $value < $args['minimum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (inclusive) and %3$d (exclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); + } + } elseif ( ! empty( $args['exclusiveMinimum'] ) && empty( $args['exclusiveMaximum'] ) ) { + if ( $value > $args['maximum'] || $value <= $args['minimum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (exclusive) and %3$d (inclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); + } + } elseif ( empty( $args['exclusiveMinimum'] ) && empty( $args['exclusiveMaximum'] ) ) { + if ( $value > $args['maximum'] || $value < $args['minimum'] ) { + return new WP_Error( 'rest_invalid_param', sprintf( /* translators: 1: parameter, 2: minimum number, 3: maximum number */ __( '%1$s must be between %2$d (inclusive) and %3$d (inclusive)' ), $param, $args['minimum'], $args['maximum'] ) ); + } + } + } + } + + return true; +} + +/** + * Sanitize a value based on a schema. + * + * @param mixed $value The value to sanitize. + * @param array $args Schema array to use for sanitization. + * @return true|WP_Error + */ +function rest_sanitize_value_from_schema( $value, $args ) { + if ( 'array' === $args['type'] ) { + if ( empty( $args['items'] ) ) { + return (array) $value; + } + foreach ( $value as $index => $v ) { + $value[ $index ] = rest_sanitize_value_from_schema( $v, $args['items'] ); + } + return $value; + } + if ( 'integer' === $args['type'] ) { + return (int) $value; + } + + if ( 'number' === $args['type'] ) { + return (float) $value; + } + + if ( 'boolean' === $args['type'] ) { + return rest_sanitize_boolean( $value ); + } + + if ( isset( $args['format'] ) ) { + switch ( $args['format'] ) { + case 'date-time' : + return sanitize_text_field( $value ); + + case 'email' : + /* + * sanitize_email() validates, which would be unexpected. + */ + return sanitize_text_field( $value ); + + case 'uri' : + return esc_url_raw( $value ); + + case 'ipv4' : + return sanitize_text_field( $value ); + } + } + + return $value; +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php index b4d9f65517..9bf2624fd9 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-controller.php @@ -559,7 +559,7 @@ abstract class WP_REST_Controller { $endpoint_args[ $field_id ]['required'] = true; } - foreach ( array( 'type', 'format', 'enum' ) as $schema_prop ) { + foreach ( array( 'type', 'format', 'enum', 'items' ) as $schema_prop ) { if ( isset( $params[ $schema_prop ] ) ) { $endpoint_args[ $field_id ][ $schema_prop ] = $params[ $schema_prop ]; } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index a9d8f3c557..49d030bad7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -1971,6 +1971,9 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { $schema['properties'][ $base ] = array( 'description' => sprintf( __( 'The terms assigned to the object in the %s taxonomy.' ), $taxonomy->name ), 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), 'context' => array( 'view', 'edit' ), ); $schema['properties'][ $base . '_exclude' ] = array( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php index 66110efe8c..0f1ead65f2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-settings-controller.php @@ -288,8 +288,30 @@ class WP_REST_Settings_Controller extends WP_REST_Controller { foreach ( $options as $option_name => $option ) { $schema['properties'][ $option_name ] = $option['schema']; + $schema['properties'][ $option_name ]['arg_options'] = array( + 'sanitize_callback' => array( $this, 'sanitize_callback' ), + ); } return $this->add_additional_fields_schema( $schema ); } + + /** + * Custom sanitize callback used for all options to allow the use of 'null'. + * + * By default, the schema of settings will throw an error if a value is set to + * `null` as it's not a valid value for something like "type => string". We + * provide a wrapper sanitizer to whitelist the use of `null`. + * + * @param mixed $value The value for the setting. + * @param WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return mixed|WP_Error + */ + public function sanitize_callback( $value, $request, $param ) { + if ( is_null( $value ) ) { + return $value; + } + return rest_parse_request_arg( $value, $request, $param ); + } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php index 48d649a10b..d366c70371 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php @@ -1006,6 +1006,9 @@ class WP_REST_Users_Controller extends WP_REST_Controller { 'roles' => array( 'description' => __( 'Roles assigned to the resource.' ), 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), 'context' => array( 'edit' ), ), 'password' => array( diff --git a/tests/phpunit/tests/rest-api/rest-schema-validation.php b/tests/phpunit/tests/rest-api/rest-schema-validation.php new file mode 100644 index 0000000000..9e413daff6 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-schema-validation.php @@ -0,0 +1,106 @@ + 'number', + 'minimum' => 1, + 'maximum' => 2, + ); + $this->assertTrue( rest_validate_value_from_schema( 1, $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( 2, $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 3, $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( true, $schema ) ); + } + + public function test_type_integer() { + $schema = array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 2, + ); + $this->assertTrue( rest_validate_value_from_schema( 1, $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( 2, $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 3, $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 1.1, $schema ) ); + } + + public function test_type_string() { + $schema = array( + 'type' => 'string', + ); + $this->assertTrue( rest_validate_value_from_schema( 'Hello :)', $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( '1', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 1, $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( array(), $schema ) ); + } + + public function test_type_boolean() { + $schema = array( + 'type' => 'boolean', + ); + $this->assertTrue( rest_validate_value_from_schema( true, $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( false, $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( 1, $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( 0, $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( 'true', $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( 'false', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 'no', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 'yes', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 1123, $schema ) ); + } + + public function test_format_email() { + $schema = array( + 'type' => 'string', + 'format' => 'email', + ); + $this->assertTrue( rest_validate_value_from_schema( 'email@example.com', $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( 'a@b.c', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( 'email', $schema ) ); + } + + public function test_format_date_time() { + $schema = array( + 'type' => 'string', + 'format' => 'date-time', + ); + $this->assertTrue( rest_validate_value_from_schema( '2016-06-30T05:43:21', $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( '2016-06-30T05:43:21Z', $schema ) ); + $this->assertTrue( rest_validate_value_from_schema( '2016-06-30T05:43:21+00:00', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( '20161027T163355Z', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( '2016', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( '2016-06-30', $schema ) ); + } + + public function test_format_ipv4() { + $schema = array( + 'type' => 'string', + 'format' => 'ipv4', + ); + $this->assertTrue( rest_validate_value_from_schema( '127.0.0.1', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( '3333.3333.3333.3333', $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( '1', $schema ) ); + } + + public function test_type_array() { + $schema = array( + 'type' => 'array', + 'items' => array( + 'type' => 'number', + ), + ); + $this->assertTrue( rest_validate_value_from_schema( array( 1 ), $schema ) ); + $this->assertWPError( rest_validate_value_from_schema( array( true ), $schema ) ); + } +}