From 94c0baaa2fb1761292539aad689df54fe44d2d46 Mon Sep 17 00:00:00 2001 From: Tim Starling Date: Wed, 26 Jun 2019 12:33:35 +1000 Subject: [PATCH] REST: basic read restrictions Protect private wikis by providing basic read restrictions, closely following the example of the action API. The BasicAccess module provides a narrow interface for this functionality, without exposing the whole session/user concept to the router. Also, add RouterTest and fix a bug in Router::getRelativePath() thus discovered. Change-Id: I82319d56f08b2eec4a585ff6dbd348ccdbadc5b5 --- .../Rest/BasicAccess/BasicAuthorizerBase.php | 28 ++++++ .../BasicAccess/BasicAuthorizerInterface.php | 28 ++++++ .../BasicAccess/BasicRequestAuthorizer.php | 46 ++++++++++ .../Rest/BasicAccess/MWBasicAuthorizer.php | 33 +++++++ .../BasicAccess/MWBasicRequestAuthorizer.php | 38 +++++++++ .../BasicAccess/StaticBasicAuthorizer.php | 30 +++++++ includes/Rest/EntryPoint.php | 7 +- includes/Rest/Handler.php | 29 +++++++ includes/Rest/Handler/HelloHandler.php | 4 + includes/Rest/Router.php | 17 +++- .../MWBasicRequestAuthorizerTest.php | 73 ++++++++++++++++ .../unit/includes/Rest/EntryPointTest.php | 4 +- .../Rest/Handler/HelloHandlerTest.php | 4 +- .../phpunit/unit/includes/Rest/RouterTest.php | 85 +++++++++++++++++++ .../unit/includes/Rest/testRoutes.json | 8 ++ 15 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 includes/Rest/BasicAccess/BasicAuthorizerBase.php create mode 100644 includes/Rest/BasicAccess/BasicAuthorizerInterface.php create mode 100644 includes/Rest/BasicAccess/BasicRequestAuthorizer.php create mode 100644 includes/Rest/BasicAccess/MWBasicAuthorizer.php create mode 100644 includes/Rest/BasicAccess/MWBasicRequestAuthorizer.php create mode 100644 includes/Rest/BasicAccess/StaticBasicAuthorizer.php create mode 100644 tests/phpunit/includes/Rest/BasicAccess/MWBasicRequestAuthorizerTest.php create mode 100644 tests/phpunit/unit/includes/Rest/RouterTest.php diff --git a/includes/Rest/BasicAccess/BasicAuthorizerBase.php b/includes/Rest/BasicAccess/BasicAuthorizerBase.php new file mode 100644 index 0000000000..7aefe255b0 --- /dev/null +++ b/includes/Rest/BasicAccess/BasicAuthorizerBase.php @@ -0,0 +1,28 @@ +createRequestAuthorizer( $request, $handler )->authorize(); + } + + /** + * Create a BasicRequestAuthorizer to authorize the request. + * + * @param RequestInterface $request + * @param Handler $handler + * @return BasicRequestAuthorizer + */ + abstract protected function createRequestAuthorizer( RequestInterface $request, + Handler $handler ) : BasicRequestAuthorizer; +} diff --git a/includes/Rest/BasicAccess/BasicAuthorizerInterface.php b/includes/Rest/BasicAccess/BasicAuthorizerInterface.php new file mode 100644 index 0000000000..7eb7f3fba1 --- /dev/null +++ b/includes/Rest/BasicAccess/BasicAuthorizerInterface.php @@ -0,0 +1,28 @@ +request = $request; + $this->handler = $handler; + } + + /** + * @see BasicAuthorizerInterface::authorize() + * @return string|null If the request is denied, the string error code. If + * the request is allowed, null. + */ + public function authorize() { + if ( $this->handler->needsReadAccess() && !$this->isReadAllowed() ) { + return 'rest-read-denied'; + } + return null; + } + + /** + * Check if the current user is allowed to read from the wiki + * + * @return bool + */ + abstract protected function isReadAllowed(); +} diff --git a/includes/Rest/BasicAccess/MWBasicAuthorizer.php b/includes/Rest/BasicAccess/MWBasicAuthorizer.php new file mode 100644 index 0000000000..43014f1379 --- /dev/null +++ b/includes/Rest/BasicAccess/MWBasicAuthorizer.php @@ -0,0 +1,33 @@ +user = $user; + $this->permissionManager = $permissionManager; + } + + protected function createRequestAuthorizer( RequestInterface $request, + Handler $handler + ): BasicRequestAuthorizer { + return new MWBasicRequestAuthorizer( $request, $handler, $this->user, + $this->permissionManager ); + } +} diff --git a/includes/Rest/BasicAccess/MWBasicRequestAuthorizer.php b/includes/Rest/BasicAccess/MWBasicRequestAuthorizer.php new file mode 100644 index 0000000000..01367d1b13 --- /dev/null +++ b/includes/Rest/BasicAccess/MWBasicRequestAuthorizer.php @@ -0,0 +1,38 @@ +user = $user; + $this->permissionManager = $permissionManager; + } + + protected function isReadAllowed() { + return $this->permissionManager->isEveryoneAllowed( 'read' ) + || $this->isAllowed( 'read' ); + } + + private function isAllowed( $action ) { + return $this->permissionManager->userHasRight( $this->user, $action ); + } +} diff --git a/includes/Rest/BasicAccess/StaticBasicAuthorizer.php b/includes/Rest/BasicAccess/StaticBasicAuthorizer.php new file mode 100644 index 0000000000..c4dcda1426 --- /dev/null +++ b/includes/Rest/BasicAccess/StaticBasicAuthorizer.php @@ -0,0 +1,30 @@ +value = $value; + } + + public function authorize( RequestInterface $request, Handler $handler ) { + return $this->value; + } +} diff --git a/includes/Rest/EntryPoint.php b/includes/Rest/EntryPoint.php index 795999a55c..40d0b4a2f8 100644 --- a/includes/Rest/EntryPoint.php +++ b/includes/Rest/EntryPoint.php @@ -4,6 +4,7 @@ namespace MediaWiki\Rest; use ExtensionRegistry; use MediaWiki\MediaWikiServices; +use MediaWiki\Rest\BasicAccess\MWBasicAuthorizer; use RequestContext; use Title; use WebResponse; @@ -35,13 +36,17 @@ class EntryPoint { 'cookiePrefix' => $conf->get( 'CookiePrefix' ) ] ); + $authorizer = new MWBasicAuthorizer( RequestContext::getMain()->getUser(), + $services->getPermissionManager() ); + global $IP; $router = new Router( [ "$IP/includes/Rest/coreRoutes.json" ], ExtensionRegistry::getInstance()->getAttribute( 'RestRoutes' ), $conf->get( 'RestPath' ), $services->getLocalServerObjectCache(), - new ResponseFactory + new ResponseFactory, + $authorizer ); $entryPoint = new self( diff --git a/includes/Rest/Handler.php b/includes/Rest/Handler.php index cee403fa2f..c05d8e774a 100644 --- a/includes/Rest/Handler.php +++ b/includes/Rest/Handler.php @@ -95,6 +95,35 @@ abstract class Handler { return null; } + /** + * Indicates whether this route requires read rights. + * + * The handler should override this if it does not need to read from the + * wiki. This is uncommon, but may be useful for login and other account + * management APIs. + * + * @return bool + */ + public function needsReadAccess() { + return true; + } + + /** + * Indicates whether this route requires write access. + * + * The handler should override this if the route does not need to write to + * the database. + * + * This should return true for routes that may require synchronous database writes. + * Modules that do not need such writes should also not rely on master database access, + * since only read queries are needed and each master DB is a single point of failure. + * + * @return bool + */ + public function needsWriteAccess() { + return true; + } + /** * Execute the handler. This is called after parameter validation. The * return value can either be a Response or any type accepted by diff --git a/includes/Rest/Handler/HelloHandler.php b/includes/Rest/Handler/HelloHandler.php index 6e119dd651..34faee26d3 100644 --- a/includes/Rest/Handler/HelloHandler.php +++ b/includes/Rest/Handler/HelloHandler.php @@ -12,4 +12,8 @@ class HelloHandler extends SimpleHandler { public function run( $name ) { return [ 'message' => "Hello, $name!" ]; } + + public function needsWriteAccess() { + return false; + } } diff --git a/includes/Rest/Router.php b/includes/Rest/Router.php index 5ba3d08c5c..14b4c9cb89 100644 --- a/includes/Rest/Router.php +++ b/includes/Rest/Router.php @@ -4,6 +4,7 @@ namespace MediaWiki\Rest; use AppendIterator; use BagOStuff; +use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface; use MediaWiki\Rest\PathTemplateMatcher\PathMatcher; use Wikimedia\ObjectFactory; @@ -40,21 +41,27 @@ class Router { /** @var ResponseFactory */ private $responseFactory; + /** @var BasicAuthorizerInterface */ + private $basicAuth; + /** * @param string[] $routeFiles List of names of JSON files containing routes * @param array $extraRoutes Extension route array * @param string $rootPath The base URL path * @param BagOStuff $cacheBag A cache in which to store the matcher trees * @param ResponseFactory $responseFactory + * @param BasicAuthorizerInterface $basicAuth */ public function __construct( $routeFiles, $extraRoutes, $rootPath, - BagOStuff $cacheBag, ResponseFactory $responseFactory + BagOStuff $cacheBag, ResponseFactory $responseFactory, + BasicAuthorizerInterface $basicAuth ) { $this->routeFiles = $routeFiles; $this->extraRoutes = $extraRoutes; $this->rootPath = $rootPath; $this->cacheBag = $cacheBag; $this->responseFactory = $responseFactory; + $this->basicAuth = $basicAuth; } /** @@ -189,7 +196,9 @@ class Router { * @return false|string */ private function getRelativePath( $path ) { - if ( substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0 ) { + if ( strlen( $this->rootPath ) > strlen( $path ) || + substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0 + ) { return false; } return substr( $path, strlen( $this->rootPath ) ); @@ -254,6 +263,10 @@ class Router { * @return ResponseInterface */ private function executeHandler( $handler ): ResponseInterface { + $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler ); + if ( $authResult ) { + return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] ); + } $response = $handler->execute(); if ( !( $response instanceof ResponseInterface ) ) { $response = $this->responseFactory->createFromReturnValue( $response ); diff --git a/tests/phpunit/includes/Rest/BasicAccess/MWBasicRequestAuthorizerTest.php b/tests/phpunit/includes/Rest/BasicAccess/MWBasicRequestAuthorizerTest.php new file mode 100644 index 0000000000..5a16434c23 --- /dev/null +++ b/tests/phpunit/includes/Rest/BasicAccess/MWBasicRequestAuthorizerTest.php @@ -0,0 +1,73 @@ +testUser = $user; + $this->testUserRights = $userRights; + } + + public function userHasRight( UserIdentity $user, $action = '' ) { + if ( $user === $this->testUser ) { + return $this->testUserRights[$action] ?? false; + } + return parent::userHasRight( $user, $action ); + } + }; + + global $IP; + + return new Router( + [ "$IP/tests/phpunit/unit/includes/Rest/testRoutes.json" ], + [], + '/rest', + new \EmptyBagOStuff(), + new ResponseFactory(), + new MWBasicAuthorizer( $user, $pm ) ); + } + + public function testReadDenied() { + $router = $this->createRouter( [ 'read' => false ] ); + $request = new RequestData( [ 'uri' => new Uri( '/rest/user/joe/hello' ) ] ); + $response = $router->execute( $request ); + $this->assertSame( 403, $response->getStatusCode() ); + + $body = $response->getBody(); + $body->rewind(); + $data = json_decode( $body->getContents(), true ); + $this->assertSame( 'rest-read-denied', $data['error'] ); + } + + public function testReadAllowed() { + $router = $this->createRouter( [ 'read' => true ] ); + $request = new RequestData( [ 'uri' => new Uri( '/rest/user/joe/hello' ) ] ); + $response = $router->execute( $request ); + $this->assertSame( 200, $response->getStatusCode() ); + } +} diff --git a/tests/phpunit/unit/includes/Rest/EntryPointTest.php b/tests/phpunit/unit/includes/Rest/EntryPointTest.php index e1f2c883a5..a74c0cbf20 100644 --- a/tests/phpunit/unit/includes/Rest/EntryPointTest.php +++ b/tests/phpunit/unit/includes/Rest/EntryPointTest.php @@ -5,6 +5,7 @@ namespace MediaWiki\Tests\Rest; use EmptyBagOStuff; use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\Stream; +use MediaWiki\Rest\BasicAccess\StaticBasicAuthorizer; use MediaWiki\Rest\Handler; use MediaWiki\Rest\EntryPoint; use MediaWiki\Rest\RequestData; @@ -25,7 +26,8 @@ class EntryPointTest extends \MediaWikiUnitTestCase { [], '/rest', new EmptyBagOStuff(), - new ResponseFactory() ); + new ResponseFactory(), + new StaticBasicAuthorizer() ); } private function createWebResponse() { diff --git a/tests/phpunit/unit/includes/Rest/Handler/HelloHandlerTest.php b/tests/phpunit/unit/includes/Rest/Handler/HelloHandlerTest.php index c68273b758..188629f8eb 100644 --- a/tests/phpunit/unit/includes/Rest/Handler/HelloHandlerTest.php +++ b/tests/phpunit/unit/includes/Rest/Handler/HelloHandlerTest.php @@ -4,6 +4,7 @@ namespace MediaWiki\Tests\Rest\Handler; use EmptyBagOStuff; use GuzzleHttp\Psr7\Uri; +use MediaWiki\Rest\BasicAccess\StaticBasicAuthorizer; use MediaWiki\Rest\RequestData; use MediaWiki\Rest\ResponseFactory; use MediaWiki\Rest\Router; @@ -52,7 +53,8 @@ class HelloHandlerTest extends \MediaWikiUnitTestCase { [], '/rest', new EmptyBagOStuff(), - new ResponseFactory() ); + new ResponseFactory(), + new StaticBasicAuthorizer() ); $request = new RequestData( $requestInfo ); $response = $router->execute( $request ); if ( isset( $responseInfo['statusCode'] ) ) { diff --git a/tests/phpunit/unit/includes/Rest/RouterTest.php b/tests/phpunit/unit/includes/Rest/RouterTest.php new file mode 100644 index 0000000000..6d8e794723 --- /dev/null +++ b/tests/phpunit/unit/includes/Rest/RouterTest.php @@ -0,0 +1,85 @@ +createRouter(); + $request = new RequestData( [ 'uri' => new Uri( '/bogus' ) ] ); + $response = $router->execute( $request ); + $this->assertSame( 404, $response->getStatusCode() ); + } + + public function testWrongMethod() { + $router = $this->createRouter(); + $request = new RequestData( [ + 'uri' => new Uri( '/rest/user/joe/hello' ), + 'method' => 'OPTIONS' + ] ); + $response = $router->execute( $request ); + $this->assertSame( 405, $response->getStatusCode() ); + $this->assertSame( 'Method Not Allowed', $response->getReasonPhrase() ); + $this->assertSame( 'GET', $response->getHeaderLine( 'Allow' ) ); + } + + public function testNoMatch() { + $router = $this->createRouter(); + $request = new RequestData( [ 'uri' => new Uri( '/rest/bogus' ) ] ); + $response = $router->execute( $request ); + $this->assertSame( 404, $response->getStatusCode() ); + // TODO: add more information to the response body and test for its presence here + } + + public static function throwHandlerFactory() { + return new class extends Handler { + public function execute() { + throw new HttpException( 'Mock error', 555 ); + } + }; + } + + public function testException() { + $router = $this->createRouter(); + $request = new RequestData( [ 'uri' => new Uri( '/rest/mock/RouterTest/throw' ) ] ); + $response = $router->execute( $request ); + $this->assertSame( 555, $response->getStatusCode() ); + $body = $response->getBody(); + $body->rewind(); + $data = json_decode( $body->getContents(), true ); + $this->assertSame( 'Mock error', $data['message'] ); + } + + public function testBasicAccess() { + $router = $this->createRouter( 'test-error' ); + // Using the throwing handler is a way to assert that the handler is not executed + $request = new RequestData( [ 'uri' => new Uri( '/rest/mock/RouterTest/throw' ) ] ); + $response = $router->execute( $request ); + $this->assertSame( 403, $response->getStatusCode() ); + $body = $response->getBody(); + $body->rewind(); + $data = json_decode( $body->getContents(), true ); + $this->assertSame( 'test-error', $data['error'] ); + } +} diff --git a/tests/phpunit/unit/includes/Rest/testRoutes.json b/tests/phpunit/unit/includes/Rest/testRoutes.json index 7e43bb0c5d..65eda6b141 100644 --- a/tests/phpunit/unit/includes/Rest/testRoutes.json +++ b/tests/phpunit/unit/includes/Rest/testRoutes.json @@ -10,5 +10,13 @@ { "path": "/mock/EntryPoint/bodyRewind", "factory": "MediaWiki\\Tests\\Rest\\EntryPointTest::mockHandlerBodyRewind" + }, + { + "path": "/mock/RouterTest/throw", + "factory": "MediaWiki\\Tests\\Rest\\RouterTest::throwHandlerFactory" + }, + { + "path": "/mock/MWBasicRequestAuthorizerTest/write", + "factory": "MediaWiki\\Tests\\Rest\\BasicAccess\\MWBasicRequestAuthorizerTest::writeHandlerFactory" } ] -- 2.20.1