Merge pull request #49560 from nextcloud/fix/login-origin

feat(login): add origin check at login
pull/49944/head
Stephan Orbaugh 4 months ago committed by GitHub
commit d4715c61f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 13
      .htaccess
  2. 7
      build/integration/features/bootstrap/Auth.php
  3. 6
      build/integration/features/bootstrap/BasicStructure.php
  4. 35
      core/Controller/LoginController.php
  5. 28
      tests/Core/Controller/LoginControllerTest.php

@ -15,11 +15,18 @@
<IfModule mod_env.c>
# Add security and privacy related headers
# Avoid doubled headers by unsetting headers in "onsuccess" table,
# then add headers to "always" table: https://github.com/nextcloud/server/pull/19002
Header onsuccess unset Referrer-Policy
Header always set Referrer-Policy "no-referrer"
<If "%{REQUEST_URI} =~ m#/login$#">
# Only on the login page we need any Origin or Referer header set.
Header onsuccess unset Referrer-Policy
Header always set Referrer-Policy "same-origin"
</If>
<Else>
Header onsuccess unset Referrer-Policy
Header always set Referrer-Policy "no-referrer"
</Else>
Header onsuccess unset X-Content-Type-Options
Header always set X-Content-Type-Options "nosniff"

@ -1,4 +1,5 @@
<?php
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
@ -203,7 +204,8 @@ trait Auth {
* @param bool $remember
*/
public function aNewBrowserSessionIsStarted($remember = false) {
$loginUrl = substr($this->baseUrl, 0, -5) . '/login';
$baseUrl = substr($this->baseUrl, 0, -5);
$loginUrl = $baseUrl . '/login';
// Request a new session and extract CSRF token
$client = new Client();
$response = $client->get($loginUrl, [
@ -222,6 +224,9 @@ trait Auth {
'requesttoken' => $this->requestToken,
],
'cookies' => $this->cookieJar,
'headers' => [
'Origin' => $baseUrl,
],
]
);
$this->extracRequestTokenFromResponse($response);

@ -279,7 +279,8 @@ trait BasicStructure {
* @param string $user
*/
public function loggingInUsingWebAs($user) {
$loginUrl = substr($this->baseUrl, 0, -5) . '/index.php/login';
$baseUrl = substr($this->baseUrl, 0, -5);
$loginUrl = $baseUrl . '/index.php/login';
// Request a new session and extract CSRF token
$client = new Client();
$response = $client->get(
@ -302,6 +303,9 @@ trait BasicStructure {
'requesttoken' => $this->requestToken,
],
'cookies' => $this->cookieJar,
'headers' => [
'Origin' => $baseUrl,
],
]
);
$this->extracRequestTokenFromResponse($response);

@ -41,12 +41,14 @@ use OCP\IUser;
use OCP\IUserManager;
use OCP\Notification\IManager;
use OCP\Security\Bruteforce\IThrottler;
use OCP\Security\ITrustedDomainHelper;
use OCP\Util;
class LoginController extends Controller {
public const LOGIN_MSG_INVALIDPASSWORD = 'invalidpassword';
public const LOGIN_MSG_USERDISABLED = 'userdisabled';
public const LOGIN_MSG_CSRFCHECKFAILED = 'csrfCheckFailed';
public const LOGIN_MSG_INVALID_ORIGIN = 'invalidOrigin';
public function __construct(
?string $appName,
@ -167,6 +169,9 @@ class LoginController extends Controller {
Util::addHeader('meta', ['property' => 'og:type', 'content' => 'website']);
Util::addHeader('meta', ['property' => 'og:image', 'content' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'favicon-touch.png'))]);
// Add same-origin referrer policy so we can check for valid requests
Util::addHeader('meta', ['name' => 'referrer', 'content' => 'same-origin']);
$parameters = [
'alt_login' => OC_App::getAlternativeLogIns(),
'pageTitle' => $this->l10n->t('Login'),
@ -269,29 +274,42 @@ class LoginController extends Controller {
return new RedirectResponse($this->urlGenerator->linkToDefaultPageUrl());
}
/**
* @return RedirectResponse
*/
#[NoCSRFRequired]
#[PublicPage]
#[BruteForceProtection(action: 'login')]
#[UseSession]
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
#[FrontpageRoute(verb: 'POST', url: '/login')]
public function tryLogin(Chain $loginChain,
public function tryLogin(
Chain $loginChain,
ITrustedDomainHelper $trustedDomainHelper,
string $user = '',
string $password = '',
?string $redirect_url = null,
string $timezone = '',
string $timezone_offset = ''): RedirectResponse {
if (!$this->request->passesCSRFCheck()) {
string $timezone_offset = '',
): RedirectResponse {
$error = '';
$origin = $this->request->getHeader('Origin');
$throttle = true;
if ($origin === '' || !$trustedDomainHelper->isTrustedUrl($origin)) {
// Login attempt not from the same origin,
// We only allow this on the login flow but not on the UI login page.
// This could have come from someone malicious who tries to block a user by triggering the bruteforce protection.
$error = self::LOGIN_MSG_INVALID_ORIGIN;
$throttle = false;
} elseif (!$this->request->passesCSRFCheck()) {
if ($this->userSession->isLoggedIn()) {
// If the user is already logged in and the CSRF check does not pass then
// simply redirect the user to the correct page as required. This is the
// case when a user has already logged-in, in another tab.
return $this->generateRedirect($redirect_url);
}
$error = self::LOGIN_MSG_CSRFCHECKFAILED;
}
if ($error !== '') {
// Clear any auth remnants like cookies to ensure a clean login
// For the next attempt
$this->userSession->logout();
@ -299,8 +317,8 @@ class LoginController extends Controller {
$user,
$user,
$redirect_url,
self::LOGIN_MSG_CSRFCHECKFAILED,
false,
$error,
$throttle,
);
}
@ -371,6 +389,7 @@ class LoginController extends Controller {
$this->session->set('loginMessages', [
[$loginMessage], []
]);
return $response;
}

@ -30,6 +30,7 @@ use OCP\IUser;
use OCP\IUserManager;
use OCP\Notification\IManager;
use OCP\Security\Bruteforce\IThrottler;
use OCP\Security\ITrustedDomainHelper;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
@ -105,6 +106,9 @@ class LoginControllerTest extends TestCase {
$this->request->method('getRemoteAddress')
->willReturn('1.2.3.4');
$this->request->method('getHeader')
->with('Origin')
->willReturn('domain.example.com');
$this->throttler->method('getDelay')
->with(
$this->equalTo('1.2.3.4'),
@ -437,6 +441,8 @@ class LoginControllerTest extends TestCase {
$password = 'secret';
$loginPageUrl = '/login?redirect_url=/apps/files';
$loginChain = $this->createMock(LoginChain::class);
$trustedDomainHelper = $this->createMock(ITrustedDomainHelper::class);
$trustedDomainHelper->method('isTrustedUrl')->willReturn(true);
$this->request
->expects($this->once())
->method('passesCSRFCheck')
@ -463,7 +469,7 @@ class LoginControllerTest extends TestCase {
$expected = new RedirectResponse($loginPageUrl);
$expected->throttle(['user' => 'MyUserName']);
$response = $this->loginController->tryLogin($loginChain, $user, $password, '/apps/files');
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password, '/apps/files');
$this->assertEquals($expected, $response);
}
@ -472,6 +478,8 @@ class LoginControllerTest extends TestCase {
$user = 'MyUserName';
$password = 'secret';
$loginChain = $this->createMock(LoginChain::class);
$trustedDomainHelper = $this->createMock(ITrustedDomainHelper::class);
$trustedDomainHelper->method('isTrustedUrl')->willReturn(true);
$this->request
->expects($this->once())
->method('passesCSRFCheck')
@ -492,7 +500,7 @@ class LoginControllerTest extends TestCase {
->willReturn('/default/foo');
$expected = new RedirectResponse('/default/foo');
$this->assertEquals($expected, $this->loginController->tryLogin($loginChain, $user, $password));
$this->assertEquals($expected, $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password));
}
public function testLoginWithoutPassedCsrfCheckAndNotLoggedIn(): void {
@ -504,6 +512,8 @@ class LoginControllerTest extends TestCase {
$password = 'secret';
$originalUrl = 'another%20url';
$loginChain = $this->createMock(LoginChain::class);
$trustedDomainHelper = $this->createMock(ITrustedDomainHelper::class);
$trustedDomainHelper->method('isTrustedUrl')->willReturn(true);
$this->request
->expects($this->once())
->method('passesCSRFCheck')
@ -517,9 +527,10 @@ class LoginControllerTest extends TestCase {
$this->userSession->expects($this->never())
->method('createRememberMeToken');
$response = $this->loginController->tryLogin($loginChain, 'Jane', $password, $originalUrl);
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, 'Jane', $password, $originalUrl);
$expected = new RedirectResponse('');
$expected->throttle(['user' => 'Jane']);
$this->assertEquals($expected, $response);
}
@ -533,6 +544,8 @@ class LoginControllerTest extends TestCase {
$originalUrl = 'another url';
$redirectUrl = 'http://localhost/another url';
$loginChain = $this->createMock(LoginChain::class);
$trustedDomainHelper = $this->createMock(ITrustedDomainHelper::class);
$trustedDomainHelper->method('isTrustedUrl')->willReturn(true);
$this->request
->expects($this->once())
->method('passesCSRFCheck')
@ -554,7 +567,7 @@ class LoginControllerTest extends TestCase {
->with('remember_login_cookie_lifetime')
->willReturn(1234);
$response = $this->loginController->tryLogin($loginChain, 'Jane', $password, $originalUrl);
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, 'Jane', $password, $originalUrl);
$expected = new RedirectResponse($redirectUrl);
$this->assertEquals($expected, $response);
@ -565,6 +578,8 @@ class LoginControllerTest extends TestCase {
$password = 'secret';
$redirectUrl = 'https://next.cloud/apps/mail';
$loginChain = $this->createMock(LoginChain::class);
$trustedDomainHelper = $this->createMock(ITrustedDomainHelper::class);
$trustedDomainHelper->method('isTrustedUrl')->willReturn(true);
$this->request
->expects($this->once())
->method('passesCSRFCheck')
@ -589,13 +604,15 @@ class LoginControllerTest extends TestCase {
->willReturn($redirectUrl);
$expected = new RedirectResponse($redirectUrl);
$response = $this->loginController->tryLogin($loginChain, $user, $password, '/apps/mail');
$response = $this->loginController->tryLogin($loginChain, $trustedDomainHelper, $user, $password, '/apps/mail');
$this->assertEquals($expected, $response);
}
public function testToNotLeakLoginName(): void {
$loginChain = $this->createMock(LoginChain::class);
$trustedDomainHelper = $this->createMock(ITrustedDomainHelper::class);
$trustedDomainHelper->method('isTrustedUrl')->willReturn(true);
$this->request
->expects($this->once())
->method('passesCSRFCheck')
@ -628,6 +645,7 @@ class LoginControllerTest extends TestCase {
$response = $this->loginController->tryLogin(
$loginChain,
$trustedDomainHelper,
'john@doe.com',
'just wrong',
'/apps/files'

Loading…
Cancel
Save