From 854352d9a064a1e469ede207493bce44fd41d96c Mon Sep 17 00:00:00 2001 From: VicDeo Date: Wed, 22 Jun 2016 14:12:36 +0300 Subject: [PATCH] occ web executor (#24957) * Initial web executor * Fix PHPDoc Fix broken integration test OccControllerTests do not require database access - moch them all! Kill unused sprintf --- core/Application.php | 13 ++ core/Controller/OccController.php | 147 ++++++++++++++++++++ core/routes.php | 1 + lib/base.php | 19 ++- lib/private/Console/Application.php | 3 +- public.php | 4 +- tests/Core/Controller/OccControllerTest.php | 143 +++++++++++++++++++ 7 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 core/Controller/OccController.php create mode 100644 tests/Core/Controller/OccControllerTest.php diff --git a/core/Application.php b/core/Application.php index a87917b626..8ea2672e54 100644 --- a/core/Application.php +++ b/core/Application.php @@ -32,6 +32,7 @@ use OC\AppFramework\Utility\TimeFactory; use OC\Core\Controller\AvatarController; use OC\Core\Controller\LoginController; use OC\Core\Controller\LostController; +use OC\Core\Controller\OccController; use OC\Core\Controller\TokenController; use OC\Core\Controller\TwoFactorChallengeController; use OC\Core\Controller\UserController; @@ -125,6 +126,18 @@ class Application extends App { $c->query('SecureRandom') ); }); + $container->registerService('OccController', function(SimpleContainer $c) { + return new OccController( + $c->query('AppName'), + $c->query('Request'), + $c->query('Config'), + new \OC\Console\Application( + $c->query('Config'), + $c->query('ServerContainer')->getEventDispatcher(), + $c->query('Request') + ) + ); + }); /** * Core class wrappers diff --git a/core/Controller/OccController.php b/core/Controller/OccController.php new file mode 100644 index 0000000000..917d02f37f --- /dev/null +++ b/core/Controller/OccController.php @@ -0,0 +1,147 @@ + + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OC\Core\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OC\Console\Application; +use OCP\IConfig; +use OCP\IRequest; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; + +class OccController extends Controller { + + /** @var array */ + private $allowedCommands = [ + 'app:disable', + 'app:enable', + 'app:getpath', + 'app:list', + 'check', + 'config:list', + 'maintenance:mode', + 'status', + 'upgrade' + ]; + + /** @var IConfig */ + private $config; + /** @var Application */ + private $console; + + /** + * OccController constructor. + * + * @param string $appName + * @param IRequest $request + * @param IConfig $config + * @param Application $console + */ + public function __construct($appName, IRequest $request, + IConfig $config, Application $console) { + parent::__construct($appName, $request); + $this->config = $config; + $this->console = $console; + } + + /** + * @PublicPage + * @NoCSRFRequired + * + * Execute occ command + * Sample request + * POST http://domain.tld/index.php/occ/status', + * { + * 'params': { + * '--no-warnings':'1', + * '--output':'json' + * }, + * 'token': 'someToken' + * } + * + * @param string $command + * @param string $token + * @param array $params + * + * @return JSONResponse + * @throws \Exception + */ + public function execute($command, $token, $params = []) { + try { + $this->validateRequest($command, $token); + + $output = new BufferedOutput(); + $formatter = $output->getFormatter(); + $formatter->setDecorated(false); + $this->console->setAutoExit(false); + $this->console->loadCommands(new ArrayInput([]), $output); + + $inputArray = array_merge(['command' => $command], $params); + $input = new ArrayInput($inputArray); + + $exitCode = $this->console->run($input, $output); + $response = $output->fetch(); + + $json = [ + 'exitCode' => $exitCode, + 'response' => $response + ]; + + } catch (\UnexpectedValueException $e){ + $json = [ + 'exitCode' => 126, + 'response' => 'Not allowed', + 'details' => $e->getMessage() + ]; + } + return new JSONResponse($json); + } + + /** + * Check if command is allowed and has a valid security token + * @param $command + * @param $token + */ + protected function validateRequest($command, $token){ + if (!in_array($this->request->getRemoteAddress(), ['::1', '127.0.0.1', 'localhost'])) { + throw new \UnexpectedValueException('Web executor is not allowed to run from a different host'); + } + + if (!in_array($command, $this->allowedCommands)) { + throw new \UnexpectedValueException(sprintf('Command "%s" is not allowed to run via web request', $command)); + } + + $coreToken = $this->config->getSystemValue('updater.secret', ''); + if ($coreToken === '') { + throw new \UnexpectedValueException( + 'updater.secret is undefined in config/config.php. Either browse the admin settings in your ownCloud and click "Open updater" or define a strong secret using
php -r \'echo password_hash("MyStrongSecretDoUseYourOwn!", PASSWORD_DEFAULT)."\n";\'
and set this in the config.php.' + ); + } + + if (!password_verify($token, $coreToken)) { + throw new \UnexpectedValueException( + 'updater.secret does not match the provided token' + ); + } + } +} diff --git a/core/routes.php b/core/routes.php index 402277d8f3..c473408e2e 100644 --- a/core/routes.php +++ b/core/routes.php @@ -48,6 +48,7 @@ $application->registerRoutes($this, [ ['name' => 'login#showLoginForm', 'url' => '/login', 'verb' => 'GET'], ['name' => 'login#logout', 'url' => '/logout', 'verb' => 'GET'], ['name' => 'token#generateToken', 'url' => '/token/generate', 'verb' => 'POST'], + ['name' => 'occ#execute', 'url' => '/occ/{command}', 'verb' => 'POST'], ['name' => 'TwoFactorChallenge#selectChallenge', 'url' => '/login/selectchallenge', 'verb' => 'GET'], ['name' => 'TwoFactorChallenge#showChallenge', 'url' => '/login/challenge/{challengeProviderId}', 'verb' => 'GET'], ['name' => 'TwoFactorChallenge#solveChallenge', 'url' => '/login/challenge/{challengeProviderId}', 'verb' => 'POST'], diff --git a/lib/base.php b/lib/base.php index b33687dbab..45f291e5cb 100644 --- a/lib/base.php +++ b/lib/base.php @@ -49,6 +49,8 @@ * */ +use OCP\IRequest; + require_once 'public/Constants.php'; /** @@ -271,9 +273,20 @@ class OC { } } - public static function checkMaintenanceMode() { + /** + * Limit maintenance mode access + * @param IRequest $request + */ + public static function checkMaintenanceMode(IRequest $request) { + // Check if requested URL matches 'index.php/occ' + $isOccControllerRequested = preg_match('|/index\.php$|', $request->getScriptName()) === 1 + && strpos($request->getPathInfo(), '/occ/') === 0; // Allow ajax update script to execute without being stopped - if (\OC::$server->getSystemConfig()->getValue('maintenance', false) && OC::$SUBURI != '/core/ajax/update.php') { + if ( + \OC::$server->getSystemConfig()->getValue('maintenance', false) + && OC::$SUBURI != '/core/ajax/update.php' + && !$isOccControllerRequested + ) { // send http status 503 header('HTTP/1.1 503 Service Temporarily Unavailable'); header('Status: 503 Service Temporarily Unavailable'); @@ -820,7 +833,7 @@ class OC { $request = \OC::$server->getRequest(); $requestPath = $request->getRawPathInfo(); if (substr($requestPath, -3) !== '.js') { // we need these files during the upgrade - self::checkMaintenanceMode(); + self::checkMaintenanceMode($request); self::checkUpgrade(); } diff --git a/lib/private/Console/Application.php b/lib/private/Console/Application.php index ec91064278..8a9191a4c5 100644 --- a/lib/private/Console/Application.php +++ b/lib/private/Console/Application.php @@ -138,9 +138,10 @@ class Application { * @throws \Exception */ public function run(InputInterface $input = null, OutputInterface $output = null) { + $args = isset($this->request->server['argv']) ? $this->request->server['argv'] : []; $this->dispatcher->dispatch(ConsoleEvent::EVENT_RUN, new ConsoleEvent( ConsoleEvent::EVENT_RUN, - $this->request->server['argv'] + $args )); return $this->application->run($input, $output); } diff --git a/public.php b/public.php index 964ed03c1a..b7125502ee 100644 --- a/public.php +++ b/public.php @@ -35,9 +35,9 @@ try { exit; } - OC::checkMaintenanceMode(); - OC::checkSingleUserMode(true); $request = \OC::$server->getRequest(); + OC::checkMaintenanceMode($request); + OC::checkSingleUserMode(true); $pathInfo = $request->getPathInfo(); if (!$pathInfo && $request->getParam('service', '') === '') { diff --git a/tests/Core/Controller/OccControllerTest.php b/tests/Core/Controller/OccControllerTest.php new file mode 100644 index 0000000000..682d917009 --- /dev/null +++ b/tests/Core/Controller/OccControllerTest.php @@ -0,0 +1,143 @@ + + * + * @copyright Copyright (c) 2015, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace Tests\Core\Controller; + +use OC\Console\Application; +use OC\Core\Controller\OccController; +use OCP\IConfig; +use Symfony\Component\Console\Output\Output; +use Test\TestCase; + +/** + * Class OccControllerTest + * + * @package OC\Core\Controller + */ +class OccControllerTest extends TestCase { + + const TEMP_SECRET = 'test'; + + /** @var \OC\AppFramework\Http\Request | \PHPUnit_Framework_MockObject_MockObject */ + private $request; + /** @var \OC\Core\Controller\OccController | \PHPUnit_Framework_MockObject_MockObject */ + private $controller; + /** @var IConfig | \PHPUnit_Framework_MockObject_MockObject */ + private $config; + /** @var Application | \PHPUnit_Framework_MockObject_MockObject */ + private $console; + + public function testFromInvalidLocation(){ + $this->getControllerMock('example.org'); + + $response = $this->controller->execute('status', ''); + $responseData = $response->getData(); + + $this->assertArrayHasKey('exitCode', $responseData); + $this->assertEquals(126, $responseData['exitCode']); + + $this->assertArrayHasKey('details', $responseData); + $this->assertEquals('Web executor is not allowed to run from a different host', $responseData['details']); + } + + public function testNotWhiteListedCommand(){ + $this->getControllerMock('localhost'); + + $response = $this->controller->execute('missing_command', ''); + $responseData = $response->getData(); + + $this->assertArrayHasKey('exitCode', $responseData); + $this->assertEquals(126, $responseData['exitCode']); + + $this->assertArrayHasKey('details', $responseData); + $this->assertEquals('Command "missing_command" is not allowed to run via web request', $responseData['details']); + } + + public function testWrongToken(){ + $this->getControllerMock('localhost'); + + $response = $this->controller->execute('status', self::TEMP_SECRET . '-'); + $responseData = $response->getData(); + + $this->assertArrayHasKey('exitCode', $responseData); + $this->assertEquals(126, $responseData['exitCode']); + + $this->assertArrayHasKey('details', $responseData); + $this->assertEquals('updater.secret does not match the provided token', $responseData['details']); + } + + public function testSuccess(){ + $this->getControllerMock('localhost'); + $this->console->expects($this->once())->method('run') + ->willReturnCallback( + function ($input, $output) { + /** @var Output $output */ + $output->writeln('{"installed":true,"version":"9.1.0.8","versionstring":"9.1.0 beta 2","edition":""}'); + return 0; + } + ); + + $response = $this->controller->execute('status', self::TEMP_SECRET, ['--output'=>'json']); + $responseData = $response->getData(); + + $this->assertArrayHasKey('exitCode', $responseData); + $this->assertEquals(0, $responseData['exitCode']); + + $this->assertArrayHasKey('response', $responseData); + $decoded = json_decode($responseData['response'], true); + + $this->assertArrayHasKey('installed', $decoded); + $this->assertEquals(true, $decoded['installed']); + } + + private function getControllerMock($host){ + $this->request = $this->getMockBuilder('OC\AppFramework\Http\Request') + ->setConstructorArgs([ + ['server' => []], + \OC::$server->getSecureRandom(), + \OC::$server->getConfig() + ]) + ->setMethods(['getRemoteAddress']) + ->getMock(); + + $this->request->expects($this->any())->method('getRemoteAddress') + ->will($this->returnValue($host)); + + $this->config = $this->getMockBuilder('\OCP\IConfig') + ->disableOriginalConstructor() + ->getMock(); + $this->config->expects($this->any())->method('getSystemValue') + ->with('updater.secret') + ->willReturn(password_hash(self::TEMP_SECRET, PASSWORD_DEFAULT)); + + $this->console = $this->getMockBuilder('\OC\Console\Application') + ->disableOriginalConstructor() + ->getMock(); + + $this->controller = new OccController( + 'core', + $this->request, + $this->config, + $this->console + ); + } + +}