Commit 7af1bf4f authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[TASK] Introduce sudo mode for install tool accessed via backend

The session expiration time for the install tool is reduced from
60 to 15 minutes. When accessing the install tool via backend user
interface, currently logged in backend users have to confirm their
user password again in order to get access to the install tool.
This process is known as "sudo mode".

Standalone install tool is not affected by sudo mode confirmation.
This change enforces mitigation as mentioned in TYPO3-CORE-SA-2020-006,
see https://typo3.org/security/advisory/typo3-core-sa-2020-006.

Resolves: #92836
Releases: master, 10.4, 9.5
Change-Id: Ib4f0e92346610879347a48587ffd575429b98650
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/66630


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Markus Klein's avatarMarkus Klein <markus.klein@typo3.org>
Tested-by: Torben Hansen's avatarTorben Hansen <derhansen@gmail.com>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Markus Klein's avatarMarkus Klein <markus.klein@typo3.org>
Reviewed-by: Torben Hansen's avatarTorben Hansen <derhansen@gmail.com>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 5074e96f
.. include:: ../../Includes.txt
=============================================================================
Important: #92836 - Introduce sudo mode for Install Tool accessed via backend
=============================================================================
See :issue:`92836`
Description
===========
When accessing the Install Tool via backend user interface, currently logged in
backend users have to confirm their user password again in order to get access
to the Install Tool. As an alternative, it is also possible to use the install
tool password (reasons described below in "side effects" section). This is done
in order to mitigate unintended modifications that might occur as result
of e.g. possible cross-site scripting vulnerabilities in the system.
Standalone Install Tool is not affected by sudo mode confirmation.
This change enforces mitigation as mentioned in TYPO3-CORE-SA-2020-006_.
Potential side effects
======================
Albeit default local authentication mechanisms are working well, there are
side effects for 3rd party extensions that make use of these `auth` service
chains as well - such as multi-factor authentication or single sign-on handling.
As an alternative, it is possible to confirm actions using the Install Tool
password, instead of confirming with users' password (which might be handled
with separate remote services).
Services that extend authentication with custom additional factors (2FA/MFA)
are advised to intercept only valid login requests instead of all `authUser`
invocations.
.. code-block: php
class MyAuthenticationService
extends \TYPO3\CMS\Core\Authentication\AbstractAuthenticationService
{
public function authUser(array $user)
{
// only handle actual login requests
if (empty($this->login['status'])
|| $this->login['status'] !== 'login') {
// skip this service, hand over to next in chain
return 100;
}
...
// usual processing for valid login requests
...
}
}
Please see this pull-request_ for a 2FA/MFA extension as an example.
.. _TYPO3-CORE-SA-2020-006: https://typo3.org/security/advisory/typo3-core-sa-2020-006
.. _pull-request: https://github.com/derhansen/sf_yubikey/pull/45/files
.. index:: Backend, ext:install
......@@ -45,6 +45,20 @@ class AbstractIntroductionPackage
$I->amOnPage('/typo3');
$I->click('Maintenance');
$I->switchToContentFrame();
try {
// fill in sudo mode password
$I->see('Confirm with user password');
$I->fillField('confirmationPassword', 'password');
$I->click('Confirm');
$I->wait(10);
// wait for Maintenance headline being available
$I->waitForText('Maintenance');
$I->canSee('Maintenance', 'h1');
} catch (\Exception $e) {
// nothing...
}
$I->click('Flush cache');
}
......
......@@ -40,14 +40,14 @@ class BlankPageCest
$I->click('No problems detected, continue with installation');
// DatabaseConnection step
$I->waitForText('Select database');
$I->waitForText('Select database', 30);
$I->fillField('#t3-install-step-mysqliManualConfiguration-username', $scenario->current('typo3InstallMysqlDatabaseUsername'));
$I->fillField('#t3-install-step-mysqliManualConfiguration-password', $scenario->current('typo3InstallMysqlDatabasePassword'));
$I->fillField('#t3-install-step-mysqliManualConfiguration-host', $scenario->current('typo3InstallMysqlDatabaseHost'));
$I->click('Continue');
// DatabaseSelect step
$I->waitForText('Select a database');
$I->waitForText('Select a database', 30);
$I->click('#t3-install-form-db-select-type-new');
$I->fillField('#t3-install-step-database-new', $scenario->current('typo3InstallMysqlDatabaseName'));
$I->click('Continue');
......@@ -59,7 +59,7 @@ class BlankPageCest
$I->click('Continue');
// DefaultConfiguration step - Create empty page
$I->waitForText('Installation Complete');
$I->waitForText('Installation Complete', 30);
$I->click('#create-site');
$I->click('Open the TYPO3 Backend');
......
......@@ -42,14 +42,14 @@ class IntroductionPackageCest extends AbstractIntroductionPackage
$I->click('No problems detected, continue with installation');
// DatabaseConnection step
$I->waitForText('Select database');
$I->waitForText('Select database', 30);
$I->fillField('#t3-install-step-mysqliManualConfiguration-username', $scenario->current('typo3InstallMysqlDatabaseUsername'));
$I->fillField('#t3-install-step-mysqliManualConfiguration-password', $scenario->current('typo3InstallMysqlDatabasePassword'));
$I->fillField('#t3-install-step-mysqliManualConfiguration-host', $scenario->current('typo3InstallMysqlDatabaseHost'));
$I->click('Continue');
// DatabaseSelect step
$I->waitForText('Select a database');
$I->waitForText('Select a database', 30);
$I->click('#t3-install-form-db-select-type-new');
$I->fillField('#t3-install-step-database-new', $scenario->current('typo3InstallMysqlDatabaseName'));
$I->click('Continue');
......@@ -61,7 +61,7 @@ class IntroductionPackageCest extends AbstractIntroductionPackage
$I->click('Continue');
// DefaultConfiguration step - Create empty page
$I->waitForText('Installation Complete');
$I->waitForText('Installation Complete', 30);
$I->click('#load-distributions');
$I->click('Open the TYPO3 Backend');
......
......@@ -18,21 +18,109 @@ declare(strict_types=1);
namespace TYPO3\CMS\Install\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Authentication\AbstractAuthenticationService;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Fluid\View\StandaloneView;
use TYPO3\CMS\Install\Service\SessionService;
/**
* Backend module controller to the install tool. Sets an install tool session
* Backend module controller to the Install Tool. Sets an Install Tool session
* marked as "initialized by a valid system administrator backend user" and
* redirects to the install tool entry point.
* redirects to the Install Tool entry point.
*
* This is a classic backend module that does not interfere with other code
* within the install tool, it can be seen as a facade around install tool just
* to embed the install tool in backend.
* within the Install Tool, it can be seen as a facade around Install Tool just
* to embed the Install Tool in backend.
* @internal This class is a specific controller implementation and is not considered part of the Public TYPO3 API.
*/
class BackendModuleController
{
protected const FLAG_CONFIRMATION_REQUEST = 1;
protected const FLAG_INSTALL_TOOL_PASSWORD = 2;
protected const ALLOWED_ACTIONS = ['maintenance', 'settings', 'upgrade', 'environment'];
/**
* @var SessionService
*/
protected $sessionService;
/**
* @var UriBuilder
*/
protected $uriBuilder;
public function __construct()
{
$this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
}
/**
* Shows and handles backend user session confirmation ("sudo mode") for
* accessing a particular Install Tool controller (as given in `$targetController`).
*
* @param ServerRequestInterface $request
* @return ResponseInterface
*/
public function backendUserConfirmationAction(ServerRequestInterface $request): ResponseInterface
{
$flags = (int)($request->getQueryParams()['flags'] ?? 0);
$targetController = (string)($request->getQueryParams()['targetController'] ?? '');
$targetHash = (string)($request->getQueryParams()['targetHash'] ?? '');
$expectedTargetHash = GeneralUtility::hmac($targetController, BackendModuleController::class);
$flagInstallToolPassword = (bool)($flags & self::FLAG_INSTALL_TOOL_PASSWORD);
$flagInvalidPassword = false;
if (!in_array($targetController, self::ALLOWED_ACTIONS, true)
|| !hash_equals($expectedTargetHash, $targetHash)) {
return new HtmlResponse('', 403);
}
if ($flags & self::FLAG_CONFIRMATION_REQUEST) {
if ($flagInstallToolPassword && $this->verifyInstallToolPassword($request)) {
return $this->setAuthorizedAndRedirect($targetController);
}
if (!$flagInstallToolPassword && $this->verifyBackendUserPassword($request)) {
return $this->setAuthorizedAndRedirect($targetController);
}
$flagInvalidPassword = true;
}
$view = GeneralUtility::makeInstance(StandaloneView::class);
$view->getTemplatePaths()->setTemplatePathAndFilename(
ExtensionManagementUtility::extPath(
'install',
'Resources/Private/Templates/BackendModule/BackendUserConfirmation.html'
)
);
$view->assignMultiple([
'flagInvalidPassword' => $flagInvalidPassword,
'flagInstallToolPassword' => $flagInstallToolPassword,
'languageFileReference' => 'LLL:EXT:install/Resources/Private/Language/BackendModule.xlf',
'passwordModeUri' => $this->getBackendUserConfirmationUri([
'targetController' => $targetController,
'targetHash' => $targetHash,
// current flags, unset FLAG_CONFIRMATION_REQUEST, toggle FLAG_INSTALL_TOOL_PASSWORD
'flags' => $flags & ~self::FLAG_CONFIRMATION_REQUEST ^ self::FLAG_INSTALL_TOOL_PASSWORD,
]),
'verifyUri' => $this->getBackendUserConfirmationUri([
'targetController' => $targetController,
'targetHash' => $targetHash,
// current flags, add FLAG_CONFIRMATION_REQUEST
'flags' => $flags | self::FLAG_CONFIRMATION_REQUEST,
]),
'cancelUri' => '',
]);
return new HtmlResponse($view->render());
}
/**
* Initialize session and redirect to "maintenance"
*
......@@ -40,7 +128,8 @@ class BackendModuleController
*/
public function maintenanceAction(): ResponseInterface
{
return $this->setAuthorizedAndRedirect('maintenance');
return $this->getBackendUserConfirmationRedirect('maintenance')
?? $this->setAuthorizedAndRedirect('maintenance');
}
/**
......@@ -50,7 +139,8 @@ class BackendModuleController
*/
public function settingsAction(): ResponseInterface
{
return $this->setAuthorizedAndRedirect('settings');
return $this->getBackendUserConfirmationRedirect('settings')
?? $this->setAuthorizedAndRedirect('settings');
}
/**
......@@ -60,7 +150,8 @@ class BackendModuleController
*/
public function upgradeAction(): ResponseInterface
{
return $this->setAuthorizedAndRedirect('upgrade');
return $this->getBackendUserConfirmationRedirect('upgrade')
?? $this->setAuthorizedAndRedirect('upgrade');
}
/**
......@@ -70,11 +161,38 @@ class BackendModuleController
*/
public function environmentAction(): ResponseInterface
{
return $this->setAuthorizedAndRedirect('environment');
return $this->getBackendUserConfirmationRedirect('environment')
?? $this->setAuthorizedAndRedirect('environment');
}
/**
* Creates redirect response to backend user confirmation (if required).
*
* @param string $targetController
* @return ResponseInterface|null
*/
protected function getBackendUserConfirmationRedirect(string $targetController): ?ResponseInterface
{
if ($this->getSessionService()->isAuthorizedBackendUserSession()) {
return null;
}
$redirectUri = $this->getBackendUserConfirmationUri([
'targetController' => $targetController,
'targetHash' => GeneralUtility::hmac($targetController, BackendModuleController::class),
]);
return new RedirectResponse((string)$redirectUri, 403);
}
protected function getBackendUserConfirmationUri(array $parameters): Uri
{
return $this->uriBuilder->buildUriFromRoute(
'install.backend-user-confirmation',
$parameters
);
}
/**
* Starts / updates the session and redirects to the install tool
* Starts / updates the session and redirects to the Install Tool
* with given action.
*
* @param string $controller
......@@ -82,10 +200,121 @@ class BackendModuleController
*/
protected function setAuthorizedAndRedirect(string $controller): ResponseInterface
{
$sessionService = new SessionService();
$sessionService->startSession();
$sessionService->setAuthorizedBackendSession();
$this->getSessionService()->setAuthorizedBackendSession();
$redirectLocation = 'install.php?install[controller]=' . $controller . '&install[context]=backend';
return new RedirectResponse($redirectLocation, 303);
}
/**
* Verifies that provided password matches Install Tool password.
*
* @param ServerRequestInterface $request
* @return bool
*/
protected function verifyInstallToolPassword(ServerRequestInterface $request): bool
{
$parsedBody = $request->getParsedBody();
$password = $parsedBody['confirmationPassword'] ?? null;
$installToolPassword = $GLOBALS['TYPO3_CONF_VARS']['BE']['installToolPassword'] ?? null;
if (!is_string($password) || empty($installToolPassword)) {
return false;
}
try {
$hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
$hashInstance = $hashFactory->get($installToolPassword, 'BE');
return $hashInstance->checkPassword($password, $installToolPassword);
} catch (InvalidPasswordHashException $exception) {
return false;
}
}
/**
* Verifies that provided password is actually correct for current backend user
* by stepping through authentication chain in `$GLOBALS['BE_USER]`.
*
* @param ServerRequestInterface $request
* @return bool
*/
protected function verifyBackendUserPassword(ServerRequestInterface $request): bool
{
$parsedBody = $request->getParsedBody();
$password = $parsedBody['confirmationPassword'] ?? null;
if (!is_string($password)) {
return false;
}
// clone current backend user object to avoid
// possible side effects for the real instance
$backendUser = clone $this->getBackendUser();
$loginData = [
'status' => 'sudo-mode',
'origin' => BackendModuleController::class,
'uname' => $backendUser->user['username'],
'uident' => $password,
];
// currently there is no dedicated API to perform authentication
// that's why this process partially has to be simulated here
$loginData = $backendUser->processLoginData($loginData);
$authInfo = $backendUser->getAuthInfoArray();
$authenticated = false;
/** @var AbstractAuthenticationService $service or any other service (sic!) */
foreach ($this->getAuthServices($backendUser, $loginData, $authInfo) as $service) {
$ret = (int)$service->authUser($backendUser->user);
if ($ret <= 0) {
return false;
}
if ($ret >= 200) {
return true;
}
if ($ret < 100) {
$authenticated = true;
continue;
}
}
return $authenticated;
}
/**
* Initializes authentication services to be used in a foreach loop
*
* @param BackendUserAuthentication $backendUser
* @param array $loginData
* @param array $authInfo
* @return \Generator<int, object>
*/
protected function getAuthServices(BackendUserAuthentication $backendUser, array $loginData, array $authInfo): \Generator
{
$serviceChain = [];
$subType = 'authUserBE';
while ($service = GeneralUtility::makeInstanceService('auth', $subType, $serviceChain)) {
$serviceChain[] = $service->getServiceKey();
if (!is_object($service)) {
break;
}
$service->initAuth($subType, $loginData, $authInfo, $backendUser);
yield $service;
}
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
/**
* Install Tool modified sessions meta-data (handler, storage, name) which
* conflicts with existing session that for instance.
*
* @return SessionService
*/
protected function getSessionService(): SessionService
{
if ($this->sessionService === null) {
$this->sessionService = new SessionService();
$this->sessionService->startSession();
}
return $this->sessionService;
}
}
......@@ -47,7 +47,7 @@ class SessionService implements SingletonInterface
*
* @var int
*/
private $expireTimeInMinutes = 60;
private $expireTimeInMinutes = 15;
/**
* time (minutes) to generate a new session id for our current session
......@@ -86,7 +86,7 @@ class SessionService implements SingletonInterface
$sessionCreationError .= '<pre>php_value session.auto_start Off</pre>';
throw new Exception($sessionCreationError, 1294587485);
}
if (defined('SID')) {
if (session_status() === PHP_SESSION_ACTIVE) {
$sessionCreationError = 'Session already started by session_start().<br />';
$sessionCreationError .= 'Make sure no installed extension is starting a session in its ext_localconf.php or ext_tables.php.';
throw new Exception($sessionCreationError, 1294587486);
......
......@@ -86,6 +86,7 @@ class ServiceProvider extends AbstractServiceProvider
public function getExtensions(): array
{
return [
'backend.routes' => [ static::class, 'configureBackendRoutes' ],
CommandRegistry::class => [ static::class, 'configureCommands' ],
];
}
......
<?php
use TYPO3\CMS\Install\Controller\BackendModuleController;
/**
* Defines routes for Install Tool being called from backend context.
*/
return [
'install.backend-user-confirmation' => [
'path' => '/install/backend-user-confirmation',
'target' => BackendModuleController::class . '::backendUserConfirmationAction'
],
];
......@@ -25,6 +25,27 @@
<trans-unit id="confirmUnlockInstallToolButton" resname="confirmUnlockInstallToolButton">
<source>Unlock the Install&amp;#160;Tool</source>
</trans-unit>
<trans-unit id="cancel" resname="cancel">
<source>Cancel</source>
</trans-unit>
<trans-unit id="confirm" resname="confirm">
<source>Confirm</source>
</trans-unit>
<trans-unit id="sudoUserPasswordConfirm" resname="sudoUserPasswordConfirm">
<source>Confirm with user password</source>
</trans-unit>
<trans-unit id="sudoInstallToolPasswordConfirm" resname="sudoInstallToolPasswordConfirm">
<source>Confirm with Install Tool password</source>
</trans-unit>
<trans-unit id="sudoPasswordInvalid" resname="sudoPasswordConfirm">
<source>Invalid password</source>
</trans-unit>
<trans-unit id="sudoUserPasswordMode" resname="sudoUserPasswordMode">
<source>Use user password instead</source>
</trans-unit>
<trans-unit id="sudoInstallToolPasswordMode" resname="sudoInstallToolPasswordMode">
<source>Use Install Tool password instead</source>
</trans-unit>
</body>
</file>
</xliff>
<html
xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers"
data-namespace-typo3-fluid="true">
<be:moduleLayout>
<div class="modal-backdrop in"></div>
<div class="modal modal-severity-warning modal-size-small" tabindex="-1" role="dialog" style="display: block;">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">
<f:if condition="{flagInstallToolPassword}">
<f:then>
<f:translate key="{languageFileReference}:sudoInstallToolPasswordConfirm" />
</f:then>
<f:else>
<f:translate key="{languageFileReference}:sudoUserPasswordConfirm" />
</f:else>
</f:if>
</h4>
</div>
<div class="modal-body">
<div>
<f:if condition="{flagInvalidPassword} || {isJsonRequest}">
<div class="alert alert-danger" id="invalid-sudo">
<f:translate key="{languageFileReference}:sudoPasswordInvalid" />
</div>
</f:if>
<f:form id="confirm-sudo" class="form" method="post" actionUri="{verifyUri}" absolute="true">
<div class="form-group">
<div class="form-control-holder">
<label for="confirmationPassword">
<f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang.xlf:login.password" />
</label>
<f:form.password name="confirmationPassword" id="confirmationPassword" class="form-control"
additionalAttributes="{required: 'required'}" />
<f:form.hidden name="flags" value="{flags}" />
</div>
</div>
</f:form>
<div class="text-right">
<a href="{passwordModeUri}">
<f:if condition="{flagInstallToolPassword}">
<f:then>
<f:translate key="{languageFileReference}:sudoUserPasswordMode" />
</f:then>
<f:else>
<f:translate key="{languageFileReference}:sudoInstallToolPasswordMode" />
</f:else>
</f:if>
</a>
</div>
</div>
</div>
<div class="modal-footer">
<f:if condition="{cancelUri}">
<a href="{cancelUri}" class="btn btn-default" role="button">
<f:translate key="{languageFileReference}:cancel" />
</a>
</f:if>
<button type="submit" form="confirm-sudo" class="btn btn-warning" role="button">
<f:translate key="{languageFileReference}:confirm" />
</button>