diff options
54 files changed, 2290 insertions, 134 deletions
diff --git a/apps/dav/lib/Connector/PublicAuth.php b/apps/dav/lib/Connector/PublicAuth.php index 9b386c7609d..38d91f086c7 100644 --- a/apps/dav/lib/Connector/PublicAuth.php +++ b/apps/dav/lib/Connector/PublicAuth.php @@ -98,7 +98,7 @@ class PublicAuth extends AbstractBasic { if ($this->shareManager->checkPassword($share, $password)) { return true; } else if ($this->session->exists('public_link_authenticated') - && $this->session->get('public_link_authenticated') === $share->getId()) { + && $this->session->get('public_link_authenticated') === (string)$share->getId()) { return true; } else { if (in_array('XMLHttpRequest', explode(',', $this->request->getHeader('X-Requested-With')))) { diff --git a/apps/dav/lib/Connector/Sabre/CustomPropertiesBackend.php b/apps/dav/lib/Connector/Sabre/CustomPropertiesBackend.php index d0d348e170e..b319350c7f0 100644 --- a/apps/dav/lib/Connector/Sabre/CustomPropertiesBackend.php +++ b/apps/dav/lib/Connector/Sabre/CustomPropertiesBackend.php @@ -327,7 +327,7 @@ class CustomPropertiesBackend implements BackendInterface { $result = $this->connection->executeQuery( $sql, - array($this->user, rtrim($path, '/') . '/%', $requestedProperties), + array($this->user, $this->connection->escapeLikeParameter(rtrim($path, '/')) . '/%', $requestedProperties), array(null, null, \Doctrine\DBAL\Connection::PARAM_STR_ARRAY) ); diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index c0cb5ecd62d..982d299c951 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -73,13 +73,16 @@ class Server { $this->server->setBaseUri($this->baseUri); $this->server->addPlugin(new BlockLegacyClientPlugin(\OC::$server->getConfig())); - $authPlugin = new Plugin($authBackend, 'ownCloud'); + $authPlugin = new Plugin(); $this->server->addPlugin($authPlugin); // allow setup of additional auth backends $event = new SabrePluginEvent($this->server); $dispatcher->dispatch('OCA\DAV\Connector\Sabre::authInit', $event); + // because we are throwing exceptions this plugin has to be the last one + $authPlugin->addBackend($authBackend); + // debugging if(\OC::$server->getConfig()->getSystemValue('debug', false)) { $this->server->addPlugin(new \Sabre\DAV\Browser\Plugin()); diff --git a/apps/files/lib/Controller/ApiController.php b/apps/files/lib/Controller/ApiController.php index 9a46cdd0fef..a442d7ea909 100644 --- a/apps/files/lib/Controller/ApiController.php +++ b/apps/files/lib/Controller/ApiController.php @@ -86,6 +86,7 @@ class ApiController extends Controller { * * @NoAdminRequired * @NoCSRFRequired + * @StrictCookieRequired * * @param int $x * @param int $y diff --git a/apps/files_external/lib/Lib/Storage/SFTP.php b/apps/files_external/lib/Lib/Storage/SFTP.php index 3be7a801229..4d4fe6945aa 100644 --- a/apps/files_external/lib/Lib/Storage/SFTP.php +++ b/apps/files_external/lib/Lib/Storage/SFTP.php @@ -426,7 +426,7 @@ class SFTP extends \OC\Files\Storage\Common { */ public function rename($source, $target) { try { - if (!$this->is_dir($target) && $this->file_exists($target)) { + if ($this->file_exists($target)) { $this->unlink($target); } return $this->getConnection()->rename( diff --git a/apps/files_external/tests/env/start-swift-ceph.sh b/apps/files_external/tests/env/start-swift-ceph.sh index b73fa899a6d..3a299a6fa85 100755 --- a/apps/files_external/tests/env/start-swift-ceph.sh +++ b/apps/files_external/tests/env/start-swift-ceph.sh @@ -80,7 +80,7 @@ if ! "$thisFolder"/env/wait-for-connection ${host} 80 600; then exit 1 fi echo "Waiting another 15 seconds" -sleep 15 +sleep 15 cat > $thisFolder/config.swift.php <<DELIM <?php diff --git a/apps/files_sharing/css/sharetabview.css b/apps/files_sharing/css/sharetabview.css index 04338820881..e048b7564ac 100644 --- a/apps/files_sharing/css/sharetabview.css +++ b/apps/files_sharing/css/sharetabview.css @@ -10,8 +10,9 @@ top: 2px; } -.shareTabView .shareWithRemoteInfo { - padding: 11px 20px; +.shareTabView .shareWithRemoteInfo, +.shareTabView .clipboardButton { + padding-left: 10px; } .shareTabView label { @@ -28,7 +29,9 @@ width: 94%; margin-left: 0; } -.shareTabView input[type="text"].shareWithField { +.shareTabView input[type="text"].shareWithField, +.shareTabView input[type="text"].emailField, +.shareTabView input[type="text"].linkText { width: 80%; } diff --git a/apps/user_ldap/lib/Access.php b/apps/user_ldap/lib/Access.php index 4d0753696ff..cdf12331477 100644 --- a/apps/user_ldap/lib/Access.php +++ b/apps/user_ldap/lib/Access.php @@ -623,7 +623,7 @@ class Access extends LDAPUtility implements IUserTools { * "Developers" */ private function _createAltInternalOwnCloudNameForGroups($name) { - $usedNames = $this->groupMapper->getNamesBySearch($name.'_%'); + $usedNames = $this->groupMapper->getNamesBySearch($name, "", '_%'); if(!($usedNames) || count($usedNames) === 0) { $lastNo = 1; //will become name_2 } else { diff --git a/apps/user_ldap/lib/Mapping/AbstractMapping.php b/apps/user_ldap/lib/Mapping/AbstractMapping.php index 54fe7db366f..67fbd9fe851 100644 --- a/apps/user_ldap/lib/Mapping/AbstractMapping.php +++ b/apps/user_ldap/lib/Mapping/AbstractMapping.php @@ -138,16 +138,18 @@ abstract class AbstractMapping { /** * Searches mapped names by the giving string in the name column * @param string $search + * @param string $prefixMatch + * @param string $postfixMatch * @return string[] */ - public function getNamesBySearch($search) { + public function getNamesBySearch($search, $prefixMatch = "", $postfixMatch = "") { $query = $this->dbc->prepare(' SELECT `owncloud_name` FROM `'. $this->getTableName() .'` WHERE `owncloud_name` LIKE ? '); - $res = $query->execute(array($search)); + $res = $query->execute(array($prefixMatch.$this->dbc->escapeLikeParameter($search).$postfixMatch)); $names = array(); if($res !== false) { while($row = $query->fetch()) { diff --git a/apps/user_ldap/tests/Mapping/AbstractMappingTest.php b/apps/user_ldap/tests/Mapping/AbstractMappingTest.php index ddd99d31709..a2e9f850913 100644 --- a/apps/user_ldap/tests/Mapping/AbstractMappingTest.php +++ b/apps/user_ldap/tests/Mapping/AbstractMappingTest.php @@ -164,7 +164,7 @@ abstract class AbstractMappingTest extends \Test\TestCase { public function testSearch() { list($mapper,) = $this->initTest(); - $names = $mapper->getNamesBySearch('%oo%'); + $names = $mapper->getNamesBySearch('oo', '%', '%'); $this->assertTrue(is_array($names)); $this->assertSame(2, count($names)); $this->assertTrue(in_array('Foobar', $names)); diff --git a/bower.json b/bower.json index 74c4be96a21..3aa2cd4b3b2 100644 --- a/bower.json +++ b/bower.json @@ -30,6 +30,7 @@ "backbone": "~1.2.3", "davclient.js": "https://github.com/evert/davclient.js.git", "es6-promise": "https://github.com/jakearchibald/es6-promise.git#~2.3.0", - "base64": "~0.3.0" + "base64": "~0.3.0", + "clipboard": "^1.5.12" } } diff --git a/core/Command/Group/AddUser.php b/core/Command/Group/AddUser.php new file mode 100644 index 00000000000..23aa193fbc0 --- /dev/null +++ b/core/Command/Group/AddUser.php @@ -0,0 +1,77 @@ +<?php +/** + * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OC\Core\Command\Group; + +use OC\Core\Command\Base; +use OCP\IGroupManager; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class AddUser extends Base { + /** @var IUserManager */ + protected $userManager; + /** @var IGroupManager */ + protected $groupManager; + + /** + * @param IUserManager $userManager + * @param IGroupManager $groupManager + */ + public function __construct(IUserManager $userManager, IGroupManager $groupManager) { + $this->userManager = $userManager; + $this->groupManager = $groupManager; + parent::__construct(); + } + + protected function configure() { + $this + ->setName('group:adduser') + ->setDescription('add a user to a group') + ->addArgument( + 'group', + InputArgument::REQUIRED, + 'group to add the user to' + )->addArgument( + 'user', + InputArgument::REQUIRED, + 'user to add to the group' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $group = $this->groupManager->get($input->getArgument('group')); + if (is_null($group)) { + $output->writeln('<error>group not found</error>'); + return 1; + } + $user = $this->userManager->get($input->getArgument('user')); + if (is_null($user)) { + $output->writeln('<error>user not found</error>'); + return 1; + } + $group->addUser($user); + } +} diff --git a/core/Command/Group/ListCommand.php b/core/Command/Group/ListCommand.php new file mode 100644 index 00000000000..d0c0adacd3a --- /dev/null +++ b/core/Command/Group/ListCommand.php @@ -0,0 +1,89 @@ +<?php +/** + * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OC\Core\Command\Group; + +use OC\Core\Command\Base; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ListCommand extends Base { + /** @var IGroupManager */ + protected $groupManager; + + /** + * @param IGroupManager $groupManager + */ + public function __construct(IGroupManager $groupManager) { + $this->groupManager = $groupManager; + parent::__construct(); + } + + protected function configure() { + $this + ->setName('group:list') + ->setDescription('list configured groups') + ->addOption( + 'limit', + 'l', + InputOption::VALUE_OPTIONAL, + 'Number of groups to retrieve', + 500 + )->addOption( + 'offset', + 'o', + InputOption::VALUE_OPTIONAL, + 'Offset for retrieving groups', + 0 + )->addOption( + 'output', + null, + InputOption::VALUE_OPTIONAL, + 'Output format (plain, json or json_pretty, default is plain)', + $this->defaultOutputFormat + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $groups = $this->groupManager->search('', (int)$input->getOption('limit'), (int)$input->getOption('offset')); + $this->writeArrayInOutputFormat($input, $output, $this->formatGroups($groups)); + } + + /** + * @param IGroup[] $groups + * @return array + */ + private function formatGroups(array $groups) { + $keys = array_map(function (IGroup $group) { + return $group->getGID(); + }, $groups); + $values = array_map(function (IGroup $group) { + return array_keys($group->getUsers()); + }, $groups); + return array_combine($keys, $values); + } +} diff --git a/core/Command/Group/RemoveUser.php b/core/Command/Group/RemoveUser.php new file mode 100644 index 00000000000..f579468a74d --- /dev/null +++ b/core/Command/Group/RemoveUser.php @@ -0,0 +1,77 @@ +<?php +/** + * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OC\Core\Command\Group; + +use OC\Core\Command\Base; +use OCP\IGroupManager; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class RemoveUser extends Base { + /** @var IUserManager */ + protected $userManager; + /** @var IGroupManager */ + protected $groupManager; + + /** + * @param IUserManager $userManager + * @param IGroupManager $groupManager + */ + public function __construct(IUserManager $userManager, IGroupManager $groupManager) { + $this->userManager = $userManager; + $this->groupManager = $groupManager; + parent::__construct(); + } + + protected function configure() { + $this + ->setName('group:removeuser') + ->setDescription('remove a user from a group') + ->addArgument( + 'group', + InputArgument::REQUIRED, + 'group to remove the user from' + )->addArgument( + 'user', + InputArgument::REQUIRED, + 'user to remove from the group' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $group = $this->groupManager->get($input->getArgument('group')); + if (is_null($group)) { + $output->writeln('<error>group not found</error>'); + return 1; + } + $user = $this->userManager->get($input->getArgument('user')); + if (is_null($user)) { + $output->writeln('<error>user not found</error>'); + return 1; + } + $group->removeUser($user); + } +} diff --git a/core/Command/User/Info.php b/core/Command/User/Info.php new file mode 100644 index 00000000000..1888e5cc644 --- /dev/null +++ b/core/Command/User/Info.php @@ -0,0 +1,88 @@ +<?php +/** + * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Info extends Base { + /** @var IUserManager */ + protected $userManager; + /** @var IGroupManager */ + protected $groupManager; + + /** + * @param IUserManager $userManager + * @param IGroupManager $groupManager + */ + public function __construct(IUserManager $userManager, IGroupManager $groupManager) { + $this->userManager = $userManager; + $this->groupManager = $groupManager; + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:info') + ->setDescription('show user info') + ->addArgument( + 'user', + InputArgument::REQUIRED, + 'user to show' + )->addOption( + 'output', + null, + InputOption::VALUE_OPTIONAL, + 'Output format (plain, json or json_pretty, default is plain)', + $this->defaultOutputFormat + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $user = $this->userManager->get($input->getArgument('user')); + if (is_null($user)) { + $output->writeln('<error>user not found</error>'); + return 1; + } + $groups = $this->groupManager->getUserGroupIds($user); + $data = [ + 'user_id' => $user->getUID(), + 'display_name' => $user->getDisplayName(), + 'email' => ($user->getEMailAddress()) ? $user->getEMailAddress() : '', + 'cloud_id' => $user->getCloudId(), + 'enabled' => $user->isEnabled(), + 'groups' => $groups, + 'quota' => $user->getQuota(), + 'last_seen' => date(\DateTime::ATOM, $user->getLastLogin()), // ISO-8601 + 'user_directory' => $user->getHome(), + 'backend' => $user->getBackendClassName() + ]; + $this->writeArrayInOutputFormat($input, $output, $data); + } +} diff --git a/core/Command/User/ListCommand.php b/core/Command/User/ListCommand.php new file mode 100644 index 00000000000..b9e10f6a31c --- /dev/null +++ b/core/Command/User/ListCommand.php @@ -0,0 +1,87 @@ +<?php +/** + * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\IUser; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ListCommand extends Base { + /** @var IUserManager */ + protected $userManager; + + /** + * @param IUserManager $userManager + */ + public function __construct(IUserManager $userManager) { + $this->userManager = $userManager; + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:list') + ->setDescription('list configured users') + ->addOption( + 'limit', + 'l', + InputOption::VALUE_OPTIONAL, + 'Number of users to retrieve', + 500 + )->addOption( + 'offset', + 'o', + InputOption::VALUE_OPTIONAL, + 'Offset for retrieving users', + 0 + )->addOption( + 'output', + null, + InputOption::VALUE_OPTIONAL, + 'Output format (plain, json or json_pretty, default is plain)', + $this->defaultOutputFormat + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $users = $this->userManager->search('', (int)$input->getOption('limit'), (int)$input->getOption('offset')); + $this->writeArrayInOutputFormat($input, $output, $this->formatUsers($users)); + } + + /** + * @param IUser[] $users + * @return array + */ + private function formatUsers(array $users) { + $keys = array_map(function (IUser $user) { + return $user->getUID(); + }, $users); + $values = array_map(function (IUser $user) { + return $user->getDisplayName(); + }, $users); + return array_combine($keys, $values); + } +} diff --git a/core/css/icons.css b/core/css/icons.css index 22b699b97ec..1a632fdd58b 100644 --- a/core/css/icons.css +++ b/core/css/icons.css @@ -204,6 +204,10 @@ img.icon-loading-small-dark, object.icon-loading-small-dark, video.icon-loading- background-image: url('../img/actions/mail.svg'); } +.icon-mail-grey { + background-image: url('../img/actions/mail-grey.svg'); +} + .icon-menu { background-image: url('../img/actions/menu.svg'); } @@ -372,3 +376,7 @@ img.icon-loading-small-dark, object.icon-loading-small-dark, video.icon-loading- .icon-picture { background-image: url('../img/places/picture.svg'); } + +.icon-clippy { + background-image: url('../img/actions/clippy.svg'); +} diff --git a/core/img/actions/clippy.svg b/core/img/actions/clippy.svg new file mode 100644 index 00000000000..8fa5c89c349 --- /dev/null +++ b/core/img/actions/clippy.svg @@ -0,0 +1,3 @@ +<svg height="1024" width="896" xmlns="http://www.w3.org/2000/svg"> + <path opacity=".5" d="M704 896h-640v-576h640v192h64v-320c0-35-29-64-64-64h-192c0-71-57-128-128-128s-128 57-128 128h-192c-35 0-64 29-64 64v704c0 35 29 64 64 64h640c35 0 64-29 64-64v-128h-64v128z m-512-704c29 0 29 0 64 0s64-29 64-64 29-64 64-64 64 29 64 64 32 64 64 64 33 0 64 0 64 29 64 64h-512c0-39 28-64 64-64z m-64 512h128v-64h-128v64z m448-128v-128l-256 192 256 192v-128h320v-128h-320z m-448 256h192v-64h-192v64z m320-448h-320v64h320v-64z m-192 128h-128v64h128v-64z" /> +</svg> diff --git a/core/js/config.php b/core/js/config.php index 197047ed8b8..c2e6213e8f7 100644 --- a/core/js/config.php +++ b/core/js/config.php @@ -32,6 +32,10 @@ * */ +if(!\OC::$server->getRequest()->passesStrictCookieCheck()) { + die(); +} + // Set the content type to Javascript header("Content-type: text/javascript"); diff --git a/core/js/core.json b/core/js/core.json index 03c72e9b3ff..5254e7b3d03 100644 --- a/core/js/core.json +++ b/core/js/core.json @@ -10,7 +10,8 @@ "bootstrap/js/tooltip.js", "backbone/backbone.js", "es6-promise/dist/es6-promise.js", - "davclient.js/lib/client.js" + "davclient.js/lib/client.js", + "clipboard/dist/clipboard.js" ], "libraries": [ "jquery-showpassword.js", diff --git a/core/js/sharedialoglinkshareview.js b/core/js/sharedialoglinkshareview.js index 457a788d589..8ad2e270099 100644 --- a/core/js/sharedialoglinkshareview.js +++ b/core/js/sharedialoglinkshareview.js @@ -22,8 +22,11 @@ '<input type="checkbox" name="linkCheckbox" id="linkCheckbox-{{cid}}" class="checkbox linkCheckbox" value="1" {{#if isLinkShare}}checked="checked"{{/if}} />' + '<label for="linkCheckbox-{{cid}}">{{linkShareLabel}}</label>' + '<br />' + + '<div class="oneline">' + '<label for="linkText-{{cid}}" class="hidden-visually">{{urlLabel}}</label>' + '<input id="linkText-{{cid}}" class="linkText {{#unless isLinkShare}}hidden{{/unless}}" type="text" readonly="readonly" value="{{shareLinkURL}}" />' + + '<a class="{{#unless isLinkShare}}hidden-visually{{/unless}} clipboardButton icon icon-clippy" data-clipboard-target="#linkText-{{cid}}"></a>' + + '</div>' + ' {{#if publicUpload}}' + '<div id="allowPublicUploadWrapper">' + ' <span class="icon-loading-small hidden"></span>' + @@ -125,6 +128,38 @@ 'onHideFileListChange', 'onAllowPublicUploadChange' ); + + var clipboard = new Clipboard('.clipboardButton'); + clipboard.on('success', function(e) { + $input = $(e.trigger); + $input.tooltip({placement: 'bottom', trigger: 'manual', title: t('core', 'Copied!')}); + $input.tooltip('show'); + _.delay(function() { + $input.tooltip('hide'); + }, 3000); + }); + clipboard.on('error', function (e) { + $input = $(e.trigger); + var actionMsg = ''; + if (/iPhone|iPad/i.test(navigator.userAgent)) { + actionMsg = t('core', 'Not supported!'); + } else if (/Mac/i.test(navigator.userAgent)) { + actionMsg = t('core', 'Press ⌘-C to copy.'); + } else { + actionMsg = t('core', 'Press Ctrl-C to copy.'); + } + + $input.tooltip({ + placement: 'bottom', + trigger: 'manual', + title: actionMsg + }); + $input.tooltip('show'); + _.delay(function () { + $input.tooltip('hide'); + }, 3000); + }); + }, onLinkCheckBoxChange: function() { diff --git a/core/js/sharedialogmailview.js b/core/js/sharedialogmailview.js index 84e3f3242ad..79741e92ac5 100644 --- a/core/js/sharedialogmailview.js +++ b/core/js/sharedialogmailview.js @@ -16,9 +16,9 @@ var TEMPLATE = '{{#if shareAllowed}}' + ' {{#if mailPublicNotificationEnabled}}' + - '<form id="emailPrivateLink" class="emailPrivateLinkForm">' + + '<form id="emailPrivateLink" class="emailPrivateLinkForm oneline">' + ' <input id="email" class="emailField" value="{{email}}" placeholder="{{mailPrivatePlaceholder}}" type="text" />' + - ' <input id="emailButton" class="emailButton" type="submit" value="{{mailButtonText}}" />' + + ' <a id="emailButton" class="icon icon-mail-grey" />' + '</form>' + ' {{/if}}' + '{{/if}}' @@ -48,7 +48,7 @@ showLink: true, events: { - 'submit .emailPrivateLinkForm': '_onEmailPrivateLink' + 'click #emailButton': '_onEmailPrivateLink' }, initialize: function(options) { @@ -173,4 +173,4 @@ OC.Share.ShareDialogMailView = ShareDialogMailView; -})();
\ No newline at end of file +})(); diff --git a/core/js/sharedialogshareelistview.js b/core/js/sharedialogshareelistview.js index 85dee978987..d156d30cecd 100644 --- a/core/js/sharedialogshareelistview.js +++ b/core/js/sharedialogshareelistview.js @@ -38,7 +38,9 @@ '<span class="shareOption">' + '<input id="canEdit-{{cid}}-{{shareWith}}" type="checkbox" name="edit" class="permissions checkbox" {{#if hasEditPermission}}checked="checked"{{/if}} />' + '<label for="canEdit-{{cid}}-{{shareWith}}">{{canEditLabel}}</label>' + + '{{#if isFolder}}' + '<a href="#" class="showCruds"><img alt="{{crudsLabel}}" src="{{triangleSImage}}"/></a>' + + '{{/if}}' + '</span>' + '{{/if}}' + '<div class="cruds hidden">' + @@ -162,7 +164,8 @@ sharePermission: OC.PERMISSION_SHARE, createPermission: OC.PERMISSION_CREATE, updatePermission: OC.PERMISSION_UPDATE, - deletePermission: OC.PERMISSION_DELETE + deletePermission: OC.PERMISSION_DELETE, + isFolder: this.model.isFolder() }; if(!this.model.hasUserShares()) { diff --git a/core/js/sharedialogview.js b/core/js/sharedialogview.js index c17da94bab3..5637ffc3a0a 100644 --- a/core/js/sharedialogview.js +++ b/core/js/sharedialogview.js @@ -30,7 +30,7 @@ '<div class="loading hidden" style="height: 50px"></div>'; var TEMPLATE_REMOTE_SHARE_INFO = - '<a target="_blank" class="icon-info shareWithRemoteInfo hasTooltip" href="{{docLink}}" ' + + '<a target="_blank" class="icon icon-info shareWithRemoteInfo hasTooltip" href="{{docLink}}" ' + 'title="{{tooltip}}"></a>'; /** diff --git a/core/js/tests/specs/sharedialogviewSpec.js b/core/js/tests/specs/sharedialogviewSpec.js index 23214a7fe86..f0e027d9fb9 100644 --- a/core/js/tests/specs/sharedialogviewSpec.js +++ b/core/js/tests/specs/sharedialogviewSpec.js @@ -444,7 +444,7 @@ describe('OC.Share.ShareDialogView', function() { dialog.render(); dialog.$el.find('.emailPrivateLinkForm .emailField').val('a@b.c'); - dialog.$el.find('.emailPrivateLinkForm').trigger('submit'); + dialog.$el.find('#emailButton').trigger('click'); expect(sendEmailPrivateLinkStub.callCount).toEqual(1); expect(dialog.$el.find('.emailPrivateLinkForm .emailField').val()).toEqual('Sending ...'); @@ -463,7 +463,7 @@ describe('OC.Share.ShareDialogView', function() { dialog.render(); dialog.$el.find('.emailPrivateLinkForm .emailField').val('a@b.c'); - dialog.$el.find('.emailPrivateLinkForm').trigger('submit'); + dialog.$el.find('#emailButton').trigger('click'); expect(sendEmailPrivateLinkStub.callCount).toEqual(1); expect(dialog.$el.find('.emailPrivateLinkForm .emailField').val()).toEqual('Sending ...'); diff --git a/core/register_command.php b/core/register_command.php index 91b00df20f1..70a1d7ed42e 100644 --- a/core/register_command.php +++ b/core/register_command.php @@ -137,6 +137,12 @@ if (\OC::$server->getConfig()->getSystemValue('installed', false)) { $application->add(new OC\Core\Command\User\Report(\OC::$server->getUserManager())); $application->add(new OC\Core\Command\User\ResetPassword(\OC::$server->getUserManager())); $application->add(new OC\Core\Command\User\Setting(\OC::$server->getUserManager(), \OC::$server->getConfig(), \OC::$server->getDatabaseConnection())); + $application->add(new OC\Core\Command\User\ListCommand(\OC::$server->getUserManager())); + $application->add(new OC\Core\Command\User\Info(\OC::$server->getUserManager(), \OC::$server->getGroupManager())); + + $application->add(new OC\Core\Command\Group\ListCommand(\OC::$server->getGroupManager())); + $application->add(new OC\Core\Command\Group\AddUser(\OC::$server->getUserManager(), \OC::$server->getGroupManager())); + $application->add(new OC\Core\Command\Group\RemoveUser(\OC::$server->getUserManager(), \OC::$server->getGroupManager())); $application->add(new OC\Core\Command\Security\ListCertificates(\OC::$server->getCertificateManager(null), \OC::$server->getL10N('core'))); $application->add(new OC\Core\Command\Security\ImportCertificate(\OC::$server->getCertificateManager(null))); diff --git a/core/vendor/.gitignore b/core/vendor/.gitignore index 58a231c4bb4..5160c2a7f97 100644 --- a/core/vendor/.gitignore +++ b/core/vendor/.gitignore @@ -137,3 +137,7 @@ es6-promise/dist/* # base64 base64/*min.js + +# clipboard +clipboard/** +!clipboard/dist/clipboard.js diff --git a/core/vendor/clipboard/dist/clipboard.js b/core/vendor/clipboard/dist/clipboard.js new file mode 100644 index 00000000000..040c5e005c3 --- /dev/null +++ b/core/vendor/clipboard/dist/clipboard.js @@ -0,0 +1,742 @@ +/*! + * clipboard.js v1.5.12 + * https://zenorocha.github.io/clipboard.js + * + * Licensed MIT © Zeno Rocha + */ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Clipboard = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ +var matches = require('matches-selector') + +module.exports = function (element, selector, checkYoSelf) { + var parent = checkYoSelf ? element : element.parentNode + + while (parent && parent !== document) { + if (matches(parent, selector)) return parent; + parent = parent.parentNode + } +} + +},{"matches-selector":5}],2:[function(require,module,exports){ +var closest = require('closest'); + +/** + * Delegates event to a selector. + * + * @param {Element} element + * @param {String} selector + * @param {String} type + * @param {Function} callback + * @param {Boolean} useCapture + * @return {Object} + */ +function delegate(element, selector, type, callback, useCapture) { + var listenerFn = listener.apply(this, arguments); + + element.addEventListener(type, listenerFn, useCapture); + + return { + destroy: function() { + element.removeEventListener(type, listenerFn, useCapture); + } + } +} + +/** + * Finds closest match and invokes callback. + * + * @param {Element} element + * @param {String} selector + * @param {String} type + * @param {Function} callback + * @return {Function} + */ +function listener(element, selector, type, callback) { + return function(e) { + e.delegateTarget = closest(e.target, selector, true); + + if (e.delegateTarget) { + callback.call(element, e); + } + } +} + +module.exports = delegate; + +},{"closest":1}],3:[function(require,module,exports){ +/** + * Check if argument is a HTML element. + * + * @param {Object} value + * @return {Boolean} + */ +exports.node = function(value) { + return value !== undefined + && value instanceof HTMLElement + && value.nodeType === 1; +}; + +/** + * Check if argument is a list of HTML elements. + * + * @param {Object} value + * @return {Boolean} + */ +exports.nodeList = function(value) { + var type = Object.prototype.toString.call(value); + + return value !== undefined + && (type === '[object NodeList]' || type === '[object HTMLCollection]') + && ('length' in value) + && (value.length === 0 || exports.node(value[0])); +}; + +/** + * Check if argument is a string. + * + * @param {Object} value + * @return {Boolean} + */ +exports.string = function(value) { + return typeof value === 'string' + || value instanceof String; +}; + +/** + * Check if argument is a function. + * + * @param {Object} value + * @return {Boolean} + */ +exports.fn = function(value) { + var type = Object.prototype.toString.call(value); + + return type === '[object Function]'; +}; + +},{}],4:[function(require,module,exports){ +var is = require('./is'); +var delegate = require('delegate'); + +/** + * Validates all params and calls the right + * listener function based on its target type. + * + * @param {String|HTMLElement|HTMLCollection|NodeList} target + * @param {String} type + * @param {Function} callback + * @return {Object} + */ +function listen(target, type, callback) { + if (!target && !type && !callback) { + throw new Error('Missing required arguments'); + } + + if (!is.string(type)) { + throw new TypeError('Second argument must be a String'); + } + + if (!is.fn(callback)) { + throw new TypeError('Third argument must be a Function'); + } + + if (is.node(target)) { + return listenNode(target, type, callback); + } + else if (is.nodeList(target)) { + return listenNodeList(target, type, callback); + } + else if (is.string(target)) { + return listenSelector(target, type, callback); + } + else { + throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList'); + } +} + +/** + * Adds an event listener to a HTML element + * and returns a remove listener function. + * + * @param {HTMLElement} node + * @param {String} type + * @param {Function} callback + * @return {Object} + */ +function listenNode(node, type, callback) { + node.addEventListener(type, callback); + + return { + destroy: function() { + node.removeEventListener(type, callback); + } + } +} + +/** + * Add an event listener to a list of HTML elements + * and returns a remove listener function. + * + * @param {NodeList|HTMLCollection} nodeList + * @param {String} type + * @param {Function} callback + * @return {Object} + */ +function listenNodeList(nodeList, type, callback) { + Array.prototype.forEach.call(nodeList, function(node) { + node.addEventListener(type, callback); + }); + + return { + destroy: function() { + Array.prototype.forEach.call(nodeList, function(node) { + node.removeEventListener(type, callback); + }); + } + } +} + +/** + * Add an event listener to a selector + * and returns a remove listener function. + * + * @param {String} selector + * @param {String} type + * @param {Function} callback + * @return {Object} + */ +function listenSelector(selector, type, callback) { + return delegate(document.body, selector, type, callback); +} + +module.exports = listen; + +},{"./is":3,"delegate":2}],5:[function(require,module,exports){ + +/** + * Element prototype. + */ + +var proto = Element.prototype; + +/** + * Vendor function. + */ + +var vendor = proto.matchesSelector + || proto.webkitMatchesSelector + || proto.mozMatchesSelector + || proto.msMatchesSelector + || proto.oMatchesSelector; + +/** + * Expose `match()`. + */ + +module.exports = match; + +/** + * Match `el` to `selector`. + * + * @param {Element} el + * @param {String} selector + * @return {Boolean} + * @api public + */ + +function match(el, selector) { + if (vendor) return vendor.call(el, selector); + var nodes = el.parentNode.querySelectorAll(selector); + for (var i = 0; i < nodes.length; ++i) { + if (nodes[i] == el) return true; + } + return false; +} +},{}],6:[function(require,module,exports){ +function select(element) { + var selectedText; + + if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') { + element.focus(); + element.setSelectionRange(0, element.value.length); + + selectedText = element.value; + } + else { + if (element.hasAttribute('contenteditable')) { + element.focus(); + } + + var selection = window.getSelection(); + var range = document.createRange(); + + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + + selectedText = selection.toString(); + } + + return selectedText; +} + +module.exports = select; + +},{}],7:[function(require,module,exports){ +function E () { + // Keep this empty so it's easier to inherit from + // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3) +} + +E.prototype = { + on: function (name, callback, ctx) { + var e = this.e || (this.e = {}); + + (e[name] || (e[name] = [])).push({ + fn: callback, + ctx: ctx + }); + + return this; + }, + + once: function (name, callback, ctx) { + var self = this; + function listener () { + self.off(name, listener); + callback.apply(ctx, arguments); + }; + + listener._ = callback + return this.on(name, listener, ctx); + }, + + emit: function (name) { + var data = [].slice.call(arguments, 1); + var evtArr = ((this.e || (this.e = {}))[name] || []).slice(); + var i = 0; + var len = evtArr.length; + + for (i; i < len; i++) { + evtArr[i].fn.apply(evtArr[i].ctx, data); + } + + return this; + }, + + off: function (name, callback) { + var e = this.e || (this.e = {}); + var evts = e[name]; + var liveEvents = []; + + if (evts && callback) { + for (var i = 0, len = evts.length; i < len; i++) { + if (evts[i].fn !== callback && evts[i].fn._ !== callback) + liveEvents.push(evts[i]); + } + } + + // Remove event from queue to prevent memory leak + // Suggested by https://github.com/lazd + // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910 + + (liveEvents.length) + ? e[name] = liveEvents + : delete e[name]; + + return this; + } +}; + +module.exports = E; + +},{}],8:[function(require,module,exports){ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define(['module', 'select'], factory); + } else if (typeof exports !== "undefined") { + factory(module, require('select')); + } else { + var mod = { + exports: {} + }; + factory(mod, global.select); + global.clipboardAction = mod.exports; + } +})(this, function (module, _select) { + 'use strict'; + + var _select2 = _interopRequireDefault(_select); + + function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { + default: obj + }; + } + + var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { + return typeof obj; + } : function (obj) { + return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; + }; + + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + + var _createClass = function () { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + return function (Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; + }(); + + var ClipboardAction = function () { + /** + * @param {Object} options + */ + + function ClipboardAction(options) { + _classCallCheck(this, ClipboardAction); + + this.resolveOptions(options); + this.initSelection(); + } + + /** + * Defines base properties passed from constructor. + * @param {Object} options + */ + + + ClipboardAction.prototype.resolveOptions = function resolveOptions() { + var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; + + this.action = options.action; + this.emitter = options.emitter; + this.target = options.target; + this.text = options.text; + this.trigger = options.trigger; + + this.selectedText = ''; + }; + + ClipboardAction.prototype.initSelection = function initSelection() { + if (this.text) { + this.selectFake(); + } else if (this.target) { + this.selectTarget(); + } + }; + + ClipboardAction.prototype.selectFake = function selectFake() { + var _this = this; + + var isRTL = document.documentElement.getAttribute('dir') == 'rtl'; + + this.removeFake(); + + this.fakeHandlerCallback = function () { + return _this.removeFake(); + }; + this.fakeHandler = document.body.addEventListener('click', this.fakeHandlerCallback) || true; + + this.fakeElem = document.createElement('textarea'); + // Prevent zooming on iOS + this.fakeElem.style.fontSize = '12pt'; + // Reset box model + this.fakeElem.style.border = '0'; + this.fakeElem.style.padding = '0'; + this.fakeElem.style.margin = '0'; + // Move element out of screen horizontally + this.fakeElem.style.position = 'absolute'; + this.fakeElem.style[isRTL ? 'right' : 'left'] = '-9999px'; + // Move element to the same position vertically + this.fakeElem.style.top = (window.pageYOffset || document.documentElement.scrollTop) + 'px'; + this.fakeElem.setAttribute('readonly', ''); + this.fakeElem.value = this.text; + + document.body.appendChild(this.fakeElem); + + this.selectedText = (0, _select2.default)(this.fakeElem); + this.copyText(); + }; + + ClipboardAction.prototype.removeFake = function removeFake() { + if (this.fakeHandler) { + document.body.removeEventListener('click', this.fakeHandlerCallback); + this.fakeHandler = null; + this.fakeHandlerCallback = null; + } + + if (this.fakeElem) { + document.body.removeChild(this.fakeElem); + this.fakeElem = null; + } + }; + + ClipboardAction.prototype.selectTarget = function selectTarget() { + this.selectedText = (0, _select2.default)(this.target); + this.copyText(); + }; + + ClipboardAction.prototype.copyText = function copyText() { + var succeeded = undefined; + + try { + succeeded = document.execCommand(this.action); + } catch (err) { + succeeded = false; + } + + this.handleResult(succeeded); + }; + + ClipboardAction.prototype.handleResult = function handleResult(succeeded) { + if (succeeded) { + this.emitter.emit('success', { + action: this.action, + text: this.selectedText, + trigger: this.trigger, + clearSelection: this.clearSelection.bind(this) + }); + } else { + this.emitter.emit('error', { + action: this.action, + trigger: this.trigger, + clearSelection: this.clearSelection.bind(this) + }); + } + }; + + ClipboardAction.prototype.clearSelection = function clearSelection() { + if (this.target) { + this.target.blur(); + } + + window.getSelection().removeAllRanges(); + }; + + ClipboardAction.prototype.destroy = function destroy() { + this.removeFake(); + }; + + _createClass(ClipboardAction, [{ + key: 'action', + set: function set() { + var action = arguments.length <= 0 || arguments[0] === undefined ? 'copy' : arguments[0]; + + this._action = action; + + if (this._action !== 'copy' && this._action !== 'cut') { + throw new Error('Invalid "action" value, use either "copy" or "cut"'); + } + }, + get: function get() { + return this._action; + } + }, { + key: 'target', + set: function set(target) { + if (target !== undefined) { + if (target && (typeof target === 'undefined' ? 'undefined' : _typeof(target)) === 'object' && target.nodeType === 1) { + if (this.action === 'copy' && target.hasAttribute('disabled')) { + throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute'); + } + + if (this.action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) { + throw new Error('Invalid "target" attribute. You can\'t cut text from elements with "readonly" or "disabled" attributes'); + } + + this._target = target; + } else { + throw new Error('Invalid "target" value, use a valid Element'); + } + } + }, + get: function get() { + return this._target; + } + }]); + + return ClipboardAction; + }(); + + module.exports = ClipboardAction; +}); + +},{"select":6}],9:[function(require,module,exports){ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define(['module', './clipboard-action', 'tiny-emitter', 'good-listener'], factory); + } else if (typeof exports !== "undefined") { + factory(module, require('./clipboard-action'), require('tiny-emitter'), require('good-listener')); + } else { + var mod = { + exports: {} + }; + factory(mod, global.clipboardAction, global.tinyEmitter, global.goodListener); + global.clipboard = mod.exports; + } +})(this, function (module, _clipboardAction, _tinyEmitter, _goodListener) { + 'use strict'; + + var _clipboardAction2 = _interopRequireDefault(_clipboardAction); + + var _tinyEmitter2 = _interopRequireDefault(_tinyEmitter); + + var _goodListener2 = _interopRequireDefault(_goodListener); + + function _interopRequireDefault(obj) { + return obj && obj.__esModule ? obj : { + default: obj + }; + } + + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + + function _possibleConstructorReturn(self, call) { + if (!self) { + throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); + } + + return call && (typeof call === "object" || typeof call === "function") ? call : self; + } + + function _inherits(subClass, superClass) { + if (typeof superClass !== "function" && superClass !== null) { + throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); + } + + subClass.prototype = Object.create(superClass && superClass.prototype, { + constructor: { + value: subClass, + enumerable: false, + writable: true, + configurable: true + } + }); + if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; + } + + var Clipboard = function (_Emitter) { + _inherits(Clipboard, _Emitter); + + /** + * @param {String|HTMLElement|HTMLCollection|NodeList} trigger + * @param {Object} options + */ + + function Clipboard(trigger, options) { + _classCallCheck(this, Clipboard); + + var _this = _possibleConstructorReturn(this, _Emitter.call(this)); + + _this.resolveOptions(options); + _this.listenClick(trigger); + return _this; + } + + /** + * Defines if attributes would be resolved using internal setter functions + * or custom functions that were passed in the constructor. + * @param {Object} options + */ + + + Clipboard.prototype.resolveOptions = function resolveOptions() { + var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; + + this.action = typeof options.action === 'function' ? options.action : this.defaultAction; + this.target = typeof options.target === 'function' ? options.target : this.defaultTarget; + this.text = typeof options.text === 'function' ? options.text : this.defaultText; + }; + + Clipboard.prototype.listenClick = function listenClick(trigger) { + var _this2 = this; + + this.listener = (0, _goodListener2.default)(trigger, 'click', function (e) { + return _this2.onClick(e); + }); + }; + + Clipboard.prototype.onClick = function onClick(e) { + var trigger = e.delegateTarget || e.currentTarget; + + if (this.clipboardAction) { + this.clipboardAction = null; + } + + this.clipboardAction = new _clipboardAction2.default({ + action: this.action(trigger), + target: this.target(trigger), + text: this.text(trigger), + trigger: trigger, + emitter: this + }); + }; + + Clipboard.prototype.defaultAction = function defaultAction(trigger) { + return getAttributeValue('action', trigger); + }; + + Clipboard.prototype.defaultTarget = function defaultTarget(trigger) { + var selector = getAttributeValue('target', trigger); + + if (selector) { + return document.querySelector(selector); + } + }; + + Clipboard.prototype.defaultText = function defaultText(trigger) { + return getAttributeValue('text', trigger); + }; + + Clipboard.prototype.destroy = function destroy() { + this.listener.destroy(); + + if (this.clipboardAction) { + this.clipboardAction.destroy(); + this.clipboardAction = null; + } + }; + + return Clipboard; + }(_tinyEmitter2.default); + + /** + * Helper function to retrieve attribute value. + * @param {String} suffix + * @param {Element} element + */ + function getAttributeValue(suffix, element) { + var attribute = 'data-clipboard-' + suffix; + + if (!element.hasAttribute(attribute)) { + return; + } + + return element.getAttribute(attribute); + } + + module.exports = Clipboard; +}); + +},{"./clipboard-action":8,"good-listener":4,"tiny-emitter":7}]},{},[9])(9) +});
\ No newline at end of file diff --git a/lib/base.php b/lib/base.php index a5e9d9716b7..b2ac0bab7d8 100644 --- a/lib/base.php +++ b/lib/base.php @@ -469,6 +469,84 @@ class OC { @ini_set('gd.jpeg_ignore_warning', 1); } + /** + * Send the same site cookies + */ + private static function sendSameSiteCookies() { + $cookieParams = session_get_cookie_params(); + $secureCookie = ($cookieParams['secure'] === true) ? 'secure; ' : ''; + $policies = [ + 'lax', + 'strict', + ]; + foreach($policies as $policy) { + header( + sprintf( + 'Set-Cookie: nc_sameSiteCookie%s=true; path=%s; httponly;' . $secureCookie . 'expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=%s', + $policy, + $cookieParams['path'], + $policy + ), + false + ); + } + } + + /** + * Same Site cookie to further mitigate CSRF attacks. This cookie has to + * be set in every request if cookies are sent to add a second level of + * defense against CSRF. + * + * If the cookie is not sent this will set the cookie and reload the page. + * We use an additional cookie since we want to protect logout CSRF and + * also we can't directly interfere with PHP's session mechanism. + */ + private static function performSameSiteCookieProtection() { + if(count($_COOKIE) > 0) { + $request = \OC::$server->getRequest(); + $requestUri = $request->getScriptName(); + $processingScript = explode('/', $requestUri); + $processingScript = $processingScript[count($processingScript)-1]; + // FIXME: In a SAML scenario we don't get any strict or lax cookie + // send for the ACS endpoint. Since we have some legacy code in Nextcloud + // (direct PHP files) the enforcement of lax cookies is performed here + // instead of the middleware. + // + // This means we cannot exclude some routes from the cookie validation, + // which normally is not a problem but is a little bit cumbersome for + // this use-case. + // Once the old legacy PHP endpoints have been removed we can move + // the verification into a middleware and also adds some exemptions. + // + // Questions about this code? Ask Lukas ;-) + $currentUrl = substr(explode('?',$request->getRequestUri(), 2)[0], strlen(\OC::$WEBROOT)); + if($currentUrl === '/index.php/apps/user_saml/saml/acs') { + return; + } + // For the "index.php" endpoint only a lax cookie is required. + if($processingScript === 'index.php') { + if(!$request->passesLaxCookieCheck()) { + self::sendSameSiteCookies(); + header('Location: '.$_SERVER['REQUEST_URI']); + exit(); + } + } else { + // All other endpoints require the lax and the strict cookie + if(!$request->passesStrictCookieCheck()) { + self::sendSameSiteCookies(); + // Debug mode gets access to the resources without strict cookie + // due to the fact that the SabreDAV browser also lives there. + if(!\OC::$server->getConfig()->getSystemValue('debug', false)) { + http_response_code(\OCP\AppFramework\Http::STATUS_SERVICE_UNAVAILABLE); + exit(); + } + } + } + } elseif(!isset($_COOKIE['nc_sameSiteCookielax']) || !isset($_COOKIE['nc_sameSiteCookiestrict'])) { + self::sendSameSiteCookies(); + } + } + public static function init() { // calculate the root directories OC::$SERVERROOT = str_replace("\\", '/', substr(__DIR__, 0, -4)); @@ -572,6 +650,8 @@ class OC { ini_set('session.cookie_secure', true); } + self::performSameSiteCookieProtection(); + if (!defined('OC_CONSOLE')) { $errors = OC_Util::checkServer(\OC::$server->getConfig()); if (count($errors) > 0) { diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php index 1684ff8027b..893d6cb9aa6 100644 --- a/lib/private/AppFramework/DependencyInjection/DIContainer.php +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -38,6 +38,7 @@ use OC\AppFramework\Http\Dispatcher; use OC\AppFramework\Http\Output; use OC\AppFramework\Middleware\MiddlewareDispatcher; use OC\AppFramework\Middleware\Security\CORSMiddleware; +use OC\AppFramework\Middleware\OCSMiddleware; use OC\AppFramework\Middleware\Security\SecurityMiddleware; use OC\AppFramework\Middleware\SessionMiddleware; use OC\AppFramework\Utility\SimpleContainer; @@ -374,6 +375,12 @@ class DIContainer extends SimpleContainer implements IAppContainer { return new TwoFactorMiddleware($twoFactorManager, $userSession, $session, $urlGenerator, $reflector, $request); }); + $this->registerService('OCSMiddleware', function (SimpleContainer $c) { + return new OCSMiddleware( + $c['Request'] + ); + }); + $middleWares = &$this->middleWares; $this->registerService('MiddlewareDispatcher', function($c) use (&$middleWares) { $dispatcher = new MiddlewareDispatcher(); @@ -386,6 +393,7 @@ class DIContainer extends SimpleContainer implements IAppContainer { } $dispatcher->registerMiddleware($c['SessionMiddleware']); + $dispatcher->registerMiddleware($c['OCSMiddleware']); return $dispatcher; }); diff --git a/lib/private/AppFramework/Http/Request.php b/lib/private/AppFramework/Http/Request.php index d9cf1919252..8fc99f125b2 100644 --- a/lib/private/AppFramework/Http/Request.php +++ b/lib/private/AppFramework/Http/Request.php @@ -465,6 +465,10 @@ class Request implements \ArrayAccess, \Countable, IRequest { return false; } + if(!$this->passesStrictCookieCheck()) { + return false; + } + if (isset($this->items['get']['requesttoken'])) { $token = $this->items['get']['requesttoken']; } elseif (isset($this->items['post']['requesttoken'])) { @@ -481,6 +485,42 @@ class Request implements \ArrayAccess, \Countable, IRequest { } /** + * Checks if the strict cookie has been sent with the request if the request + * is including any cookies. + * + * @return bool + * @since 9.1.0 + */ + public function passesStrictCookieCheck() { + if(count($this->cookies) === 0) { + return true; + } + if($this->getCookie('nc_sameSiteCookiestrict') === 'true' + && $this->passesLaxCookieCheck()) { + return true; + } + return false; + } + + /** + * Checks if the lax cookie has been sent with the request if the request + * is including any cookies. + * + * @return bool + * @since 9.1.0 + */ + public function passesLaxCookieCheck() { + if(count($this->cookies) === 0) { + return true; + } + if($this->getCookie('nc_sameSiteCookielax') === 'true') { + return true; + } + return false; + } + + + /** * Returns an ID for the request, value is not guaranteed to be unique and is mostly meant for logging * If `mod_unique_id` is installed this value will be taken. * @return string diff --git a/lib/private/AppFramework/Middleware/OCSMiddleware.php b/lib/private/AppFramework/Middleware/OCSMiddleware.php new file mode 100644 index 00000000000..2c7d1167e7c --- /dev/null +++ b/lib/private/AppFramework/Middleware/OCSMiddleware.php @@ -0,0 +1,80 @@ +<?php +/** + * + * @author Roeland Jago Douma <roeland@famdouma.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OC\AppFramework\Middleware; + +use OC\AppFramework\Http; +use OCP\AppFramework\Http\OCSResponse; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\AppFramework\Middleware; + +class OCSMiddleware extends Middleware { + + /** @var IRequest */ + private $request; + + /** + * @param IRequest $request + */ + public function __construct(IRequest $request) { + $this->request = $request; + } + + /** + * @param \OCP\AppFramework\Controller $controller + * @param string $methodName + * @param \Exception $exception + * @throws \Exception + * @return OCSResponse + */ + public function afterException($controller, $methodName, \Exception $exception) { + if ($controller instanceof OCSController && $exception instanceof OCSException) { + $format = $this->getFormat($controller); + + $code = $exception->getCode(); + if ($code === 0) { + $code = Http::STATUS_INTERNAL_SERVER_ERROR; + } + return new OCSResponse($format, $code, $exception->getMessage()); + } + + throw $exception; + } + + /** + * @param \OCP\AppFramework\Controller $controller + * @return string + */ + private function getFormat($controller) { + // get format from the url format or request format parameter + $format = $this->request->getParam('format'); + + // if none is given try the first Accept header + if($format === null) { + $headers = $this->request->getHeader('Accept'); + $format = $controller->getResponderByHTTPHeader($headers); + } + + return $format; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php new file mode 100644 index 00000000000..9ccaed4566f --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php @@ -0,0 +1,36 @@ +<?php +/** + * @author Lukas Reschke <lukas@statuscode.ch> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class StrictCookieMissingException is thrown when the strict cookie has not + * been sent with the request but is required. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class StrictCookieMissingException extends SecurityException { + public function __construct() { + parent::__construct('Strict Cookie has not been found in request.', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php b/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php index a3ece262e10..98117751e21 100644 --- a/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php +++ b/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php @@ -32,6 +32,7 @@ use OC\AppFramework\Middleware\Security\Exceptions\AppNotEnabledException; use OC\AppFramework\Middleware\Security\Exceptions\CrossSiteRequestForgeryException; use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException; use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; +use OC\AppFramework\Middleware\Security\Exceptions\StrictCookieMissingException; use OC\AppFramework\Utility\ControllerMethodReflector; use OC\Security\CSP\ContentSecurityPolicyManager; use OCP\AppFramework\Http\ContentSecurityPolicy; @@ -134,6 +135,12 @@ class SecurityMiddleware extends Middleware { } } + // Check for strict cookie requirement + if($this->reflector->hasAnnotation('StrictCookieRequired') || !$this->reflector->hasAnnotation('NoCSRFRequired')) { + if(!$this->request->passesStrictCookieCheck()) { + throw new StrictCookieMissingException(); + } + } // CSRF check - also registers the CSRF token since the session may be closed later Util::callRegister(); if(!$this->reflector->hasAnnotation('NoCSRFRequired')) { @@ -186,7 +193,9 @@ class SecurityMiddleware extends Middleware { */ public function afterException($controller, $methodName, \Exception $exception) { if($exception instanceof SecurityException) { - + if($exception instanceof StrictCookieMissingException) { + return new RedirectResponse(\OC::$WEBROOT); + } if (stripos($this->request->getHeader('Accept'),'html') === false) { $response = new JSONResponse( array('message' => $exception->getMessage()), diff --git a/lib/private/Group/Database.php b/lib/private/Group/Database.php index 36d19f74cc6..64f249d4d25 100644 --- a/lib/private/Group/Database.php +++ b/lib/private/Group/Database.php @@ -285,7 +285,7 @@ class Database extends \OC\Group\Backend { $parameters = [$gid]; $searchLike = ''; if ($search !== '') { - $parameters[] = '%' . $search . '%'; + $parameters[] = '%' . $this->dbConn->escapeLikeParameter($search) . '%'; $searchLike = ' AND `uid` LIKE ?'; } @@ -311,7 +311,7 @@ class Database extends \OC\Group\Backend { $parameters = [$gid]; $searchLike = ''; if ($search !== '') { - $parameters[] = '%' . $search . '%'; + $parameters[] = '%' . $this->dbConn->escapeLikeParameter($search) . '%'; $searchLike = ' AND `uid` LIKE ?'; } diff --git a/lib/private/Repair/RepairLegacyStorages.php b/lib/private/Repair/RepairLegacyStorages.php index 8ef20cdf3c2..096300f51c2 100644 --- a/lib/private/Repair/RepairLegacyStorages.php +++ b/lib/private/Repair/RepairLegacyStorages.php @@ -172,7 +172,7 @@ class RepairLegacyStorages implements IRepairStep{ $sql = 'SELECT `id`, `numeric_id` FROM `*PREFIX*storages`' . ' WHERE `id` LIKE ?' . ' ORDER BY `id`'; - $result = $this->connection->executeQuery($sql, array($dataDirId . '%')); + $result = $this->connection->executeQuery($sql, array($this->connection->escapeLikeParameter($dataDirId) . '%')); while ($row = $result->fetch()) { $currentId = $row['id']; diff --git a/lib/private/legacy/eventsource.php b/lib/private/legacy/eventsource.php index 51040e7be7d..70e9847d237 100644 --- a/lib/private/legacy/eventsource.php +++ b/lib/private/legacy/eventsource.php @@ -76,6 +76,10 @@ class OC_EventSource implements \OCP\IEventSource { } else { header("Content-Type: text/event-stream"); } + if(!\OC::$server->getRequest()->passesStrictCookieCheck()) { + header('Location: '.\OC::$WEBROOT); + exit(); + } if (!(\OC::$server->getRequest()->passesCSRFCheck())) { $this->send('error', 'Possible CSRF attack. Connection will be closed.'); $this->close(); diff --git a/lib/private/legacy/files.php b/lib/private/legacy/files.php index 8cf98322223..cb8dc35aa5c 100644 --- a/lib/private/legacy/files.php +++ b/lib/private/legacy/files.php @@ -192,7 +192,7 @@ class OC_Files { * @return array $rangeArray ('from'=>int,'to'=>int), ... */ private static function parseHttpRangeHeader($rangeHeaderPos, $fileSize) { - $rArray=split(',', $rangeHeaderPos); + $rArray=explode(',', $rangeHeaderPos); $minOffset = 0; $ind = 0; diff --git a/lib/private/legacy/json.php b/lib/private/legacy/json.php index 1dde63602b1..557e1d77012 100644 --- a/lib/private/legacy/json.php +++ b/lib/private/legacy/json.php @@ -79,6 +79,11 @@ class OC_JSON{ * @deprecated Use annotation based CSRF checks from the AppFramework instead */ public static function callCheck() { + if(!\OC::$server->getRequest()->passesStrictCookieCheck()) { + header('Location: '.\OC::$WEBROOT); + exit(); + } + if( !(\OC::$server->getRequest()->passesCSRFCheck())) { $l = \OC::$server->getL10N('lib'); self::error(array( 'data' => array( 'message' => $l->t('Token expired. Please reload page.'), 'error' => 'token_expired' ))); diff --git a/lib/public/API.php b/lib/public/API.php index 4d68bef6f29..d5c08f43347 100644 --- a/lib/public/API.php +++ b/lib/public/API.php @@ -35,6 +35,7 @@ namespace OCP; /** * This class provides functions to manage apps in ownCloud * @since 5.0.0 + * @deprecated 9.1.0 Use the AppFramework */ class API { @@ -66,6 +67,7 @@ class API { * @param array $defaults * @param array $requirements * @since 5.0.0 + * @deprecated 9.1.0 Use the AppFramework */ public static function register($method, $url, $action, $app, $authLevel = self::USER_AUTH, $defaults = array(), $requirements = array()){ diff --git a/lib/public/AppFramework/OCS/OCSBadRequestException.php b/lib/public/AppFramework/OCS/OCSBadRequestException.php new file mode 100644 index 00000000000..0f4278fddc4 --- /dev/null +++ b/lib/public/AppFramework/OCS/OCSBadRequestException.php @@ -0,0 +1,45 @@ +<?php +/** + * + * @author Roeland Jago Douma <roeland@famdouma.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCP\AppFramework\OCS; + +use Exception; +use OCP\AppFramework\Http; + +/** + * Class OCSBadRequestException + * + * @package OCP\AppFramework + * @since 9.1.0 + */ +class OCSBadRequestException extends OCSException { + /** + * OCSBadRequestException constructor. + * + * @param string $message + * @param Exception|null $previous + * @since 9.1.0 + */ + public function __construct($message = '', Exception $previous = null) { + parent::__construct($message, Http::STATUS_BAD_REQUEST, $previous); + } + +} diff --git a/lib/public/AppFramework/OCS/OCSException.php b/lib/public/AppFramework/OCS/OCSException.php new file mode 100644 index 00000000000..f95b5a16844 --- /dev/null +++ b/lib/public/AppFramework/OCS/OCSException.php @@ -0,0 +1,32 @@ +<?php +/** + * + * @author Roeland Jago Douma <roeland@famdouma.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCP\AppFramework\OCS; + +use Exception; + +/** + * Class OCSException + * + * @package OCP\AppFramework + * @since 9.1.0 + */ +class OCSException extends Exception {} diff --git a/lib/public/AppFramework/OCS/OCSForbiddenException.php b/lib/public/AppFramework/OCS/OCSForbiddenException.php new file mode 100644 index 00000000000..0c792722d9a --- /dev/null +++ b/lib/public/AppFramework/OCS/OCSForbiddenException.php @@ -0,0 +1,44 @@ +<?php +/** + * + * @author Roeland Jago Douma <roeland@famdouma.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCP\AppFramework\OCS; + +use Exception; +use OCP\AppFramework\Http; + +/** + * Class OCSForbiddenException + * + * @package OCP\AppFramework + * @since 9.1.0 + */ +class OCSForbiddenException extends OCSException { + /** + * OCSForbiddenException constructor. + * + * @param string $message + * @param Exception|null $previous + * @since 9.1.0 + */ + public function __construct($message = '', Exception $previous = null) { + parent::__construct($message, Http::STATUS_FORBIDDEN, $previous); + } +} diff --git a/lib/public/AppFramework/OCS/OCSNotFoundException.php b/lib/public/AppFramework/OCS/OCSNotFoundException.php new file mode 100644 index 00000000000..aaef36af1c7 --- /dev/null +++ b/lib/public/AppFramework/OCS/OCSNotFoundException.php @@ -0,0 +1,44 @@ +<?php +/** + * + * @author Roeland Jago Douma <roeland@famdouma.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCP\AppFramework\OCS; + +use Exception; +use OCP\AppFramework\Http; + +/** + * Class OCSNotFoundException + * + * @package OCP\AppFramework + * @since 9.1.0 + */ +class OCSNotFoundException extends OCSException { + /** + * OCSNotFoundException constructor. + * + * @param string $message + * @param Exception|null $previous + * @since 9.1.0 + */ + public function __construct($message = '', Exception $previous = null) { + parent::__construct($message, Http::STATUS_NOT_FOUND, $previous); + } +} diff --git a/lib/public/IRequest.php b/lib/public/IRequest.php index 4db1c18b9c1..46e67d1415f 100644 --- a/lib/public/IRequest.php +++ b/lib/public/IRequest.php @@ -158,6 +158,24 @@ interface IRequest { public function passesCSRFCheck(); /** + * Checks if the strict cookie has been sent with the request if the request + * is including any cookies. + * + * @return bool + * @since 9.0.0 + */ + public function passesStrictCookieCheck(); + + /** + * Checks if the lax cookie has been sent with the request if the request + * is including any cookies. + * + * @return bool + * @since 9.0.0 + */ + public function passesLaxCookieCheck(); + + /** * Returns an ID for the request, value is not guaranteed to be unique and is mostly meant for logging * If `mod_unique_id` is installed this value will be taken. * diff --git a/lib/public/Util.php b/lib/public/Util.php index 687f4e78f69..9422dbac66a 100644 --- a/lib/public/Util.php +++ b/lib/public/Util.php @@ -513,6 +513,11 @@ class Util { * @deprecated 9.0.0 Use annotations based on the app framework. */ public static function callCheck() { + if(!\OC::$server->getRequest()->passesStrictCookieCheck()) { + header('Location: '.\OC::$WEBROOT); + exit(); + } + if (!(\OC::$server->getRequest()->passesCSRFCheck())) { exit(); } diff --git a/settings/css/settings.css b/settings/css/settings.css index a190cd91b00..4cd85598443 100644 --- a/settings/css/settings.css +++ b/settings/css/settings.css @@ -102,7 +102,7 @@ table.nostyle td { padding: 0.2em 0; } #sessions table, #apppasswords table { width: 100%; - min-height: 150px; + min-height: 50px; padding-top: 5px; max-width: 580px; } @@ -125,6 +125,11 @@ table.nostyle td { padding: 0.2em 0; } white-space: nowrap; overflow: hidden; } + +#sessions tr *:nth-child(2), +#apppasswords tr *:nth-child(2) { + text-align: right; +} #sessions .token-list td a.icon-delete, #apppasswords .token-list td a.icon-delete { display: block; diff --git a/settings/js/authtoken_collection.js b/settings/js/authtoken_collection.js index ab7f7d5804a..d1ffc25a599 100644 --- a/settings/js/authtoken_collection.js +++ b/settings/js/authtoken_collection.js @@ -39,7 +39,7 @@ comparator: function (t1, t2) { var ts1 = parseInt(t1.get('lastActivity'), 10); var ts2 = parseInt(t2.get('lastActivity'), 10); - return ts1 < ts2; + return ts2 - ts1; }, tokenType: null, diff --git a/settings/js/authtoken_view.js b/settings/js/authtoken_view.js index 2ebedb4131c..354173341bc 100644 --- a/settings/js/authtoken_view.js +++ b/settings/js/authtoken_view.js @@ -114,14 +114,18 @@ // Chrome User Agent from https://developer.chrome.com/multidevice/user-agent chrome: /^Mozilla\/5\.0 \([^)]*(Windows|OS X|Linux)[^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Chrome\/(\d+)[0-9.]+ (?:Mobile Safari|Safari)\/[0-9.]+$/, // Safari User Agent from http://www.useragentstring.com/pages/Safari/ - safari: /^Mozilla\/5\.0 \([^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Version\/([0-9]+)[0-9.]+ Safari\/[0-9.A-Z]+$/, + safari: /^Mozilla\/5\.0 \([^)]*(Windows|OS X)[^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\)(?: Version\/([0-9]+)[0-9.]+)? Safari\/[0-9.A-Z]+$/, // Android Chrome user agent: https://developers.google.com/chrome/mobile/docs/user-agent androidChrome: /Android.*(?:; (.*) Build\/).*Chrome\/(\d+)[0-9.]+/, iphone: / *CPU +iPhone +OS +(\d+)_\d+ +like +Mac +OS +X */, iosClient: /^Mozilla\/5\.0 \(iOS\) ownCloud\-iOS.*$/, androidClient:/^Mozilla\/5\.0 \(Android\) ownCloud\-android.*$/, // DAVdroid/1.2 (2016/07/03; dav4android; okhttp3) Android/6.0.1 - davDroid: /DAVdroid\/([0-9.]+)/ + davDroid: /DAVdroid\/([0-9.]+)/, + // Mozilla/5.0 (U; Linux; Maemo; Jolla; Sailfish; like Android 4.3) AppleWebKit/538.1 (KHTML, like Gecko) WebPirate/2.0 like Mobile Safari/538.1 (compatible) + webPirate: /(Sailfish).*WebPirate\/(\d+)/, + // Mozilla/5.0 (Maemo; Linux; U; Jolla; Sailfish; Mobile; rv:31.0) Gecko/31.0 Firefox/31.0 SailfishBrowser/1.0 + sailfishBrowser: /(Sailfish).*SailfishBrowser\/(\d+)/ }; var nameMap = { ie: t('setting', 'Internet Explorer'), @@ -133,7 +137,9 @@ iphone: t('setting', 'iPhone'), iosClient: t('setting', 'iOS Client'), androidClient: t('setting', 'Android Client'), - davDroid: 'DAVdroid' + davDroid: 'DAVdroid', + webPirate: 'WebPirate', + sailfishBrowser: 'SailfishBrowser' }; if (matches) { diff --git a/settings/templates/personal.php b/settings/templates/personal.php index e3164eb5b98..4f580e41287 100644 --- a/settings/templates/personal.php +++ b/settings/templates/personal.php @@ -172,7 +172,7 @@ if($_['passwordChangeSupported']) { <thead class="token-list-header"> <tr> <th><?php p($l->t('Device'));?></th> - <th><?php p($l->t('Recent activity'));?></th> + <th><?php p($l->t('Last activity'));?></th> <th></th> </tr> </thead> @@ -188,7 +188,7 @@ if($_['passwordChangeSupported']) { <thead class="hidden-when-empty"> <tr> <th><?php p($l->t('Name'));?></th> - <th><?php p($l->t('Recent activity'));?></th> + <th><?php p($l->t('Last activity'));?></th> <th></th> </tr> </thead> diff --git a/tests/lib/AppFramework/Http/RequestTest.php b/tests/lib/AppFramework/Http/RequestTest.php index ddc2403d866..8df81afeb3b 100644 --- a/tests/lib/AppFramework/Http/RequestTest.php +++ b/tests/lib/AppFramework/Http/RequestTest.php @@ -1,7 +1,7 @@ <?php /** * @copyright 2013 Thomas Tanghus (thomas@tanghus.net) - * @copyright 2015 Lukas Reschke lukas@owncloud.com + * @copyright 2016 Lukas Reschke lukas@owncloud.com * * This file is licensed under the Affero General Public License version 3 or * later. @@ -740,15 +740,15 @@ class RequestTest extends \Test\TestCase { */ public function testUserAgent($testAgent, $userAgent, $matches) { $request = new Request( - [ - 'server' => [ - 'HTTP_USER_AGENT' => $testAgent, - ] - ], - $this->secureRandom, - $this->config, - $this->csrfTokenManager, - $this->stream + [ + 'server' => [ + 'HTTP_USER_AGENT' => $testAgent, + ] + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream ); $this->assertSame($matches, $request->isUserAgent($userAgent)); @@ -762,11 +762,11 @@ class RequestTest extends \Test\TestCase { */ public function testUndefinedUserAgent($testAgent, $userAgent, $matches) { $request = new Request( - [], - $this->secureRandom, - $this->config, - $this->csrfTokenManager, - $this->stream + [], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream ); $this->assertFalse($request->isUserAgent($userAgent)); @@ -1322,6 +1322,10 @@ class RequestTest extends \Test\TestCase { 'get' => [ 'requesttoken' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', ], + 'cookies' => [ + 'nc_sameSiteCookiestrict' => 'true', + 'nc_sameSiteCookielax' => 'true', + ], ], $this->secureRandom, $this->config, @@ -1348,6 +1352,10 @@ class RequestTest extends \Test\TestCase { 'post' => [ 'requesttoken' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', ], + 'cookies' => [ + 'nc_sameSiteCookiestrict' => 'true', + 'nc_sameSiteCookielax' => 'true', + ], ], $this->secureRandom, $this->config, @@ -1357,10 +1365,10 @@ class RequestTest extends \Test\TestCase { ->getMock(); $token = new CsrfToken('AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds'); $this->csrfTokenManager - ->expects($this->once()) - ->method('isTokenValid') - ->with($token) - ->willReturn(true); + ->expects($this->once()) + ->method('isTokenValid') + ->with($token) + ->willReturn(true); $this->assertTrue($request->passesCSRFCheck()); } @@ -1374,6 +1382,10 @@ class RequestTest extends \Test\TestCase { 'server' => [ 'HTTP_REQUESTTOKEN' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', ], + 'cookies' => [ + 'nc_sameSiteCookiestrict' => 'true', + 'nc_sameSiteCookielax' => 'true', + ], ], $this->secureRandom, $this->config, @@ -1383,14 +1395,254 @@ class RequestTest extends \Test\TestCase { ->getMock(); $token = new CsrfToken('AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds'); $this->csrfTokenManager - ->expects($this->once()) - ->method('isTokenValid') - ->with($token) - ->willReturn(true); + ->expects($this->once()) + ->method('isTokenValid') + ->with($token) + ->willReturn(true); + + $this->assertTrue($request->passesCSRFCheck()); + } + + public function testPassesCSRFCheckWithGetAndWithoutCookies() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'get' => [ + 'requesttoken' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + $this->csrfTokenManager + ->expects($this->once()) + ->method('isTokenValid') + ->willReturn(true); + + $this->assertTrue($request->passesCSRFCheck()); + } + + public function testPassesCSRFCheckWithPostAndWithoutCookies() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'post' => [ + 'requesttoken' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + $this->csrfTokenManager + ->expects($this->once()) + ->method('isTokenValid') + ->willReturn(true); $this->assertTrue($request->passesCSRFCheck()); } + public function testPassesCSRFCheckWithHeaderAndWithoutCookies() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'server' => [ + 'HTTP_REQUESTTOKEN' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + $this->csrfTokenManager + ->expects($this->once()) + ->method('isTokenValid') + ->willReturn(true); + + $this->assertTrue($request->passesCSRFCheck()); + } + + public function testFailsCSRFCheckWithHeaderAndNotAllChecksPassing() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'server' => [ + 'HTTP_REQUESTTOKEN' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + 'cookies' => [ + 'nc_sameSiteCookiestrict' => 'true', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + $this->csrfTokenManager + ->expects($this->never()) + ->method('isTokenValid'); + + $this->assertFalse($request->passesCSRFCheck()); + } + + public function testPassesStrictCookieCheckWithAllCookies() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'server' => [ + 'HTTP_REQUESTTOKEN' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + 'cookies' => [ + 'nc_sameSiteCookiestrict' => 'true', + 'nc_sameSiteCookielax' => 'true', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + + $this->assertTrue($request->passesStrictCookieCheck()); + } + + public function testFailsSRFCheckWithPostAndWithCookies() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'post' => [ + 'requesttoken' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + 'cookies' => [ + 'foo' => 'bar', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + $this->csrfTokenManager + ->expects($this->never()) + ->method('isTokenValid'); + + $this->assertFalse($request->passesCSRFCheck()); + } + + public function testFailStrictCookieCheckWithOnlyLaxCookie() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'server' => [ + 'HTTP_REQUESTTOKEN' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + 'cookies' => [ + 'nc_sameSiteCookielax' => 'true', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + + $this->assertFalse($request->passesStrictCookieCheck()); + } + + public function testFailStrictCookieCheckWithOnlyStrictCookie() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'server' => [ + 'HTTP_REQUESTTOKEN' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + 'cookies' => [ + 'nc_sameSiteCookiestrict' => 'true', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + + $this->assertFalse($request->passesStrictCookieCheck()); + } + + public function testPassesLaxCookieCheck() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'server' => [ + 'HTTP_REQUESTTOKEN' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + 'cookies' => [ + 'nc_sameSiteCookielax' => 'true', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + + $this->assertTrue($request->passesLaxCookieCheck()); + } + + public function testFailsLaxCookieCheckWithOnlyStrictCookie() { + /** @var Request $request */ + $request = $this->getMockBuilder('\OC\AppFramework\Http\Request') + ->setMethods(['getScriptName']) + ->setConstructorArgs([ + [ + 'server' => [ + 'HTTP_REQUESTTOKEN' => 'AAAHGxsTCTc3BgMQESAcNR0OAR0=:MyTotalSecretShareds', + ], + 'cookies' => [ + 'nc_sameSiteCookiestrict' => 'true', + ], + ], + $this->secureRandom, + $this->config, + $this->csrfTokenManager, + $this->stream + ]) + ->getMock(); + + $this->assertFalse($request->passesLaxCookieCheck()); + } + /** * @return array */ @@ -1426,10 +1678,10 @@ class RequestTest extends \Test\TestCase { $token = new CsrfToken($invalidToken); $this->csrfTokenManager - ->expects($this->any()) - ->method('isTokenValid') - ->with($token) - ->willReturn(false); + ->expects($this->any()) + ->method('isTokenValid') + ->with($token) + ->willReturn(false); $this->assertFalse($request->passesCSRFCheck()); } @@ -1449,5 +1701,4 @@ class RequestTest extends \Test\TestCase { $this->assertFalse($request->passesCSRFCheck()); } - } diff --git a/tests/lib/AppFramework/Middleware/OCSMiddlewareTest.php b/tests/lib/AppFramework/Middleware/OCSMiddlewareTest.php new file mode 100644 index 00000000000..66131aa4b25 --- /dev/null +++ b/tests/lib/AppFramework/Middleware/OCSMiddlewareTest.php @@ -0,0 +1,108 @@ +<?php +/** + * + * @author Roeland Jago Douma <roeland@famdouma.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace Test\AppFramework\Middleware; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OC\AppFramework\Http\Request; +use OC\AppFramework\Middleware\OCSMiddleware; + + +class OCSMiddlewareTest extends \Test\TestCase { + + /** + * @var Request + */ + private $request; + + protected function setUp() { + parent::setUp(); + + $this->request = $this->getMockBuilder('OCP\IRequest') + ->getMock(); + + } + + public function dataAfterException() { + $OCSController = $this->getMockBuilder('OCP\AppFramework\OCSController') + ->disableOriginalConstructor() + ->getMock(); + $controller = $this->getMockBuilder('OCP\AppFramework\Controller') + ->disableOriginalConstructor() + ->getMock(); + + return [ + [$OCSController, new \Exception(), true], + [$OCSController, new OCSException(), false, '', Http::STATUS_INTERNAL_SERVER_ERROR], + [$OCSController, new OCSException('foo'), false, 'foo', Http::STATUS_INTERNAL_SERVER_ERROR], + [$OCSController, new OCSException('foo', Http::STATUS_IM_A_TEAPOT), false, 'foo', Http::STATUS_IM_A_TEAPOT], + [$OCSController, new OCSBadRequestException(), false, '', Http::STATUS_BAD_REQUEST], + [$OCSController, new OCSBadRequestException('foo'), false, 'foo', Http::STATUS_BAD_REQUEST], + [$OCSController, new OCSForbiddenException(), false, '', Http::STATUS_FORBIDDEN], + [$OCSController, new OCSForbiddenException('foo'), false, 'foo', Http::STATUS_FORBIDDEN], + [$OCSController, new OCSNotFoundException(), false, '', Http::STATUS_NOT_FOUND], + [$OCSController, new OCSNotFoundException('foo'), false, 'foo', Http::STATUS_NOT_FOUND], + + [$controller, new \Exception(), true], + [$controller, new OCSException(), true], + [$controller, new OCSException('foo'), true], + [$controller, new OCSException('foo', Http::STATUS_IM_A_TEAPOT), true], + [$controller, new OCSBadRequestException(), true], + [$controller, new OCSBadRequestException('foo'), true], + [$controller, new OCSForbiddenException(), true], + [$controller, new OCSForbiddenException('foo'), true], + [$controller, new OCSNotFoundException(), true], + [$controller, new OCSNotFoundException('foo'), true], + ]; + } + + /** + * @dataProvider dataAfterException + * + * @param Controller $controller + * @param \Exception $exception + * @param bool $forward + * @param string $message + * @param int $code + */ + public function testAfterException($controller, $exception, $forward, $message = '', $code = 0) { + $OCSMiddleware = new OCSMiddleware($this->request); + + try { + $result = $OCSMiddleware->afterException($controller, 'method', $exception); + $this->assertFalse($forward); + + $this->assertInstanceOf('OCP\AppFramework\Http\OCSResponse', $result); + + $this->assertSame($message, $this->invokePrivate($result, 'message')); + $this->assertSame($code, $this->invokePrivate($result, 'statuscode')); + } catch (\Exception $e) { + $this->assertTrue($forward); + $this->assertEquals($exception, $e); + } + } + +} diff --git a/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php index a4f203bacd7..487b83c0bef 100644 --- a/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php +++ b/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php @@ -31,6 +31,7 @@ use OC\AppFramework\Middleware\Security\Exceptions\CrossSiteRequestForgeryExcept use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException; use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; +use OC\Appframework\Middleware\Security\Exceptions\StrictCookieMissingException; use OC\AppFramework\Middleware\Security\SecurityMiddleware; use OC\AppFramework\Utility\ControllerMethodReflector; use OC\Security\CSP\ContentSecurityPolicy; @@ -57,28 +58,28 @@ class SecurityMiddlewareTest extends \Test\TestCase { $this->controller = $this->getMockBuilder('OCP\AppFramework\Controller') ->disableOriginalConstructor() - ->getMock(); + ->getMock(); $this->reader = new ControllerMethodReflector(); $this->logger = $this->getMockBuilder( - 'OCP\ILogger') - ->disableOriginalConstructor() - ->getMock(); + 'OCP\ILogger') + ->disableOriginalConstructor() + ->getMock(); $this->navigationManager = $this->getMockBuilder( - 'OCP\INavigationManager') - ->disableOriginalConstructor() - ->getMock(); + 'OCP\INavigationManager') + ->disableOriginalConstructor() + ->getMock(); $this->urlGenerator = $this->getMockBuilder( - 'OCP\IURLGenerator') - ->disableOriginalConstructor() - ->getMock(); + 'OCP\IURLGenerator') + ->disableOriginalConstructor() + ->getMock(); $this->request = $this->getMockBuilder( - 'OCP\IRequest') - ->disableOriginalConstructor() - ->getMock(); + 'OCP\IRequest') + ->disableOriginalConstructor() + ->getMock(); $this->contentSecurityPolicyManager = $this->getMockBuilder( - 'OC\Security\CSP\ContentSecurityPolicyManager') - ->disableOriginalConstructor() - ->getMock(); + 'OC\Security\CSP\ContentSecurityPolicyManager') + ->disableOriginalConstructor() + ->getMock(); $this->middleware = $this->getMiddleware(true, true); $this->secException = new SecurityException('hey', false); $this->secAjaxException = new SecurityException('hey', true); @@ -211,8 +212,8 @@ class SecurityMiddlewareTest extends \Test\TestCase { */ public function testNoChecks(){ $this->request->expects($this->never()) - ->method('passesCSRFCheck') - ->will($this->returnValue(false)); + ->method('passesCSRFCheck') + ->will($this->returnValue(false)); $sec = $this->getMiddleware(false, false); @@ -256,7 +257,9 @@ class SecurityMiddlewareTest extends \Test\TestCase { $this->request->expects($this->once()) ->method('passesCSRFCheck') ->will($this->returnValue(false)); - + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->will($this->returnValue(true)); $this->reader->reflect(__CLASS__, __FUNCTION__); $this->middleware->beforeController(__CLASS__, __FUNCTION__); } @@ -275,19 +278,81 @@ class SecurityMiddlewareTest extends \Test\TestCase { $this->middleware->beforeController(__CLASS__, __FUNCTION__); } + /** + * @PublicPage + */ + public function testPassesCsrfCheck(){ + $this->request->expects($this->once()) + ->method('passesCSRFCheck') + ->will($this->returnValue(true)); + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->will($this->returnValue(true)); + + $this->reader->reflect(__CLASS__, __FUNCTION__); + $this->middleware->beforeController(__CLASS__, __FUNCTION__); + } /** * @PublicPage + * @expectedException \OC\AppFramework\Middleware\Security\Exceptions\CrossSiteRequestForgeryException */ public function testFailCsrfCheck(){ $this->request->expects($this->once()) ->method('passesCSRFCheck') + ->will($this->returnValue(false)); + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') ->will($this->returnValue(true)); $this->reader->reflect(__CLASS__, __FUNCTION__); $this->middleware->beforeController(__CLASS__, __FUNCTION__); } + /** + * @PublicPage + * @StrictCookieRequired + * @expectedException \OC\Appframework\Middleware\Security\Exceptions\StrictCookieMissingException + */ + public function testStrictCookieRequiredCheck() { + $this->request->expects($this->never()) + ->method('passesCSRFCheck'); + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->will($this->returnValue(false)); + + $this->reader->reflect(__CLASS__, __FUNCTION__); + $this->middleware->beforeController(__CLASS__, __FUNCTION__); + } + + + /** + * @PublicPage + * @NoCSRFRequired + */ + public function testNoStrictCookieRequiredCheck() { + $this->request->expects($this->never()) + ->method('passesStrictCookieCheck') + ->will($this->returnValue(false)); + + $this->reader->reflect(__CLASS__, __FUNCTION__); + $this->middleware->beforeController(__CLASS__, __FUNCTION__); + } + + /** + * @PublicPage + * @NoCSRFRequired + * @StrictCookieRequired + */ + public function testPassesStrictCookieRequiredCheck() { + $this->request + ->expects($this->once()) + ->method('passesStrictCookieCheck') + ->willReturn(true); + + $this->reader->reflect(__CLASS__, __FUNCTION__); + $this->middleware->beforeController(__CLASS__, __FUNCTION__); + } /** * @NoCSRFRequired @@ -331,41 +396,64 @@ class SecurityMiddlewareTest extends \Test\TestCase { public function testAfterExceptionReturnsRedirectForNotLoggedInUser() { $this->request = new Request( - [ - 'server' => - [ - 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'REQUEST_URI' => 'owncloud/index.php/apps/specialapp' - ] - ], - $this->getMockBuilder('\OCP\Security\ISecureRandom')->getMock(), - $this->getMockBuilder('\OCP\IConfig')->getMock() + [ + 'server' => + [ + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'REQUEST_URI' => 'owncloud/index.php/apps/specialapp' + ] + ], + $this->getMockBuilder('\OCP\Security\ISecureRandom')->getMock(), + $this->getMockBuilder('\OCP\IConfig')->getMock() ); $this->middleware = $this->getMiddleware(false, false); $this->urlGenerator - ->expects($this->once()) - ->method('linkToRoute') - ->with( - 'core.login.showLoginForm', - [ - 'redirect_url' => 'owncloud%2Findex.php%2Fapps%2Fspecialapp', - ] - ) - ->will($this->returnValue('http://localhost/index.php/login?redirect_url=owncloud%2Findex.php%2Fapps%2Fspecialapp')); + ->expects($this->once()) + ->method('linkToRoute') + ->with( + 'core.login.showLoginForm', + [ + 'redirect_url' => 'owncloud%2Findex.php%2Fapps%2Fspecialapp', + ] + ) + ->will($this->returnValue('http://localhost/index.php/login?redirect_url=owncloud%2Findex.php%2Fapps%2Fspecialapp')); $this->logger - ->expects($this->once()) - ->method('debug') - ->with('Current user is not logged in'); + ->expects($this->once()) + ->method('debug') + ->with('Current user is not logged in'); $response = $this->middleware->afterException( - $this->controller, - 'test', - new NotLoggedInException() + $this->controller, + 'test', + new NotLoggedInException() ); - $expected = new RedirectResponse('http://localhost/index.php/login?redirect_url=owncloud%2Findex.php%2Fapps%2Fspecialapp'); $this->assertEquals($expected , $response); } + public function testAfterExceptionRedirectsToWebRootAfterStrictCookieFail() { + $this->request = new Request( + [ + 'server' => [ + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'REQUEST_URI' => 'owncloud/index.php/apps/specialapp', + ], + ], + $this->getMockBuilder('\OCP\Security\ISecureRandom')->getMock(), + $this->getMockBuilder('\OCP\IConfig')->getMock() + ); + + $this->middleware = $this->getMiddleware(false, false); + $response = $this->middleware->afterException( + $this->controller, + 'test', + new StrictCookieMissingException() + ); + + $expected = new RedirectResponse(\OC::$WEBROOT); + $this->assertEquals($expected , $response); + } + + /** * @return array */ @@ -389,36 +477,34 @@ class SecurityMiddlewareTest extends \Test\TestCase { */ public function testAfterExceptionReturnsTemplateResponse(SecurityException $exception) { $this->request = new Request( - [ - 'server' => - [ - 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'REQUEST_URI' => 'owncloud/index.php/apps/specialapp' - ] - ], - $this->getMockBuilder('\OCP\Security\ISecureRandom')->getMock(), - $this->getMockBuilder('\OCP\IConfig')->getMock() + [ + 'server' => + [ + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'REQUEST_URI' => 'owncloud/index.php/apps/specialapp' + ] + ], + $this->getMockBuilder('\OCP\Security\ISecureRandom')->getMock(), + $this->getMockBuilder('\OCP\IConfig')->getMock() ); $this->middleware = $this->getMiddleware(false, false); $this->logger - ->expects($this->once()) - ->method('debug') - ->with($exception->getMessage()); + ->expects($this->once()) + ->method('debug') + ->with($exception->getMessage()); $response = $this->middleware->afterException( - $this->controller, - 'test', - $exception + $this->controller, + 'test', + $exception ); - $expected = new TemplateResponse('core', '403', ['file' => $exception->getMessage()], 'guest'); $expected->setStatus($exception->getCode()); $this->assertEquals($expected , $response); } - public function testAfterAjaxExceptionReturnsJSONError(){ $response = $this->middleware->afterException($this->controller, 'test', - $this->secAjaxException); + $this->secAjaxException); $this->assertTrue($response instanceof JSONResponse); } @@ -440,10 +526,10 @@ class SecurityMiddlewareTest extends \Test\TestCase { ->method('getDefaultPolicy') ->willReturn($defaultPolicy); $this->contentSecurityPolicyManager - ->expects($this->once()) - ->method('mergePolicies') - ->with($defaultPolicy, $currentPolicy) - ->willReturn($mergedPolicy); + ->expects($this->once()) + ->method('mergePolicies') + ->with($defaultPolicy, $currentPolicy) + ->willReturn($mergedPolicy); $response->expects($this->once()) ->method('setContentSecurityPolicy') ->with($mergedPolicy); diff --git a/tests/lib/Files/Storage/Storage.php b/tests/lib/Files/Storage/Storage.php index ed2ea87f9d9..04aafece2e3 100644 --- a/tests/lib/Files/Storage/Storage.php +++ b/tests/lib/Files/Storage/Storage.php @@ -105,6 +105,17 @@ abstract class Storage extends \Test\TestCase { $this->assertEquals(array(), $content); } + public function fileNameProvider() { + return [ + ['file.txt'], + [' file.txt'], + ['folder .txt'], + ['file with space.txt'], + ['spéciäl fäile'], + ['test single\'quote.txt'], + ]; + } + public function directoryProvider() { return [ ['folder'], @@ -336,22 +347,25 @@ abstract class Storage extends \Test\TestCase { $this->assertFalse($this->instance->file_exists('/lorem.txt')); } - public function testFOpen() { + /** + * @dataProvider fileNameProvider + */ + public function testFOpen($fileName) { $textFile = \OC::$SERVERROOT . '/tests/data/lorem.txt'; - $fh = @$this->instance->fopen('foo', 'r'); + $fh = @$this->instance->fopen($fileName, 'r'); if ($fh) { fclose($fh); } $this->assertFalse($fh); - $this->assertFalse($this->instance->file_exists('foo')); + $this->assertFalse($this->instance->file_exists($fileName)); - $fh = $this->instance->fopen('foo', 'w'); + $fh = $this->instance->fopen($fileName, 'w'); fwrite($fh, file_get_contents($textFile)); fclose($fh); - $this->assertTrue($this->instance->file_exists('foo')); + $this->assertTrue($this->instance->file_exists($fileName)); - $fh = $this->instance->fopen('foo', 'r'); + $fh = $this->instance->fopen($fileName, 'r'); $content = stream_get_contents($fh); $this->assertEquals(file_get_contents($textFile), $content); } |