VYPR
Critical severityOSV Advisory· Published Apr 17, 2019· Updated Aug 4, 2024

CVE-2019-10643

CVE-2019-10643

Description

Contao 4.7 allows Use of a Key Past its Expiration Date.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
contao/contaoPackagist
>= 4.7.0, < 4.7.34.7.3
contao/core-bundlePackagist
>= 4.7.0, < 4.7.34.7.3

Affected products

1

Patches

1
70348cc812b1

Invalidate old opt-in tokens when a token is confirmed (see CVE-2019-10643)

https://github.com/contao/contaoLeo FeyerApr 9, 2019via ghsa
14 files changed · +353 34
  • CHANGELOG.md+1 0 modified
    @@ -2,6 +2,7 @@
     
     ## DEV
     
    + * Invalidate old opt-in tokens when a token is confirmed (see CVE-2019-10643).
      * Invalidate the user sessions if a password changes (see CVE-2019-10641).
      * Correctly check if a file or folder is excluded from synchronization (see 410).
     
    
  • comments-bundle/src/Resources/contao/classes/Comments.php+18 4 modified
    @@ -584,7 +584,7 @@ public static function addCommentsSubscription(CommentsModel $objComment)
     
     		/** @var OptIn $optIn */
     		$optIn = System::getContainer()->get('contao.opt-in');
    -		$optInToken = $optIn->create('com-', $objComment->email, array('tl_comments_notify'=>array($objNotify->id)));
    +		$optInToken = $optIn->create('com', $objComment->email, array('tl_comments_notify'=>array($objNotify->id)));
     
     		// Send the token
     		$optInToken->send(sprintf($GLOBALS['TL_LANG']['MSC']['com_optInSubject'], Idna::decode(Environment::get('host'))), sprintf($GLOBALS['TL_LANG']['MSC']['com_optInMessage'], $objComment->name, $strUrl, $strUrl . $strConnector . 'token=' . $optInToken->getIdentifier(), $strUrl . $strConnector . 'token=' . $objNotify->tokenRemove));
    @@ -603,9 +603,23 @@ public static function changeSubscriptionStatus(FrontendTemplate $objTemplate)
     			$optIn = System::getContainer()->get('contao.opt-in');
     
     			// Find an unconfirmed token with only one related record
    -			if ((!$optInToken = $optIn->find(Input::get('token'))) || $optInToken->isConfirmed() || \count($arrRelated = $optInToken->getRelatedRecords()) != 1 || key($arrRelated) != 'tl_comments_notify' || \count($arrIds = current($arrRelated)) != 1 || (!$objNotify = CommentsNotifyModel::findByPk($arrIds[0])))
    +			if ((!$optInToken = $optIn->find(Input::get('token'))) || !$optInToken->isValid() || \count($arrRelated = $optInToken->getRelatedRecords()) != 1 || key($arrRelated) != 'tl_comments_notify' || \count($arrIds = current($arrRelated)) != 1 || (!$objNotify = CommentsNotifyModel::findByPk($arrIds[0])))
     			{
    -				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['invalidTokenUrl'];
    +				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['invalidToken'];
    +
    +				return;
    +			}
    +
    +			if ($optInToken->isConfirmed())
    +			{
    +				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['tokenConfirmed'];
    +
    +				return;
    +			}
    +
    +			if ($optInToken->getEmail() != $objNotify->email)
    +			{
    +				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['tokenEmailMismatch'];
     
     				return;
     			}
    @@ -623,7 +637,7 @@ public static function changeSubscriptionStatus(FrontendTemplate $objTemplate)
     
     			if ($objNotify === null)
     			{
    -				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['invalidTokenUrl'];
    +				$objTemplate->confirm = $GLOBALS['TL_LANG']['MSC']['invalidToken'];
     
     				return;
     			}
    
  • core-bundle/src/OptIn/OptIn.php+5 1 modified
    @@ -33,14 +33,18 @@ public function __construct(ContaoFramework $framework)
          */
         public function create(string $prefix, string $email, array $related): OptInTokenInterface
         {
    +        if ($prefix) {
    +            $prefix = rtrim($prefix, '-');
    +        }
    +
             if (\strlen($prefix) > 6) {
                 throw new \InvalidArgumentException('The token prefix must not be longer than 6 characters');
             }
     
             $token = bin2hex(random_bytes(12));
     
             if ($prefix) {
    -            $token = $prefix.substr($token, \strlen($prefix));
    +            $token = $prefix.'-'.substr($token, \strlen($prefix) + 1);
             }
     
             /** @var OptInModel $optIn */
    
  • core-bundle/src/OptIn/OptInToken.php+39 1 modified
    @@ -55,7 +55,7 @@ public function getEmail(): string
          */
         public function isValid(): bool
         {
    -        return $this->model->createdOn > strtotime('-24 hours');
    +        return !$this->model->invalidatedThrough && $this->model->createdOn > strtotime('-24 hours');
         }
     
         /**
    @@ -75,6 +75,44 @@ public function confirm(): void
             $this->model->confirmedOn = time();
             $this->model->removeOn = strtotime('+3 years');
             $this->model->save();
    +
    +        $related = $this->model->getRelatedRecords();
    +
    +        if (empty($related)) {
    +            return;
    +        }
    +
    +        /** @var OptInModel $adapter */
    +        $adapter = $this->framework->getAdapter(OptInModel::class);
    +        $prefix = strtok($this->getIdentifier(), '-');
    +
    +        // Invalidate other tokens that relate to the same records
    +        foreach ($related as $table => $ids) {
    +            if (!$models = $adapter->findByRelatedTableAndIds($table, $ids)) {
    +                continue;
    +            }
    +
    +            foreach ($models as $model) {
    +                if (
    +                    $model->confirmedOn > 0
    +                    || $model->invalidatedThrough
    +                    || $model->token === $this->getIdentifier()
    +                    || 0 !== strncmp($model->token, $prefix.'-', \strlen($prefix) + 1)
    +                ) {
    +                    continue;
    +                }
    +
    +                $token = new OptInToken($model, $this->framework);
    +
    +                // The related records must match exactly
    +                if ($token->getRelatedRecords() !== $related) {
    +                    continue;
    +                }
    +
    +                $model->invalidatedThrough = $this->model->token;
    +                $model->save();
    +            }
    +        }
         }
     
         /**
    
  • core-bundle/src/Resources/contao/controllers/BackendConfirm.php+3 3 modified
    @@ -145,14 +145,14 @@ public function run()
     		$objTemplate->link = StringUtil::specialchars($url);
     		$objTemplate->info = $arrInfo;
     		$objTemplate->labels = $GLOBALS['TL_LANG']['CONFIRM'];
    -		$objTemplate->explain = $GLOBALS['TL_LANG']['ERR']['invalidTokenUrl'];
    +		$objTemplate->explain = $GLOBALS['TL_LANG']['MSC']['invalidTokenUrl'];
     		$objTemplate->cancel = $GLOBALS['TL_LANG']['MSC']['cancelBT'];
     		$objTemplate->continue = $GLOBALS['TL_LANG']['MSC']['continue'];
     		$objTemplate->theme = Backend::getTheme();
     		$objTemplate->base = Environment::get('base');
     		$objTemplate->language = $GLOBALS['TL_LANGUAGE'];
    -		$objTemplate->h1 = $GLOBALS['TL_LANG']['MSC']['invalidTokenUrl'];
    -		$objTemplate->title = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['invalidTokenUrl']);
    +		$objTemplate->h1 = $GLOBALS['TL_LANG']['MSC']['invalidToken'];
    +		$objTemplate->title = StringUtil::specialchars($GLOBALS['TL_LANG']['MSC']['invalidToken']);
     		$objTemplate->host = Environment::get('host');
     		$objTemplate->charset = Config::get('characterSet');
     
    
  • core-bundle/src/Resources/contao/dca/tl_opt_in.php+7 1 modified
    @@ -110,6 +110,12 @@
     			'eval'                    => array('rgxp'=>'datim'),
     			'sql'                     => "int(10) unsigned NOT NULL default '0'"
     		),
    +		'invalidatedThrough' => array
    +		(
    +			'label'                   => &$GLOBALS['TL_LANG']['tl_opt_in']['invalidatedThrough'],
    +			'search'                  => true,
    +			'sql'                     => "varchar(24) NOT NULL default ''"
    +		),
     		'email' => array
     		(
     			'label'                   => &$GLOBALS['TL_LANG']['MSC']['emailAddress'],
    @@ -201,6 +207,6 @@ public function resendToken(Contao\DataContainer $dc)
     	 */
     	public function resendButton($row, $href, $label, $title, $icon, $attributes)
     	{
    -		return (!$row['confirmedOn'] && $row['emailSubject'] && $row['emailText'] && $row['createdOn'] > strtotime('-24 hours')) ? '<a href="'.$this->addToUrl($href.'&amp;id='.$row['id']).'" title="'.Contao\StringUtil::specialchars($title).'"'.$attributes.'>'.Contao\Image::getHtml($icon, $label).'</a> ' : '';
    +		return (!$row['confirmedOn'] &&!$row['invalidatedThrough'] && $row['emailSubject'] && $row['emailText'] && $row['createdOn'] > strtotime('-24 hours')) ? '<a href="'.$this->addToUrl($href.'&amp;id='.$row['id']).'" title="'.Contao\StringUtil::specialchars($title).'"'.$attributes.'>'.Contao\Image::getHtml($icon, $label).'</a> ' : '';
     	}
     }
    
  • core-bundle/src/Resources/contao/languages/en/default.xlf+10 4 modified
    @@ -188,9 +188,6 @@
           <trans-unit id="ERR.topLevelRegular">
             <source>There are top-level pages in the site structure which are not website root pages.</source>
           </trans-unit>
    -      <trans-unit id="ERR.invalidTokenUrl">
    -        <source>The link you were trying to open could not be verified. If you have clicked the link yourself or have received it by a trustworthy person, you can confirm the process below.</source>
    -      </trans-unit>
           <trans-unit id="ERR.form">
             <source>The form could not be sent</source>
           </trans-unit>
    @@ -1736,12 +1733,21 @@
           <trans-unit id="MSC.relevance">
             <source>%s relevance</source>
           </trans-unit>
    -      <trans-unit id="MSC.invalidTokenUrl">
    +      <trans-unit id="MSC.invalidToken">
             <source>Invalid token</source>
           </trans-unit>
    +      <trans-unit id="MSC.tokenConfirmed">
    +        <source>This token has already been confirmed</source>
    +      </trans-unit>
    +      <trans-unit id="MSC.tokenEmailMismatch">
    +        <source>The e-mail address of the token does not match</source>
    +      </trans-unit>
           <trans-unit id="MSC.resendToken">
             <source>The token has been re-sent to %s.</source>
           </trans-unit>
    +      <trans-unit id="MSC.invalidTokenUrl">
    +        <source>The link you were trying to open could not be verified. If you have clicked the link yourself or have received it by a trustworthy person, you can confirm the process below.</source>
    +      </trans-unit>
           <trans-unit id="MSC.versionConflict">
             <source>Version conflict</source>
           </trans-unit>
    
  • core-bundle/src/Resources/contao/languages/en/tl_opt_in.xlf+3 0 modified
    @@ -11,6 +11,9 @@
           <trans-unit id="tl_opt_in.removeOn">
             <source>Remove on</source>
           </trans-unit>
    +      <trans-unit id="tl_opt_in.invalidatedThrough">
    +        <source>Invalidated through</source>
    +      </trans-unit>
           <trans-unit id="tl_opt_in.emailSubject">
             <source>E-mail subject</source>
           </trans-unit>
    
  • core-bundle/src/Resources/contao/models/OptInModel.php+52 2 modified
    @@ -22,6 +22,7 @@
      * @property integer $createdOn
      * @property integer $confirmedOn
      * @property integer $removeOn
    + * @property string  $invalidatedThrough
      * @property string  $email
      * @property string  $emailSubject
      * @property string  $emailText
    @@ -35,6 +36,7 @@
      * @method static OptInModel|null findOneByCreatedOn($val, array $opt=array())
      * @method static OptInModel|null findOneByConfirmedOn($val, array $opt=array())
      * @method static OptInModel|null findOneByRemoveOn($val, array $opt=array())
    + * @method static OptInModel|null findOneByInvalidatedThrough($val, array $opt=array())
      * @method static OptInModel|null findOneByEmail($val, array $opt=array())
      * @method static OptInModel|null findOneByEmailSubject($val, array $opt=array())
      * @method static OptInModel|null findOneByEmailText($val, array $opt=array())
    @@ -44,6 +46,7 @@
      * @method static Collection|OptInModel[]|OptInModel|null findByCreatedOn($val, array $opt=array())
      * @method static Collection|OptInModel[]|OptInModel|null findByConfirmedOn($val, array $opt=array())
      * @method static Collection|OptInModel[]|OptInModel|null findByRemoveOn($val, array $opt=array())
    + * @method static Collection|OptInModel[]|OptInModel|null findByInvalidatedThrough($val, array $opt=array())
      * @method static Collection|OptInModel[]|OptInModel|null findByEmail($val, array $opt=array())
      * @method static Collection|OptInModel[]|OptInModel|null findByEmailSubject($val, array $opt=array())
      * @method static Collection|OptInModel[]|OptInModel|null findByEmailText($val, array $opt=array())
    @@ -57,6 +60,7 @@
      * @method static integer countByCreatedOn($val, array $opt=array())
      * @method static integer countByConfirmedOn($val, array $opt=array())
      * @method static integer countByRemoveOn($val, array $opt=array())
    + * @method static integer countByInvalidatedThrough($val, array $opt=array())
      * @method static integer countByEmail($val, array $opt=array())
      * @method static integer countByEmailSubject($val, array $opt=array())
      * @method static integer countByEmailText($val, array $opt=array())
    @@ -75,9 +79,9 @@ class OptInModel extends Model
     	/**
     	 * Find expired double opt-in tokens
     	 *
    -	 * @param array $arrOptions An optional options array
    +	 * @param array $arrOptions
     	 *
    -	 * @return Collection|OptInModel[]|OptInModel|null A collection of models or null if there are no expired tokens
    +	 * @return Collection|OptInModel[]|OptInModel|null
     	 */
     	public static function findExpiredTokens(array $arrOptions=array())
     	{
    @@ -94,9 +98,14 @@ public static function findExpiredTokens(array $arrOptions=array())
     	 * @param array   $arrOptions
     	 *
     	 * @return static|null
    +	 *
    +	 * @deprecated Deprecated since Contao 4.7, to be removed in Contao 5.0; use the
    +	 *             Contao\OptInModel::findByRelatedTableAndIds() method instead
     	 */
     	public static function findOneByRelatedTableAndId($strTable, $intId, array $arrOptions=array())
     	{
    +		@trigger_error('Using the Contao\OptInModel::findOneByRelatedTableAndIds() method has been deprecated and will no longer work in Contao 5.0. Use the Contao\OptInModel::findByRelatedTableAndIds() method instead.', E_USER_DEPRECATED);
    +
     		$t = static::$strTable;
     		$objDatabase = Database::getInstance();
     
    @@ -119,6 +128,47 @@ public static function findOneByRelatedTableAndId($strTable, $intId, array $arrO
     		return new static($objResult);
     	}
     
    +	/**
    +	 * Find opt-in tokens by their related table and ID
    +	 *
    +	 * @param string $strTable
    +	 * @param array  $arrIds
    +	 * @param array  $arrOptions
    +	 *
    +	 * @return Collection|OptInModel[]|OptInModel|null
    +	 */
    +	public static function findByRelatedTableAndIds($strTable, array $arrIds, array $arrOptions=array())
    +	{
    +		$t = static::$strTable;
    +		$objDatabase = Database::getInstance();
    +
    +		$objResult = $objDatabase->prepare("SELECT * FROM $t WHERE $t.id IN (SELECT pid FROM tl_opt_in_related WHERE relTable=? AND relId IN(" . implode(',', array_map('\intval', $arrIds)) . ")) ORDER BY $t.createdOn DESC")
    +								 ->execute($strTable, $arrIds);
    +
    +		if ($objResult->numRows < 1)
    +		{
    +			return null;
    +		}
    +
    +		$arrModels = array();
    +		$objRegistry = Registry::getInstance();
    +
    +		while ($objResult->next())
    +		{
    +			/** @var OptInModel|Model $objOptIn */
    +			if ($objOptIn = $objRegistry->fetch($t, $objResult->id))
    +			{
    +				$arrModels[] = $objOptIn;
    +			}
    +			else
    +			{
    +				$arrModels[] = new static($objResult->row());
    +			}
    +		}
    +
    +		return static::createCollection($arrModels, $t);
    +	}
    +
     	/**
     	 * Delete the related records if the model is deleted
     	 *
    
  • core-bundle/src/Resources/contao/modules/ModulePassword.php+25 3 modified
    @@ -177,13 +177,35 @@ protected function setNewPassword()
     		$optIn = System::getContainer()->get('contao.opt-in');
     
     		// Find an unconfirmed token with only one related record
    -		if ((!$optInToken = $optIn->find(Input::get('token'))) || $optInToken->isConfirmed() || \count($arrRelated = $optInToken->getRelatedRecords()) != 1 || key($arrRelated) != 'tl_member' || \count($arrIds = current($arrRelated)) != 1 || (!$objMember = MemberModel::findByPk($arrIds[0])))
    +		if ((!$optInToken = $optIn->find(Input::get('token'))) || !$optInToken->isValid() || \count($arrRelated = $optInToken->getRelatedRecords()) != 1 || key($arrRelated) != 'tl_member' || \count($arrIds = current($arrRelated)) != 1 || (!$objMember = MemberModel::findByPk($arrIds[0])))
     		{
     			$this->strTemplate = 'mod_message';
     
     			$this->Template = new FrontendTemplate($this->strTemplate);
     			$this->Template->type = 'error';
    -			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['accountError'];
    +			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['invalidToken'];
    +
    +			return;
    +		}
    +
    +		if ($optInToken->isConfirmed())
    +		{
    +			$this->strTemplate = 'mod_message';
    +
    +			$this->Template = new FrontendTemplate($this->strTemplate);
    +			$this->Template->type = 'error';
    +			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['tokenConfirmed'];
    +
    +			return;
    +		}
    +
    +		if ($optInToken->getEmail() != $objMember->email)
    +		{
    +			$this->strTemplate = 'mod_message';
    +
    +			$this->Template = new FrontendTemplate($this->strTemplate);
    +			$this->Template->type = 'error';
    +			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['tokenEmailMismatch'];
     
     			return;
     		}
    @@ -287,7 +309,7 @@ protected function sendPasswordLink($objMember)
     	{
     		/** @var OptIn $optIn */
     		$optIn = System::getContainer()->get('contao.opt-in');
    -		$optInToken = $optIn->create('pw-', $objMember->email, array('tl_member'=>array($objMember->id)));
    +		$optInToken = $optIn->create('pw', $objMember->email, array('tl_member'=>array($objMember->id)));
     
     		// Prepare the simple token data
     		$arrData = $objMember->row();
    
  • core-bundle/src/Resources/contao/modules/ModuleRegistration.php+32 5 modified
    @@ -464,7 +464,7 @@ protected function sendActivationMail($arrData)
     	{
     		/** @var OptIn $optIn */
     		$optIn = System::getContainer()->get('contao.opt-in');
    -		$optInToken = $optIn->create('reg-', $arrData['email'], array('tl_member'=>array($arrData['id'])));
    +		$optInToken = $optIn->create('reg', $arrData['email'], array('tl_member'=>array($arrData['id'])));
     
     		// Prepare the simple token data
     		$arrTokenData = $arrData;
    @@ -521,10 +521,26 @@ protected function activateAcount()
     		$optIn = System::getContainer()->get('contao.opt-in');
     
     		// Find an unconfirmed token with only one related record
    -		if ((!$optInToken = $optIn->find(Input::get('token'))) || $optInToken->isConfirmed() || \count($arrRelated = $optInToken->getRelatedRecords()) != 1 || key($arrRelated) != 'tl_member' || \count($arrIds = current($arrRelated)) != 1 || (!$objMember = MemberModel::findByPk($arrIds[0])))
    +		if ((!$optInToken = $optIn->find(Input::get('token'))) || !$optInToken->isValid() || \count($arrRelated = $optInToken->getRelatedRecords()) != 1 || key($arrRelated) != 'tl_member' || \count($arrIds = current($arrRelated)) != 1 || (!$objMember = MemberModel::findByPk($arrIds[0])))
     		{
     			$this->Template->type = 'error';
    -			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['accountError'];
    +			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['invalidToken'];
    +
    +			return;
    +		}
    +
    +		if ($optInToken->isConfirmed())
    +		{
    +			$this->Template->type = 'error';
    +			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['tokenConfirmed'];
    +
    +			return;
    +		}
    +
    +		if ($optInToken->getEmail() != $objMember->email)
    +		{
    +			$this->Template->type = 'error';
    +			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['tokenEmailMismatch'];
     
     			return;
     		}
    @@ -576,9 +592,20 @@ protected function resendActivationMail(MemberModel $objMember)
     
     		/** @var OptIn $optIn */
     		$optIn = System::getContainer()->get('contao.opt-in');
    +		$optInToken = null;
    +		$models = OptInModel::findByRelatedTableAndIds('tl_member', array($objMember->id));
    +
    +		foreach ($models as $model)
    +		{
    +			// Look for a valid, unconfirmed token
    +			if (($token = $optIn->find($model->token)) && $token->isValid() && !$token->isConfirmed())
    +			{
    +				$optInToken = $token;
    +				break;
    +			}
    +		}
     
    -		/** @var OptInModel $model */
    -		if ((!$model = OptInModel::findOneByRelatedTableAndId('tl_member', $objMember->id)) || (!$optInToken = $optIn->find($model->token)))
    +		if ($optInToken === null)
     		{
     			return;
     		}
    
  • core-bundle/tests/OptIn/OptInTest.php+2 2 modified
    @@ -43,7 +43,7 @@ public function testCreatesAToken(): void
                 ->willReturn($model)
             ;
     
    -        $token = (new OptIn($framework))->create('reg-', 'foo@bar.com', ['tl_member' => 1]);
    +        $token = (new OptIn($framework))->create('reg', 'foo@bar.com', ['tl_member' => 1]);
     
             $this->assertStringMatchesFormat('reg-%x', $token->getIdentifier());
             $this->assertTrue($token->isValid());
    @@ -62,7 +62,7 @@ public function testDoesNotCreateATokenIfThePrefixIsTooLong(): void
             $this->expectException('InvalidArgumentException');
             $this->expectExceptionMessage('The token prefix must not be longer than 6 characters');
     
    -        (new OptIn($framework))->create('registration-', 'foo@bar.com', ['tl_member' => 1]);
    +        (new OptIn($framework))->create('registration', 'foo@bar.com', ['tl_member' => 1]);
         }
     
         public function testFindsAToken(): void
    
  • core-bundle/tests/OptIn/OptInTokenTest.php+121 0 modified
    @@ -42,6 +42,15 @@ public function testReturnsTheEmailAddress(): void
             $this->assertSame('foo@bar.com', $token->getEmail());
         }
     
    +    public function testInvalidatesAToken(): void
    +    {
    +        /** @var OptInModel|MockObject $model */
    +        $model = $this->mockClassWithGetterSetter(OptInModel::class, ['invalidatedThrough' => 'foo']);
    +        $token = $this->getToken($model);
    +
    +        $this->assertFalse($token->isValid());
    +    }
    +
         public function testConfirmsAToken(): void
         {
             $properties = [
    @@ -51,13 +60,122 @@ public function testConfirmsAToken(): void
     
             /** @var OptInModel|MockObject $model */
             $model = $this->mockClassWithGetterSetter(OptInModel::class, $properties);
    +        $model
    +            ->expects($this->once())
    +            ->method('getRelatedRecords')
    +            ->willReturn([])
    +        ;
     
             $token = $this->getToken($model);
             $token->confirm();
     
             $this->assertTrue($token->isConfirmed());
         }
     
    +    public function testInvalidatesRelatedTokens(): void
    +    {
    +        $properties = [
    +            'token' => 'reg-first',
    +            'createdOn' => time(),
    +            'confirmedOn' => 0,
    +        ];
    +
    +        /** @var OptInModel|MockObject $related */
    +        $related = $this->mockClassWithGetterSetter(OptInModel::class, $properties);
    +        $related
    +            ->expects($this->once())
    +            ->method('save')
    +        ;
    +
    +        $related
    +            ->expects($this->once())
    +            ->method('getRelatedRecords')
    +            ->willReturn(['tl_user' => [2]])
    +        ;
    +
    +        $adapter = $this->mockAdapter(['findByRelatedTableAndIds']);
    +        $adapter
    +            ->expects($this->once())
    +            ->method('findByRelatedTableAndIds')
    +            ->with('tl_user', [2])
    +            ->willReturn([$related])
    +        ;
    +
    +        $framework = $this->mockContaoFramework([OptInModel::class => $adapter]);
    +
    +        $properties = [
    +            'token' => 'reg-second',
    +            'createdOn' => time(),
    +            'confirmedOn' => 0,
    +        ];
    +
    +        /** @var OptInModel|MockObject $model */
    +        $model = $this->mockClassWithGetterSetter(OptInModel::class, $properties);
    +        $model
    +            ->expects($this->once())
    +            ->method('getRelatedRecords')
    +            ->willReturn(['tl_user' => [2]])
    +        ;
    +
    +        $token = $this->getToken($model, $framework);
    +        $token->confirm();
    +
    +        $this->assertTrue($token->isConfirmed());
    +        $this->assertSame('reg-second', $related->invalidatedThrough);
    +    }
    +
    +    public function testDoesNotInvalidateRelatedTokensIfTheRelatedRecordsDoNotMatch(): void
    +    {
    +        $properties = [
    +            'token' => 'reg-first',
    +            'createdOn' => time(),
    +            'confirmedOn' => 0,
    +        ];
    +
    +        /** @var OptInModel|MockObject $related */
    +        $related = $this->mockClassWithGetterSetter(OptInModel::class, $properties);
    +        $related
    +            ->expects($this->never())
    +            ->method('save')
    +        ;
    +
    +        $related
    +            ->expects($this->once())
    +            ->method('getRelatedRecords')
    +            ->willReturn(['tl_user' => [2, 3]])
    +        ;
    +
    +        $adapter = $this->mockAdapter(['findByRelatedTableAndIds']);
    +        $adapter
    +            ->expects($this->once())
    +            ->method('findByRelatedTableAndIds')
    +            ->with('tl_user', [2])
    +            ->willReturn([$related])
    +        ;
    +
    +        $framework = $this->mockContaoFramework([OptInModel::class => $adapter]);
    +
    +        $properties = [
    +            'token' => 'reg-second',
    +            'createdOn' => time(),
    +            'confirmedOn' => 0,
    +        ];
    +
    +        /** @var OptInModel|MockObject $model */
    +        $model = $this->mockClassWithGetterSetter(OptInModel::class, $properties);
    +        $model
    +            ->expects($this->once())
    +            ->method('getRelatedRecords')
    +            ->willReturn(['tl_user' => [2]])
    +        ;
    +
    +        $token = $this->getToken($model, $framework);
    +        $token->confirm();
    +
    +        $this->assertTrue($token->isConfirmed());
    +        $this->assertNull($related->invalidatedThrough);
    +    }
    +
         public function testDoesNotConfirmAConfirmedToken(): void
         {
             $properties = [
    @@ -220,6 +338,9 @@ public function testDoesNotRequireSubjectAndTextToResendToken(): void
             $this->assertTrue($token->hasBeenSent());
         }
     
    +    /**
    +     * @param ContaoFramework|MockObject|null $framework
    +     */
         private function getToken(OptInModel $model, ContaoFramework $framework = null): OptInTokenInterface
         {
             if (null === $framework) {
    
  • newsletter-bundle/src/Resources/contao/modules/ModuleSubscribe.php+35 8 modified
    @@ -169,26 +169,53 @@ protected function activateRecipient()
     		$optIn = System::getContainer()->get('contao.opt-in');
     
     		// Find an unconfirmed token
    -		if ((!$optInToken = $optIn->find(Input::get('token'))) || $optInToken->isConfirmed() || \count($arrRelated = $optInToken->getRelatedRecords()) < 1 || key($arrRelated) != 'tl_newsletter_recipients' || \count($arrIds = current($arrRelated)) < 1)
    +		if ((!$optInToken = $optIn->find(Input::get('token'))) || !$optInToken->isValid() || \count($arrRelated = $optInToken->getRelatedRecords()) < 1 || key($arrRelated) != 'tl_newsletter_recipients' || \count($arrIds = current($arrRelated)) < 1)
     		{
     			$this->Template->type = 'error';
    -			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['accountError'];
    +			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['invalidToken'];
     
     			return;
     		}
     
    -		$time = time();
    -		$arrAdd = array();
    -		$arrCids = array();
    +		if ($optInToken->isConfirmed())
    +		{
    +			$this->Template->type = 'error';
    +			$this->Template->message = $GLOBALS['TL_LANG']['MSC']['tokenConfirmed'];
     
    -		// Update the subscriptions
    +			return;
    +		}
    +
    +		$arrRecipients = array();
    +
    +		// Validate the token
     		foreach ($arrIds as $intId)
     		{
     			if (!$objRecipient = NewsletterRecipientsModel::findByPk($intId))
     			{
    -				continue;
    +				$this->Template->type = 'error';
    +				$this->Template->message = $GLOBALS['TL_LANG']['MSC']['invalidToken'];
    +
    +				return;
     			}
     
    +			if ($optInToken->getEmail() != $objRecipient->email)
    +			{
    +				$this->Template->type = 'error';
    +				$this->Template->message = $GLOBALS['TL_LANG']['MSC']['tokenEmailMismatch'];
    +
    +				return;
    +			}
    +
    +			$arrRecipients[] = $objRecipient;
    +		}
    +
    +		$time = time();
    +		$arrAdd = array();
    +		$arrCids = array();
    +
    +		// Activate the subscriptions
    +		foreach ($arrRecipients as $objRecipient)
    +		{
     			$arrAdd[] = $objRecipient->id;
     			$arrCids[] = $objRecipient->pid;
     
    @@ -333,7 +360,7 @@ protected function addRecipient($strEmail, $arrNew)
     
     		/** @var OptIn $optIn */
     		$optIn = System::getContainer()->get('contao.opt-in');
    -		$optInToken = $optIn->create('nl-', $strEmail, $arrRelated);
    +		$optInToken = $optIn->create('nl', $strEmail, $arrRelated);
     
     		// Get the channels
     		$objChannel = NewsletterChannelModel::findByIds($arrNew);
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.