Merge remote-tracking branch 'ghsa-crj4-pmx2-cxqm/advisory-fix-1'

This commit is contained in:
djmaze 2026-11-03 15:23:55 +01:00
commit fcf45b51ac
21 changed files with 930 additions and 30 deletions

View file

@ -88,6 +88,34 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
}
}
/**
* Turning 2-step verification off, wiping it, replacing its secret or showing
* the secret all used to need only the session. Whoever held a session (a
* stolen cookie, an unlocked machine) could quietly drop the second factor or
* copy it. While it is enabled, these now need a current code or backup code,
* as login does. A used backup code is spent.
*/
private function requireCurrentCode(MainAccount $oAccount) : void
{
$aData = $this->getTwoFactorInfo($oAccount);
if (empty($aData['IsSet']) || empty($aData['Enable']) || empty($aData['Secret'])) {
return;
}
$sCode = \trim($this->jsonParam('Code', ''));
if (\strlen($sCode)) {
if ($this->TwoFactorAuthProvider($oAccount)->VerifyCode($aData['Secret'], $sCode)) {
return;
}
$aBackupCodes = \explode(' ', \trim(\preg_replace('/[^\d]+/', ' ', $aData['BackupCodes'] ?? '')));
if (6 < \strlen($sCode) && \in_array($sCode, $aBackupCodes, true)) {
$this->removeBackupCodeFromTwoFactorInfo($oAccount, $sCode);
return;
}
}
$this->Logger()->Write("TFA: change refused for {$oAccount->Email()}, no valid current code");
throw new ClientException(\RainLoop\Notifications::AuthError);
}
public function DoGetTwoFactorInfo() : array
{
$oAccount = $this->getMainAccountFromToken();
@ -107,11 +135,15 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
return $this->jsonResponse(__FUNCTION__, false);
}
// Overwrites the stored secret with Enable false, i.e. also switches it off
$this->requireCurrentCode($oAccount);
$sEmail = $oAccount->Email();
$sSecret = $this->TwoFactorAuthProvider($oAccount)->CreateSecret();
$aCodes = \array_map(function(){return \rand(100000000, 900000000);}, \array_fill(0, 8, null));
// Backup codes are a second factor; rand() is not a CSPRNG
$aCodes = \array_map(fn() => \random_int(100000000, 899999999), \array_fill(0, 8, null));
$this->StorageProvider()->Put($oAccount,
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
@ -148,6 +180,8 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
return $this->jsonResponse(__FUNCTION__, false);
}
$this->requireCurrentCode($oAccount);
$aResult = $this->getTwoFactorInfo($oAccount);
unset($aResult['BackupCodes']);
@ -164,6 +198,10 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
return $this->jsonResponse(__FUNCTION__, false);
}
if ('1' !== \trim($this->jsonParam('Enable', '0'))) {
$this->requireCurrentCode($oAccount);
}
$oActions = $this->Manager()->Actions();
if ($oActions->HasActionParam('EnableTwoFactor')) {
$sValue = $oActions->GetActionParam('EnableTwoFactor', '');
@ -215,6 +253,8 @@ class TwoFactorAuthPlugin extends \RainLoop\Plugins\AbstractPlugin
return $this->jsonResponse(__FUNCTION__, false);
}
$this->requireCurrentCode($oAccount);
$this->StorageProvider()->Clear($oAccount,
\RainLoop\Providers\Storage\Enumerations\StorageType::CONFIG,
'two_factor'

View file

@ -22,8 +22,9 @@ const
* @param {?Function} fCallback
* @param {boolean} bEnable
*/
enableTwoFactor(fCallback, bEnable) {
enableTwoFactor(fCallback, bEnable, oParams) {
rl.pluginRemoteRequest(fCallback, 'EnableTwoFactor', {
...oParams,
Enable: bEnable ? 1 : 0
});
}
@ -61,8 +62,12 @@ class TwoFactorAuthSettings
rl.settings.get('RequireTwoFactor') && rl.settings.set('SetupTwoFactor', !!iError);
}, value);
} else {
value || this.viewEnable_(value);
Remote.enableTwoFactor(fn, false);
// Turning it off needs a current code while it is on.
// Cancelled or refused, the checkbox goes back to on.
this.withCode(params => {
value || this.viewEnable_(value);
Remote.enableTwoFactor(iError => iError && this.viewEnable_(true), false, params);
}, () => this.viewEnable_.valueHasMutated());
}
}
},
@ -86,12 +91,28 @@ class TwoFactorAuthSettings
}).forEach(([key, fn]) => this[key] = ko.computed(fn));
this.onResult = this.onResult.bind(this);
this.onChangeResult = this.onChangeResult.bind(this);
this.onShowSecretResult = this.onShowSecretResult.bind(this);
}
/**
* While 2-step verification is on, the server wants a current code (or a
* backup code) to turn it off, clear it, replace it or show its secret.
*/
withCode(fDo, fCancel) {
if (!this.viewEnable_()) {
fDo({});
} else {
const code = (prompt(rl.i18n('PLUGIN_2FA/LABEL_TWO_FACTOR_CODE')) || '').trim();
code ? fDo({ Code: code }) : fCancel?.();
}
}
showSecret() {
this.secreting(true);
rl.pluginRemoteRequest(this.onShowSecretResult, 'ShowTwoFactorSecret');
this.withCode(params => {
this.secreting(true);
rl.pluginRemoteRequest(this.onShowSecretResult, 'ShowTwoFactorSecret', params);
});
}
hideSecret() {
@ -101,8 +122,10 @@ class TwoFactorAuthSettings
}
createTwoFactor() {
this.processing(true);
rl.pluginRemoteRequest(this.onResult, 'CreateTwoFactorSecret');
this.withCode(params => {
this.processing(true);
rl.pluginRemoteRequest(this.onChangeResult, 'CreateTwoFactorSecret', params);
});
}
testTwoFactor() {
@ -115,12 +138,26 @@ class TwoFactorAuthSettings
}
clearTwoFactor() {
this.hideSecret();
this.withCode(params => {
this.hideSecret();
this.twoFactorTested(false);
this.twoFactorTested(false);
this.clearing(true);
rl.pluginRemoteRequest(this.onResult, 'ClearTwoFactorInfo');
this.clearing(true);
rl.pluginRemoteRequest(this.onChangeResult, 'ClearTwoFactorInfo', params);
});
}
// A refused change must not show the account as unconfigured, which is what
// onResult does with an error; reload the real state instead.
onChangeResult(iError, oData) {
if (iError) {
this.processing(false);
this.clearing(false);
this.onBuild();
} else {
this.onResult(iError, oData);
}
}
onShow() {