diff options
Diffstat (limited to 'includes')
-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 ); + } + +} |