diff options
author | Brad Jorsch <bjorsch@wikimedia.org> | 2019-06-12 15:51:59 -0400 |
---|---|---|
committer | Brad Jorsch <bjorsch@wikimedia.org> | 2019-09-04 10:12:35 -0400 |
commit | ebfbd2d42a57e6cf87feda0d46b0c1bd2c00c2c5 (patch) | |
tree | 78fcbc55641d99594b9d8729597cc3bea3ceeee9 /includes/Rest | |
parent | bb575f1dc75af99de11568837cdabe28dde5b8e5 (diff) | |
download | mediawikicore-ebfbd2d42a57e6cf87feda0d46b0c1bd2c00c2c5.tar.gz mediawikicore-ebfbd2d42a57e6cf87feda0d46b0c1bd2c00c2c5.zip |
rest: Use ParamValidator library, add BodyValidator
Parameter validation is based on parameter definitions like those in the
Action API, using the new ParamValidator library. Handlers should use
the provided Handler methods to access parameters rather than fetching
them directly from the RequestInterface.
Body validation allows the handler to have the (non-form-data) body of a
request parsed and validated. The only validator included in this patch
ignores the body entirely; future patches may implement validation for
JSON bodies based on JSON schemas, or the like.
Bug: T223239
Change-Id: I3c37ea2b432840514b6bff90007c8403989225d5
Diffstat (limited to 'includes/Rest')
-rw-r--r-- | includes/Rest/EntryPoint.php | 9 | ||||
-rw-r--r-- | includes/Rest/Handler.php | 73 | ||||
-rw-r--r-- | includes/Rest/Handler/HelloHandler.php | 11 | ||||
-rw-r--r-- | includes/Rest/HttpException.php | 14 | ||||
-rw-r--r-- | includes/Rest/ResponseFactory.php | 9 | ||||
-rw-r--r-- | includes/Rest/Router.php | 20 | ||||
-rw-r--r-- | includes/Rest/SimpleHandler.php | 21 | ||||
-rw-r--r-- | includes/Rest/Validator/BodyValidator.php | 26 | ||||
-rw-r--r-- | includes/Rest/Validator/NullBodyValidator.php | 16 | ||||
-rw-r--r-- | includes/Rest/Validator/ParamValidatorCallbacks.php | 82 | ||||
-rw-r--r-- | includes/Rest/Validator/Validator.php | 163 |
11 files changed, 437 insertions, 7 deletions
diff --git a/includes/Rest/EntryPoint.php b/includes/Rest/EntryPoint.php index f28b4ea80cb4..ee3441e59587 100644 --- a/includes/Rest/EntryPoint.php +++ b/includes/Rest/EntryPoint.php @@ -6,6 +6,7 @@ use ExtensionRegistry; use MediaWiki; use MediaWiki\MediaWikiServices; use MediaWiki\Rest\BasicAccess\MWBasicAuthorizer; +use MediaWiki\Rest\Validator\Validator; use RequestContext; use Title; use WebResponse; @@ -36,6 +37,7 @@ class EntryPoint { $services = MediaWikiServices::getInstance(); $conf = $services->getMainConfig(); + $objectFactory = $services->getObjectFactory(); if ( !$conf->get( 'EnableRestAPI' ) ) { wfHttpError( 403, 'Access Denied', @@ -51,6 +53,9 @@ class EntryPoint { $authorizer = new MWBasicAuthorizer( $context->getUser(), $services->getPermissionManager() ); + // @phan-suppress-next-line PhanAccessMethodInternal + $restValidator = new Validator( $objectFactory, $request, RequestContext::getMain()->getUser() ); + global $IP; $router = new Router( [ "$IP/includes/Rest/coreRoutes.json" ], @@ -58,7 +63,9 @@ class EntryPoint { $conf->get( 'RestPath' ), $services->getLocalServerObjectCache(), new ResponseFactory, - $authorizer + $authorizer, + $objectFactory, + $restValidator ); $entryPoint = new self( diff --git a/includes/Rest/Handler.php b/includes/Rest/Handler.php index c05d8e774a0c..efe2b7e9e25b 100644 --- a/includes/Rest/Handler.php +++ b/includes/Rest/Handler.php @@ -2,7 +2,18 @@ namespace MediaWiki\Rest; +use MediaWiki\Rest\Validator\BodyValidator; +use MediaWiki\Rest\Validator\NullBodyValidator; +use MediaWiki\Rest\Validator\Validator; + abstract class Handler { + + /** + * (string) ParamValidator constant to specify the source of the parameter. + * Value must be 'path', 'query', or 'post'. + */ + const PARAM_SOURCE = 'rest-param-source'; + /** @var Router */ private $router; @@ -15,6 +26,12 @@ abstract class Handler { /** @var ResponseFactory */ private $responseFactory; + /** @var array|null */ + private $validatedParams; + + /** @var mixed */ + private $validatedBody; + /** * Initialise with dependencies from the Router. This is called after construction. * @internal @@ -69,6 +86,62 @@ abstract class Handler { } /** + * Validate the request parameters/attributes and body. If there is a validation + * failure, a response with an error message should be returned or an + * HttpException should be thrown. + * + * @param Validator $restValidator + * @throws HttpException On validation failure. + */ + public function validate( Validator $restValidator ) { + $validatedParams = $restValidator->validateParams( $this->getParamSettings() ); + $validatedBody = $restValidator->validateBody( $this->request, $this ); + $this->validatedParams = $validatedParams; + $this->validatedBody = $validatedBody; + } + + /** + * Fetch ParamValidator settings for parameters + * + * Every setting must include self::PARAM_SOURCE to specify which part of + * the request is to contain the parameter. + * + * @return array[] Associative array mapping parameter names to + * ParamValidator settings arrays + */ + public function getParamSettings() { + return []; + } + + /** + * Fetch the BodyValidator + * @param string $contentType Content type of the request. + * @return BodyValidator + */ + public function getBodyValidator( $contentType ) { + return new NullBodyValidator(); + } + + /** + * Fetch the validated parameters + * + * @return array|null Array mapping parameter names to validated values, + * or null if validateParams() was not called yet or validation failed. + */ + public function getValidatedParams() { + return $this->validatedParams; + } + + /** + * Fetch the validated body + * @return mixed Value returned by the body validator, or null if validateParams() was + * not called yet, validation failed, there was no body, or the body was form data. + */ + public function getValidatedBody() { + return $this->validatedBody; + } + + /** * The subclass should override this to provide the maximum last modified * timestamp for the current request. This is called before execute() in * order to decide whether to send a 304. diff --git a/includes/Rest/Handler/HelloHandler.php b/includes/Rest/Handler/HelloHandler.php index 34faee26d35b..495b10139a93 100644 --- a/includes/Rest/Handler/HelloHandler.php +++ b/includes/Rest/Handler/HelloHandler.php @@ -2,6 +2,7 @@ namespace MediaWiki\Rest\Handler; +use Wikimedia\ParamValidator\ParamValidator; use MediaWiki\Rest\SimpleHandler; /** @@ -16,4 +17,14 @@ class HelloHandler extends SimpleHandler { public function needsWriteAccess() { return false; } + + public function getParamSettings() { + return [ + 'name' => [ + self::PARAM_SOURCE => 'path', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => true, + ], + ]; + } } diff --git a/includes/Rest/HttpException.php b/includes/Rest/HttpException.php index ae6dde2b3f4f..bcc414fdf129 100644 --- a/includes/Rest/HttpException.php +++ b/includes/Rest/HttpException.php @@ -8,7 +8,19 @@ namespace MediaWiki\Rest; * error response. */ class HttpException extends \Exception { - public function __construct( $message, $code = 500 ) { + + /** @var array|null */ + private $errorData = null; + + public function __construct( $message, $code = 500, $errorData = null ) { parent::__construct( $message, $code ); + $this->errorData = $errorData; + } + + /** + * @return array|null + */ + public function getErrorData() { + return $this->errorData; } } diff --git a/includes/Rest/ResponseFactory.php b/includes/Rest/ResponseFactory.php index d18cdb5d6bbe..5e5a19852d8a 100644 --- a/includes/Rest/ResponseFactory.php +++ b/includes/Rest/ResponseFactory.php @@ -175,8 +175,13 @@ class ResponseFactory { public function createFromException( $exception ) { if ( $exception instanceof HttpException ) { // FIXME can HttpException represent 2xx or 3xx responses? - $response = $this->createHttpError( $exception->getCode(), - [ 'message' => $exception->getMessage() ] ); + $response = $this->createHttpError( + $exception->getCode(), + array_merge( + [ 'message' => $exception->getMessage() ], + (array)$exception->getErrorData() + ) + ); } else { $response = $this->createHttpError( 500, [ 'message' => 'Error: exception of type ' . get_class( $exception ), diff --git a/includes/Rest/Router.php b/includes/Rest/Router.php index 961da0147104..a520130d0616 100644 --- a/includes/Rest/Router.php +++ b/includes/Rest/Router.php @@ -6,6 +6,7 @@ use AppendIterator; use BagOStuff; use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface; use MediaWiki\Rest\PathTemplateMatcher\PathMatcher; +use MediaWiki\Rest\Validator\Validator; use Wikimedia\ObjectFactory; /** @@ -44,6 +45,12 @@ class Router { /** @var BasicAuthorizerInterface */ private $basicAuth; + /** @var ObjectFactory */ + private $objectFactory; + + /** @var Validator */ + private $restValidator; + /** * @param string[] $routeFiles List of names of JSON files containing routes * @param array $extraRoutes Extension route array @@ -51,10 +58,13 @@ class Router { * @param BagOStuff $cacheBag A cache in which to store the matcher trees * @param ResponseFactory $responseFactory * @param BasicAuthorizerInterface $basicAuth + * @param ObjectFactory $objectFactory + * @param Validator $restValidator */ public function __construct( $routeFiles, $extraRoutes, $rootPath, BagOStuff $cacheBag, ResponseFactory $responseFactory, - BasicAuthorizerInterface $basicAuth + BasicAuthorizerInterface $basicAuth, ObjectFactory $objectFactory, + Validator $restValidator ) { $this->routeFiles = $routeFiles; $this->extraRoutes = $extraRoutes; @@ -62,6 +72,8 @@ class Router { $this->cacheBag = $cacheBag; $this->responseFactory = $responseFactory; $this->basicAuth = $basicAuth; + $this->objectFactory = $objectFactory; + $this->restValidator = $restValidator; } /** @@ -245,9 +257,10 @@ class Router { $request->setPathParams( array_map( 'rawurldecode', $match['params'] ) ); $spec = $match['userData']; $objectFactorySpec = array_intersect_key( $spec, + // @todo ObjectFactory supports more keys than this. [ 'factory' => true, 'class' => true, 'args' => true ] ); /** @var $handler Handler (annotation for PHPStorm) */ - $handler = ObjectFactory::getObjectFromSpec( $objectFactorySpec ); + $handler = $this->objectFactory->createObject( $objectFactorySpec ); $handler->init( $this, $request, $spec, $this->responseFactory ); try { @@ -268,6 +281,9 @@ class Router { if ( $authResult ) { return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] ); } + + $handler->validate( $this->restValidator ); + $response = $handler->execute(); if ( !( $response instanceof ResponseInterface ) ) { $response = $this->responseFactory->createFromReturnValue( $response ); diff --git a/includes/Rest/SimpleHandler.php b/includes/Rest/SimpleHandler.php index 3718d66b9350..3c19e48e876e 100644 --- a/includes/Rest/SimpleHandler.php +++ b/includes/Rest/SimpleHandler.php @@ -14,7 +14,26 @@ namespace MediaWiki\Rest; */ class SimpleHandler extends Handler { public function execute() { - $params = array_values( $this->getRequest()->getPathParams() ); + $paramSettings = $this->getParamSettings(); + $validatedParams = $this->getValidatedParams(); + $unvalidatedParams = []; + $params = []; + foreach ( $this->getRequest()->getPathParams() as $name => $value ) { + $source = $paramSettings[$name][self::PARAM_SOURCE] ?? 'unknown'; + if ( $source !== 'path' ) { + $unvalidatedParams[] = $name; + $params[] = $value; + } else { + $params[] = $validatedParams[$name]; + } + } + + if ( $unvalidatedParams ) { + throw new \LogicException( + 'Path parameters were not validated: ' . implode( ', ', $unvalidatedParams ) + ); + } + // @phan-suppress-next-line PhanUndeclaredMethod return $this->run( ...$params ); } diff --git a/includes/Rest/Validator/BodyValidator.php b/includes/Rest/Validator/BodyValidator.php new file mode 100644 index 000000000000..0147fa880cf3 --- /dev/null +++ b/includes/Rest/Validator/BodyValidator.php @@ -0,0 +1,26 @@ +<?php + +namespace MediaWiki\Rest\Validator; + +use MediaWiki\Rest\HttpException; +use MediaWiki\Rest\RequestInterface; + +/** + * Interface for validating a request body + */ +interface BodyValidator { + + /** + * Validate the body of a request. + * + * This may return a data structure representing the parsed body. When used + * in the context of Handler::validateParams(), the returned value will be + * available to the handler via Handler::getValidatedBody(). + * + * @param RequestInterface $request + * @return mixed + * @throws HttpException on validation failure + */ + public function validateBody( RequestInterface $request ); + +} diff --git a/includes/Rest/Validator/NullBodyValidator.php b/includes/Rest/Validator/NullBodyValidator.php new file mode 100644 index 000000000000..4fba5fb1e3ac --- /dev/null +++ b/includes/Rest/Validator/NullBodyValidator.php @@ -0,0 +1,16 @@ +<?php + +namespace MediaWiki\Rest\Validator; + +use MediaWiki\Rest\RequestInterface; + +/** + * Do-nothing body validator + */ +class NullBodyValidator implements BodyValidator { + + public function validateBody( RequestInterface $request ) { + return null; + } + +} diff --git a/includes/Rest/Validator/ParamValidatorCallbacks.php b/includes/Rest/Validator/ParamValidatorCallbacks.php new file mode 100644 index 000000000000..6c54a504d796 --- /dev/null +++ b/includes/Rest/Validator/ParamValidatorCallbacks.php @@ -0,0 +1,82 @@ +<?php + +namespace MediaWiki\Rest\Validator; + +use InvalidArgumentException; +use MediaWiki\Rest\RequestInterface; +use Psr\Http\Message\UploadedFileInterface; +use User; +use Wikimedia\ParamValidator\Callbacks; +use Wikimedia\ParamValidator\ValidationException; + +class ParamValidatorCallbacks implements Callbacks { + + /** @var RequestInterface */ + private $request; + + /** @var User */ + private $user; + + public function __construct( RequestInterface $request, User $user ) { + $this->request = $request; + $this->user = $user; + } + + /** + * Get the raw parameters from a source in the request + * @param string $source 'path', 'query', or 'post' + * @return array + */ + private function getParamsFromSource( $source ) { + switch ( $source ) { + case 'path': + return $this->request->getPathParams(); + + case 'query': + return $this->request->getQueryParams(); + + case 'post': + return $this->request->getPostParams(); + + default: + throw new InvalidArgumentException( __METHOD__ . ": Invalid source '$source'" ); + } + } + + public function hasParam( $name, array $options ) { + $params = $this->getParamsFromSource( $options['source'] ); + return isset( $params[$name] ); + } + + public function getValue( $name, $default, array $options ) { + $params = $this->getParamsFromSource( $options['source'] ); + return $params[$name] ?? $default; + // @todo Should normalization to NFC UTF-8 be done here (much like in the + // action API and the rest of MW), or should it be left to handlers to + // do whatever normalization they need? + } + + public function hasUpload( $name, array $options ) { + if ( $options['source'] !== 'post' ) { + return false; + } + return $this->getUploadedFile( $name, $options ) !== null; + } + + public function getUploadedFile( $name, array $options ) { + if ( $options['source'] !== 'post' ) { + return null; + } + $upload = $this->request->getUploadedFiles()[$name] ?? null; + return $upload instanceof UploadedFileInterface ? $upload : null; + } + + public function recordCondition( ValidationException $condition, array $options ) { + // @todo Figure out how to handle warnings + } + + public function useHighLimits( array $options ) { + return $this->user->isAllowed( 'apihighlimits' ); + } + +} diff --git a/includes/Rest/Validator/Validator.php b/includes/Rest/Validator/Validator.php new file mode 100644 index 000000000000..cee1cdb35973 --- /dev/null +++ b/includes/Rest/Validator/Validator.php @@ -0,0 +1,163 @@ +<?php + +namespace MediaWiki\Rest\Validator; + +use MediaWiki\Rest\Handler; +use MediaWiki\Rest\HttpException; +use MediaWiki\Rest\RequestInterface; +use User; +use Wikimedia\ObjectFactory; +use Wikimedia\ParamValidator\ParamValidator; +use Wikimedia\ParamValidator\TypeDef\BooleanDef; +use Wikimedia\ParamValidator\TypeDef\EnumDef; +use Wikimedia\ParamValidator\TypeDef\FloatDef; +use Wikimedia\ParamValidator\TypeDef\IntegerDef; +use Wikimedia\ParamValidator\TypeDef\PasswordDef; +use Wikimedia\ParamValidator\TypeDef\StringDef; +use Wikimedia\ParamValidator\TypeDef\TimestampDef; +use Wikimedia\ParamValidator\TypeDef\UploadDef; +use Wikimedia\ParamValidator\ValidationException; + +/** + * Wrapper for ParamValidator + * + * It's intended to be used in the REST API classes by composition. + * + * @since 1.34 + */ +class Validator { + + /** @var array Type defs for ParamValidator */ + private static $typeDefs = [ + 'boolean' => [ 'class' => BooleanDef::class ], + 'enum' => [ 'class' => EnumDef::class ], + 'integer' => [ 'class' => IntegerDef::class ], + 'float' => [ 'class' => FloatDef::class ], + 'double' => [ 'class' => FloatDef::class ], + 'NULL' => [ + 'class' => StringDef::class, + 'args' => [ [ + 'allowEmptyWhenRequired' => true, + ] ], + ], + 'password' => [ 'class' => PasswordDef::class ], + 'string' => [ 'class' => StringDef::class ], + 'timestamp' => [ 'class' => TimestampDef::class ], + 'upload' => [ 'class' => UploadDef::class ], + ]; + + /** @var string[] HTTP request methods that we expect never to have a payload */ + private static $noBodyMethods = [ 'GET', 'HEAD', 'DELETE' ]; + + /** @var string[] HTTP request methods that we expect always to have a payload */ + private static $bodyMethods = [ 'POST', 'PUT' ]; + + /** @var string[] Content types handled via $_POST */ + private static $formDataContentTypes = [ + 'application/x-www-form-urlencoded', + 'multipart/form-data', + ]; + + /** @var ParamValidator */ + private $paramValidator; + + /** + * @internal + * @param ObjectFactory $objectFactory + * @param RequestInterface $request + * @param User $user + */ + public function __construct( + ObjectFactory $objectFactory, RequestInterface $request, User $user + ) { + $this->paramValidator = new ParamValidator( + new ParamValidatorCallbacks( $request, $user ), + $objectFactory, + [ + 'typeDefs' => self::$typeDefs, + ] + ); + } + + /** + * Validate parameters + * @param array[] $paramSettings Parameter settings + * @return array Validated parameters + * @throws HttpException on validaton failure + */ + public function validateParams( array $paramSettings ) { + $validatedParams = []; + foreach ( $paramSettings as $name => $settings ) { + try { + $validatedParams[$name] = $this->paramValidator->getValue( $name, $settings, [ + 'source' => $settings[Handler::PARAM_SOURCE] ?? 'unspecified', + ] ); + } catch ( ValidationException $e ) { + throw new HttpException( 'Parameter validation failed', 400, [ + 'error' => 'parameter-validation-failed', + 'name' => $e->getParamName(), + 'value' => $e->getParamValue(), + 'failureCode' => $e->getFailureCode(), + 'failureData' => $e->getFailureData(), + ] ); + } + } + return $validatedParams; + } + + /** + * Validate the body of a request. + * + * This may return a data structure representing the parsed body. When used + * in the context of Handler::validateParams(), the returned value will be + * available to the handler via Handler::getValidatedBody(). + * + * @param RequestInterface $request + * @param Handler $handler Used to call getBodyValidator() + * @return mixed May be null + * @throws HttpException on validation failure + */ + public function validateBody( RequestInterface $request, Handler $handler ) { + $method = strtoupper( trim( $request->getMethod() ) ); + + // If the method should never have a body, don't bother validating. + if ( in_array( $method, self::$noBodyMethods, true ) ) { + return null; + } + + // Get the content type + list( $ct ) = explode( ';', $request->getHeaderLine( 'Content-Type' ), 2 ); + $ct = strtolower( trim( $ct ) ); + if ( $ct === '' ) { + // No Content-Type was supplied. RFC 7231 ยง 3.1.1.5 allows this, but since it's probably a + // client error let's return a 415. But don't 415 for unknown methods and an empty body. + if ( !in_array( $method, self::$bodyMethods, true ) ) { + $body = $request->getBody(); + $size = $body->getSize(); + if ( $size === null ) { + // No size available. Try reading 1 byte. + if ( $body->isSeekable() ) { + $body->rewind(); + } + $size = $body->read( 1 ) === '' ? 0 : 1; + } + if ( $size === 0 ) { + return null; + } + } + throw new HttpException( "A Content-Type header must be supplied with a request payload.", 415, [ + 'error' => 'no-content-type', + ] ); + } + + // Form data is parsed into $_POST and $_FILES by PHP and from there is accessed as parameters, + // don't bother trying to handle these via BodyValidator too. + if ( in_array( $ct, self::$formDataContentTypes, true ) ) { + return null; + } + + // Validate the body. BodyValidator throws an HttpException on failure. + return $handler->getBodyValidator( $ct )->validateBody( $request ); + } + +} |