VYPR
High severity7.5NVD Advisory· Published Apr 21, 2026· Updated May 5, 2026

CVE-2026-6553

CVE-2026-6553

Description

Changing backend users' passwords via the user settings module results in storing the cleartext password in the uc and user_settings fields of the be_users database table. This issue affects TYPO3 CMS version 14.2.0.

Affected products

2
  • TYPO3/Typo3references2 versions
    (expand)+ 1 more
    • (no CPE)
    • cpe:2.3:a:typo3:typo3:14.2.0:*:*:*:*:*:*:*

Patches

1
9a6e913f7076

[SECURITY] Do not store password in serialized user settings

https://github.com/TYPO3/typo3Garvin HickingApr 20, 2026via ghsa
6 files changed · +580 34
  • typo3/sysext/backend/Classes/Controller/SetupModuleController.php+61 22 modified
    @@ -72,6 +72,8 @@
     #[AsController]
     class SetupModuleController
     {
    +    protected const DISALLOWED_FIELD_NAMES = ['password', 'password2', 'email', 'realName', 'admin', 'avatar'];
    +
         protected const PASSWORD_NOT_UPDATED = 0;
         protected const PASSWORD_UPDATED = 1;
         protected const PASSWORD_NOT_THE_SAME = 2;
    @@ -272,46 +274,52 @@ protected function storeIncomingData(ServerRequestInterface $request): void
             }
     
             $formProtection = $this->formProtectionFactory->createFromRequest($request);
    -        // First check if something is submitted in the data-array from POST vars
    -        $d = $postData['data']['be_users_settings'][(int)$this->getBackendUser()->user['uid']] ?? null;
    +        // Separate submitted data into corresponding partitions
    +        $backendUserId = (int)$this->getBackendUser()->user['uid'];
    +        $beUsersSubmission = $this->extractPartitionData($postData['data']['be_users_settings'][$backendUserId] ?? [], 'be_users');
    +        $userSettingsSubmission = $this->extractPartitionData($postData['data']['be_users_settings'][$backendUserId] ?? [], 'user_settings');
             $columns = $this->userSettingsSchema->getColumns();
             $backendUser = $this->getBackendUser();
             $beUserId = (int)$backendUser->user['uid'];
             $storeRec = [];
             $doSaveData = false;
             $fieldList = $this->getFieldsFromShowItem();
    -        if (is_array($d) && $formProtection->validateToken((string)($postData['formToken'] ?? ''), 'BE user setup', 'edit')) {
    +
    +        if ($beUsersSubmission !== []
    +            && $userSettingsSubmission !== []
    +            && $formProtection->validateToken((string)($postData['formToken'] ?? ''), 'BE user setup', 'edit')
    +        ) {
                 // UC hashed before applying changes
                 $save_before = md5(serialize($backendUser->uc));
                 // PUT SETTINGS into the ->uc array:
                 // Reload left frame when switching BE language
    -            if (isset($d['lang']) && $d['lang'] !== $backendUser->user['lang']) {
    +            if (isset($beUsersSubmission['lang']) && $beUsersSubmission['lang'] !== $backendUser->user['lang']) {
                     $this->languageUpdate = true;
                 }
                 // Reload pagetree if the title length is changed
    -            if (isset($d['titleLen']) && $d['titleLen'] !== $backendUser->uc['titleLen']) {
    +            if (isset($userSettingsSubmission['titleLen']) && $userSettingsSubmission['titleLen'] !== $backendUser->uc['titleLen']) {
                     $this->pagetreeNeedsRefresh = true;
                 }
    -            if (isset($d['colorScheme']) && $d['colorScheme'] !== ($backendUser->uc['colorScheme'] ?? null)) {
    +            if (isset($userSettingsSubmission['colorScheme']) && $userSettingsSubmission['colorScheme'] !== ($backendUser->uc['colorScheme'] ?? null)) {
                     $this->colorSchemeChanged = true;
                 }
    -            if (isset($d['theme']) && $d['theme'] !== ($backendUser->uc['theme'] ?? null)) {
    +            if (isset($userSettingsSubmission['theme']) && $userSettingsSubmission['theme'] !== ($backendUser->uc['theme'] ?? null)) {
                     $this->themeChanged = true;
                 }
    -            if (isset($d['backendTitleFormat']) && $d['backendTitleFormat'] !== ($backendUser->uc['backendTitleFormat'] ?? null)) {
    +            if (isset($userSettingsSubmission['backendTitleFormat']) && $userSettingsSubmission['backendTitleFormat'] !== ($backendUser->uc['backendTitleFormat'] ?? null)) {
                     $this->backendTitleFormatChanged = true;
                 }
    -            if (isset($d['dateTimeFirstDayOfWeek']) && $d['dateTimeFirstDayOfWeek'] !== ($backendUser->uc['dateTimeFirstDayOfWeek'] ?? null)) {
    +            if (isset($userSettingsSubmission['dateTimeFirstDayOfWeek']) && $userSettingsSubmission['dateTimeFirstDayOfWeek'] !== ($backendUser->uc['dateTimeFirstDayOfWeek'] ?? null)) {
                     $this->dateTimeFirstDayOfWeekChanged = true;
                     $this->persistentUpdate[] = [
                         'fieldName' => 'dateTimeFirstDayOfWeek',
    -                    'value' => $d['dateTimeFirstDayOfWeek'],
    +                    'value' => $userSettingsSubmission['dateTimeFirstDayOfWeek'],
                     ];
                 }
                 // Options which should trigger direct JS persistent update, because
                 // their new state needs to be available in JS components right away.
                 foreach ($this->userSettingsSchema->getPersistentUpdateFieldNames() as $fieldName) {
    -                $fieldValue = ((int)($d[$fieldName] ?? 0)) ? 'on' : 0;
    +                $fieldValue = ((int)($userSettingsSubmission[$fieldName] ?? 0)) ? 1 : 0;
                     if ($fieldValue !== ($backendUser->uc[$fieldName] ?? null)) {
                         $this->persistentUpdate[] = [
                             'fieldName' => $fieldName,
    @@ -320,45 +328,53 @@ protected function storeIncomingData(ServerRequestInterface $request): void
                     }
                 }
     
    -            if ($d['setValuesToDefault'] ?? $postData['data']['setValuesToDefault'] ?? false) {
    +            if ($postData['data']['setValuesToDefault'] ?? false) {
                     // If every value should be default
                     $backendUser->resetUC();
                     $this->settingsAreResetToDefault = true;
    -            } elseif ($d['save'] ?? $postData['data']['save'] ?? false) {
    +            } elseif ($postData['data']['save'] ?? false) {
                     foreach ($columns as $field => $config) {
                         if (!in_array($field, $fieldList, true)) {
                             continue;
                         }
    +                    // Skip any disallowed field name, not matter if it's in be_users or user_settings partition
    +                    if (in_array($field, self::DISALLOWED_FIELD_NAMES, true)) {
    +                        continue;
    +                    }
                         $isBeUsersField = ($config['table'] ?? '') === 'be_users';
                         $fieldType = $config['type'] ?? 'text';
    -                    if ($isBeUsersField && !in_array($field, ['password', 'password2', 'email', 'realName', 'admin', 'avatar'], true)) {
    -                        $submittedValue = $d[$field] ?? null;
    +                    if ($isBeUsersField) {
    +                        $submittedValue = $beUsersSubmission[$field] ?? null;
                             if (!isset($config['access']) || ($this->checkAccess($config) && ($backendUser->user[$field] !== $submittedValue))) {
                                 if ($fieldType === 'check') {
    -                                $fieldValue = (int)($d[$field] ?? 0);
    +                                $fieldValue = (int)($submittedValue ?? 0);
                                 } else {
                                     $fieldValue = $submittedValue;
                                 }
                                 $storeRec['be_users'][$beUserId][$field] = $fieldValue;
                                 $backendUser->user[$field] = $fieldValue;
                             }
    -                    }
    -                    if ($fieldType === 'check') {
    -                        $backendUser->uc[$field] = (int)($d[$field] ?? 0);
                         } else {
    -                        $backendUser->uc[$field] = htmlspecialchars($d[$field] ?? '');
    +                        if ($fieldType === 'check') {
    +                            $backendUser->uc[$field] = (int)($userSettingsSubmission[$field] ?? 0);
    +                        } else {
    +                            $backendUser->uc[$field] = htmlspecialchars($userSettingsSubmission[$field] ?? '');
    +                        }
                         }
                     }
                     // Personal data for the users be_user-record (email, name, password...)
                     // If email and name is changed, set it in the users record:
    -                $be_user_data = $d;
    +                $be_user_data = $beUsersSubmission;
    +                // Temporarily hold `password2` for the hook to be able to adjust the password
    +                $be_user_data['password2'] = $userSettingsSubmission['password2'] ?? '';
                     // Possibility to modify the transmitted values. Useful to do transformations, like RSA password decryption
                     foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/setup/mod/index.php']['modifyUserDataBeforeSave'] ?? [] as $function) {
                         $params = ['be_user_data' => &$be_user_data];
                         GeneralUtility::callUserFunction($function, $params, $this);
                     }
                     $this->passwordIsSubmitted = (string)($be_user_data['password'] ?? '') !== '';
                     $passwordIsConfirmed = $this->passwordIsSubmitted && $be_user_data['password'] === $be_user_data['password2'];
    +                unset($be_user_data['password2']);
     
                     // Validate password against password policy
                     $contextData = new ContextData(
    @@ -408,6 +424,10 @@ protected function storeIncomingData(ServerRequestInterface $request): void
     
                     $doSaveData = true;
                 }
    +            // Explicitly unset disallowed field names
    +            foreach (self::DISALLOWED_FIELD_NAMES as $disallowedFieldName) {
    +                unset($backendUser->uc[$disallowedFieldName]);
    +            }
                 // Inserts the overriding values.
                 $backendUser->overrideUC();
                 $save_after = md5(serialize($backendUser->uc));
    @@ -472,7 +492,7 @@ protected function getFieldsFromShowItem(): array
         {
             // just keep field names, filter out control sequences
             $tcaFieldNames = array_keys($this->userSettingsSchema->getColumns());
    -        $allowedFields = GeneralUtility::trimExplode(',', $this->userSettingsSchema->getTcaShowitem(), true);
    +        $allowedFields = GeneralUtility::trimExplode(',', $this->userSettingsSchema->getRawShowitem(), true);
             $allowedFields = array_map($this->extractShowitemFieldName(...), $allowedFields);
             $allowedFields = array_filter($allowedFields, static fn(string $field): bool => in_array($field, $tcaFieldNames, true));
             $backendUser = $this->getBackendUser();
    @@ -661,6 +681,25 @@ protected function addFlashMessages(ModuleTemplate $view): void
             }
         }
     
    +    /**
    +     * @param array<string, scalar> $data
    +     * @return array<string, scalar>
    +     */
    +    protected function extractPartitionData(array $data, string $partition): array
    +    {
    +        $partitionData = [];
    +        $prefix = $partition . '__';
    +        $length = strlen($prefix);
    +        foreach ($data as $key => $value) {
    +            if (!str_starts_with($key, $prefix)) {
    +                continue;
    +            }
    +            $normalizedKey = substr($key, $length);
    +            $partitionData[$normalizedKey] = $value;
    +        }
    +        return $partitionData;
    +    }
    +
         protected function getBackendUser(): BackendUserAuthentication
         {
             return $GLOBALS['BE_USER'];
    
  • typo3/sysext/backend/Classes/Form/FormDataProvider/UserSettingsDatabaseEditRow.php+17 10 modified
    @@ -34,6 +34,8 @@
      */
     readonly class UserSettingsDatabaseEditRow implements FormDataProviderInterface
     {
    +    public function __construct(private UserSettingsSchema $userSettingsSchema) {}
    +
         public function addData(array $result): array
         {
             if ($result['command'] !== 'edit' || $result['tableName'] !== 'be_users_settings') {
    @@ -43,26 +45,31 @@ public function addData(array $result): array
             $backendUser = $this->getBackendUser();
             $userSettings = $backendUser->getUserSettings()->toArray();
     
    -        // Set most rows from the current user
    -        $userSettingsSchema = GeneralUtility::makeInstance(UserSettingsSchema::class);
    +        $userSettingsColumns = $this->userSettingsSchema->getColumns();
    +        $jsonFieldSettingKeys = $this->userSettingsSchema->getJsonFieldSettingKeys();
             // Also provide direct access to be_users fields that are shown in the form
             // These are needed for fields with inheritFromParent=true
    -        foreach ($userSettingsSchema->getColumns() as $column => $config) {
    +        foreach ($userSettingsColumns as $column => $config) {
    +            $partitionedColumnName = $this->userSettingsSchema->getTcaFieldName($column);
                 if (isset($backendUser->user[$column])) {
    -                $result['databaseRow'][$column] = $backendUser->user[$column];
    +                $result['databaseRow'][$partitionedColumnName] = $backendUser->user[$column];
                 } elseif (isset($userSettings[$column])) {
    -                $result['databaseRow'][$column] = $userSettings[$column];
    +                $result['databaseRow'][$partitionedColumnName] = $userSettings[$column];
                 }
             }
             // Set the uid from the current user
             $result['databaseRow']['uid'] = (int)$backendUser->user['uid'];
             $result['databaseRow']['pid'] = 0;
    -        $result['databaseRow']['password'] = $backendUser->user['password'] ?? '';
    -        $result['databaseRow']['password2'] = $backendUser->user['password'] ?? '';
    -        $result['databaseRow']['avatar'] = $this->getAvatarFileUid((int)$backendUser->user['uid']);
    +        // Fill in random to passwords to avoid FormEngine issuing the required field error
    +        $randomPassword = bin2hex(random_bytes(20));
    +        $passwordFieldName = $this->userSettingsSchema->getTcaFieldName('password');
    +        $result['databaseRow'][$passwordFieldName] = $randomPassword;
    +        $passwordConfirmationFieldName = $this->userSettingsSchema->getTcaFieldName('password2');
    +        $result['databaseRow'][$passwordConfirmationFieldName] = $randomPassword;
    +        // Forward the avatar FAL id
    +        $avatarFieldName = $this->userSettingsSchema->getTcaFieldName('avatar');
    +        $result['databaseRow'][$avatarFieldName] = $this->getAvatarFileUid((int)$backendUser->user['uid']);
     
    -        // Load user settings from uc array
    -        $result['databaseRow']['user_settings'] = $backendUser->getUserSettings()->toArray();
             return $result;
         }
     
    
  • typo3/sysext/backend/Classes/Upgrades/UserSettingsScrubbingMigration.php+168 0 added
    @@ -0,0 +1,168 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of the TYPO3 CMS project.
    + *
    + * It is free software; you can redistribute it and/or modify it under
    + * the terms of the GNU General Public License, either version 2
    + * of the License, or any later version.
    + *
    + * For the full copyright and license information, please read the
    + * LICENSE.txt file that was distributed with this source code.
    + *
    + * The TYPO3 project - inspiring people to share!
    + */
    +
    +namespace TYPO3\CMS\Backend\Upgrades;
    +
    +use Doctrine\DBAL\Result;
    +use Doctrine\DBAL\Types\Type;
    +use Doctrine\DBAL\Types\Types;
    +use TYPO3\CMS\Core\Attribute\UpgradeWizard;
    +use TYPO3\CMS\Core\Database\Connection;
    +use TYPO3\CMS\Core\Database\ConnectionPool;
    +use TYPO3\CMS\Core\Upgrades\DatabaseUpdatedPrerequisite;
    +use TYPO3\CMS\Core\Upgrades\UpgradeWizardInterface;
    +
    +/**
    + * Removes confidential data "password" and "password2" from user settings.
    + *
    + * @since 14.3
    + * @internal This class is only meant to be used within EXT:backend and is not part of the TYPO3 Core API.
    + */
    +#[UpgradeWizard('setup_userSettingsScrubbingMigration')]
    +final readonly class UserSettingsScrubbingMigration implements UpgradeWizardInterface
    +{
    +    public function __construct(
    +        private ConnectionPool $connectionPool,
    +    ) {}
    +
    +    public function getTitle(): string
    +    {
    +        return 'Scrub user settings to remove confidential credential data from serialized data (uc and user_settings)';
    +    }
    +
    +    public function getDescription(): string
    +    {
    +        return 'Evaluates all uc and user_settings profile data in the "be_users" database table, removes "password" and "password2" field contents.';
    +    }
    +
    +    public function getPrerequisites(): array
    +    {
    +        return [DatabaseUpdatedPrerequisite::class];
    +    }
    +
    +    public function updateNecessary(): bool
    +    {
    +        return $this->hasRecordsToMigrate();
    +    }
    +
    +    public function executeUpdate(): bool
    +    {
    +        $connection = $this->connectionPool->getConnectionForTable('be_users');
    +        $hasFailures = false;
    +
    +        $result = $this->getRecordsToMigrate();
    +        while ($record = $result->fetchAssociative()) {
    +            try {
    +                // Redact "user_settings" (may fall back to "uc" for data)
    +                $userSettings = $record['user_settings'];
    +                if (!is_string($userSettings)) {
    +                    continue;
    +                }
    +                $userSettings = json_decode(json: $userSettings, flags: JSON_THROW_ON_ERROR | JSON_OBJECT_AS_ARRAY);
    +                if (is_string($userSettings)) {
    +                    // https://review.typo3.org/c/Packages/TYPO3.CMS/+/93395 introduced a double `json_encode()` issue,
    +                    // which is resolved here as well. The fallback remains in place in case the generic cleanup upgrade
    +                    // wizard from https://review.typo3.org/c/Packages/TYPO3.CMS/+/93726 has not been executed yet,
    +                    // ensuring affected records are cleaned up during this step.
    +                    $userSettings = json_decode(json: $userSettings, flags: JSON_THROW_ON_ERROR | JSON_OBJECT_AS_ARRAY);
    +                }
    +                if (!is_array($userSettings)) {
    +                    $hasFailures = true;
    +                    continue;
    +                }
    +                // Remove invalid entries.
    +                unset($userSettings['password'], $userSettings['password2']);
    +
    +                $uc = @unserialize($record['uc'], ['allowed_classes' => false]);
    +                // Update "uc", only if unserializable - redact two keys, only if set.
    +                if (is_array($uc)) {
    +                    // Remove invalid entries.
    +                    unset($uc['password'], $uc['password2']);
    +                    $connection->update(
    +                        'be_users',
    +                        [
    +                            'uc' => serialize($uc),
    +                            // The array must be passed directly to prevent double JSON encoding.
    +                            // See: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293
    +                            'user_settings' => $userSettings,
    +                        ],
    +                        [
    +                            'uid' => (int)$record['uid'],
    +                        ],
    +                        [
    +                            'uc' => Connection::PARAM_LOB,
    +                            // @todo This behavior cannot be modified yet; the array value must be passed directly
    +                            //       until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged,
    +                            //       otherwise the value will be JSON-encoded twice.
    +                            'user_settings' => Type::getType(Types::JSON),
    +                        ],
    +                    );
    +                } else {
    +                    // No "uc" serialization, only update user_settings.
    +                    $connection->update(
    +                        'be_users',
    +                        [
    +                            'user_settings' => $userSettings,
    +                        ],
    +                        ['uid' => (int)$record['uid']],
    +                        [
    +                            // @todo This behavior cannot be modified yet; the array value must be passed directly
    +                            //       until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged,
    +                            //       otherwise the value will be JSON-encoded twice.
    +                            'user_settings' => Type::getType(Types::JSON),
    +                        ],
    +                    );
    +                }
    +            } catch (\Throwable) {
    +                $hasFailures = true;
    +            }
    +        }
    +
    +        return !$hasFailures;
    +    }
    +
    +    private function hasRecordsToMigrate(): bool
    +    {
    +        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_users');
    +        return (bool)$queryBuilder
    +            ->count('uid')
    +            ->from('be_users')
    +            ->where(
    +                $queryBuilder->expr()->or(
    +                    $queryBuilder->expr()->like('uc', $queryBuilder->createNamedParameter('%' . $queryBuilder->escapeLikeWildcards(':"password') . '%')),
    +                    $queryBuilder->expr()->like('user_settings', $queryBuilder->createNamedParameter('%' . $queryBuilder->escapeLikeWildcards('"password') . '%')),
    +                ),
    +            )
    +            ->executeQuery()
    +            ->fetchOne();
    +    }
    +
    +    private function getRecordsToMigrate(): Result
    +    {
    +        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_users');
    +        return $queryBuilder
    +            ->select('uid', 'uc', 'user_settings')
    +            ->from('be_users')
    +            ->where(
    +                $queryBuilder->expr()->or(
    +                    $queryBuilder->expr()->like('uc', $queryBuilder->createNamedParameter('%' . $queryBuilder->escapeLikeWildcards(':"password') . '%')),
    +                    $queryBuilder->expr()->like('user_settings', $queryBuilder->createNamedParameter('%' . $queryBuilder->escapeLikeWildcards('"password') . '%')),
    +                ),
    +            )
    +            ->executeQuery();
    +    }
    +}
    
  • typo3/sysext/backend/Tests/Functional/Upgrades/UserSettingsScrubbingMigrationTest.php+251 0 added
    @@ -0,0 +1,251 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of the TYPO3 CMS project.
    + *
    + * It is free software; you can redistribute it and/or modify it under
    + * the terms of the GNU General Public License, either version 2
    + * of the License, or any later version.
    + *
    + * For the full copyright and license information, please read the
    + * LICENSE.txt file that was distributed with this source code.
    + *
    + * The TYPO3 project - inspiring people to share!
    + */
    +
    +namespace TYPO3\CMS\Backend\Tests\Functional\Upgrades;
    +
    +use Doctrine\DBAL\Types\Type;
    +use Doctrine\DBAL\Types\Types;
    +use PHPUnit\Framework\Attributes\Test;
    +use TYPO3\CMS\Backend\Upgrades\UserSettingsScrubbingMigration;
    +use TYPO3\CMS\Core\Database\Connection;
    +use TYPO3\CMS\Core\Database\ConnectionPool;
    +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
    +
    +final class UserSettingsScrubbingMigrationTest extends FunctionalTestCase
    +{
    +    #[Test]
    +    public function updateNecessaryReturnsTrueWhenDoubleJsonEncodedUserSettingsWithInvalidFieldsExists(): void
    +    {
    +        $connection = $this->get(ConnectionPool::class)->getConnectionForTable('be_users');
    +
    +        // Create a user with uc but without user_settings
    +        $connection->insert(
    +            'be_users',
    +            [
    +                'username' => 'testuser',
    +                'uc' => serialize(['colorScheme' => 'dark', 'titleLen' => 50, 'password' => 'some-plain-password']),
    +                // The array would normally be passed directly to prevent double JSON encoding;
    +                // however, in this case the desired test context requires it to be intentionally
    +                // JSON-encoded here.
    +                // See: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293
    +                'user_settings' => json_encode(['colorScheme' => 'dark', 'titleLen' => 50, 'password' => 'some-plain-password']),
    +            ],
    +            [
    +                'uc' => Connection::PARAM_LOB,
    +                // @todo This behavior cannot be modified yet; the array value must be passed directly
    +                //       until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged,
    +                //       otherwise the value will be JSON-encoded twice.
    +                'user_settings' => Type::getType(Types::JSON),
    +            ],
    +        );
    +
    +        $subject = $this->get(UserSettingsScrubbingMigration::class);
    +        self::assertTrue($subject->updateNecessary());
    +    }
    +
    +    #[Test]
    +    public function updateNecessaryReturnsTrueWhenSingleJsonEncodedUserSettingsWithInvalidFieldsExists(): void
    +    {
    +        $connection = $this->get(ConnectionPool::class)->getConnectionForTable('be_users');
    +
    +        // Create a user with uc but without user_settings
    +        $connection->insert(
    +            'be_users',
    +            [
    +                'username' => 'testuser',
    +                'uc' => serialize(['colorScheme' => 'dark', 'titleLen' => 50, 'password' => 'some-plain-password']),
    +                // The array must be passed directly to prevent double JSON encoding.
    +                // See: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293
    +                'user_settings' => ['colorScheme' => 'dark', 'titleLen' => 50, 'password' => 'some-plain-password'],
    +            ],
    +            [
    +                'uc' => Connection::PARAM_LOB,
    +                // @todo This behavior cannot be modified yet; the array value must be passed directly
    +                //       until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged,
    +                //       otherwise the value will be JSON-encoded twice.
    +                'user_settings' => Type::getType(Types::JSON),
    +            ],
    +        );
    +
    +        $subject = $this->get(UserSettingsScrubbingMigration::class);
    +        self::assertTrue($subject->updateNecessary());
    +    }
    +
    +    #[Test]
    +    public function updateNecessaryReturnsFalseWhenDoubleJsonEncodedUserSettingsWithoutInvalidFieldsExists(): void
    +    {
    +        $connection = $this->get(ConnectionPool::class)->getConnectionForTable('be_users');
    +
    +        // Create a user with uc but without user_settings
    +        $connection->insert(
    +            'be_users',
    +            [
    +                'username' => 'testuser',
    +                'uc' => serialize(['colorScheme' => 'dark', 'titleLen' => 50]),
    +                // The array would normally be passed directly to prevent double JSON encoding;
    +                // however, in this case the desired test context requires it to be intentionally
    +                // JSON-encoded here.
    +                // See: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293
    +                'user_settings' => json_encode(['colorScheme' => 'dark', 'titleLen' => 50]),
    +            ],
    +            [
    +                'uc' => Connection::PARAM_LOB,
    +                // @todo This behavior cannot be modified yet; the array value must be passed directly
    +                //       until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged,
    +                //       otherwise the value will be JSON-encoded twice.
    +                'user_settings' => Type::getType(Types::JSON),
    +            ],
    +        );
    +
    +        $subject = $this->get(UserSettingsScrubbingMigration::class);
    +        self::assertFalse($subject->updateNecessary());
    +    }
    +
    +    #[Test]
    +    public function updateNecessaryReturnsFalseWhenSingleJsonEncodedUserSettingsWithoutInvalidFieldsExists(): void
    +    {
    +        $connection = $this->get(ConnectionPool::class)->getConnectionForTable('be_users');
    +
    +        // Create a user with uc but without user_settings
    +        $connection->insert(
    +            'be_users',
    +            [
    +                'username' => 'testuser',
    +                'uc' => serialize(['colorScheme' => 'dark', 'titleLen' => 50]),
    +                // The array must be passed directly to prevent double JSON encoding.
    +                // See: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293
    +                'user_settings' => ['colorScheme' => 'dark', 'titleLen' => 50],
    +            ],
    +            [
    +                'uc' => Connection::PARAM_LOB,
    +                // @todo This behavior cannot be modified yet; the array value must be passed directly
    +                //       until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged,
    +                //       otherwise the value will be JSON-encoded twice.
    +                'user_settings' => Type::getType(Types::JSON),
    +            ],
    +        );
    +
    +        $subject = $this->get(UserSettingsScrubbingMigration::class);
    +        self::assertFalse($subject->updateNecessary());
    +    }
    +
    +    #[Test]
    +    public function executeUpdateMigratesDoubleJsonEncodedUserSettingsRemovingInvalidValues(): void
    +    {
    +        $connection = $this->get(ConnectionPool::class)->getConnectionForTable('be_users');
    +
    +        // Create a user with uc but without user_settings
    +        $connection->insert(
    +            'be_users',
    +            [
    +                'username' => 'testuser',
    +                'uc' => serialize(['colorScheme' => 'dark', 'titleLen' => 50]),
    +                // The array would normally be passed directly to prevent double JSON encoding;
    +                // however, in this case the desired test context requires it to be intentionally
    +                // JSON-encoded here.
    +                // See: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293
    +                'user_settings' => json_encode(['colorScheme' => 'dark', 'titleLen' => 50, 'password' => 'some-plain-password']),
    +            ],
    +            [
    +                'uc' => Connection::PARAM_LOB,
    +                // @todo This behavior cannot be modified yet; the array value must be passed directly
    +                //       until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged,
    +                //       otherwise the value will be JSON-encoded twice.
    +                'user_settings' => Type::getType(Types::JSON),
    +            ],
    +        );
    +        $userId = (int)$connection->lastInsertId();
    +
    +        // Check user settings is double json_encoded
    +        $row = $connection->select(['user_settings'], 'be_users', ['uid' => $userId])->fetchAssociative();
    +        self::assertNotEmpty($row['user_settings'], 'user_settings should not be empty');
    +        $rawValue = $row['user_settings'];
    +        self::assertTrue(str_starts_with($rawValue, '"{'));
    +        self::assertTrue(str_ends_with($rawValue, '}"'));
    +        $decoded = json_decode(json: $rawValue, flags: JSON_THROW_ON_ERROR | JSON_OBJECT_AS_ARRAY);
    +        self::assertIsString($decoded);
    +        $decoded = json_decode(json: $decoded, flags: JSON_THROW_ON_ERROR | JSON_OBJECT_AS_ARRAY);
    +        self::assertIsArray($decoded);
    +        self::assertArrayHasKey('password', $decoded);
    +
    +        $subject = $this->get(UserSettingsScrubbingMigration::class);
    +        self::assertTrue($subject->updateNecessary());
    +        self::assertTrue($subject->executeUpdate());
    +
    +        // Check user settings is now single json_encoded
    +        $row = $connection->select(['user_settings'], 'be_users', ['uid' => $userId])->fetchAssociative();
    +        self::assertNotEmpty($row['user_settings'], 'user_settings should not be empty');
    +        $rawValue = $row['user_settings'];
    +        self::assertFalse(str_starts_with($rawValue, '"{'));
    +        self::assertFalse(str_ends_with($rawValue, '}"'));
    +        $decoded = json_decode(json: $rawValue, flags: JSON_THROW_ON_ERROR | JSON_OBJECT_AS_ARRAY);
    +        self::assertIsArray($decoded);
    +        self::assertArrayNotHasKey('password', $decoded);
    +
    +        self::assertFalse($subject->updateNecessary());
    +    }
    +
    +    #[Test]
    +    public function executeUpdateMigratesSingleJsonEncodedUserSettingsRemovingInvalidValues(): void
    +    {
    +        $connection = $this->get(ConnectionPool::class)->getConnectionForTable('be_users');
    +
    +        // Create a user with uc but without user_settings
    +        $connection->insert(
    +            'be_users',
    +            [
    +                'username' => 'testuser',
    +                'uc' => serialize(['colorScheme' => 'dark', 'titleLen' => 50]),
    +                // The array must be passed directly to prevent double JSON encoding.
    +                // See: https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293
    +                'user_settings' => ['colorScheme' => 'dark', 'titleLen' => 50, 'password' => 'some-plain-password'],
    +            ],
    +            [
    +                'uc' => Connection::PARAM_LOB,
    +                // @todo This behavior cannot be modified yet; the array value must be passed directly
    +                //       until https://review.typo3.org/c/Packages/TYPO3.CMS/+/89293 is merged,
    +                //       otherwise the value will be JSON-encoded twice.
    +                'user_settings' => Type::getType(Types::JSON),
    +            ],
    +        );
    +        $userId = (int)$connection->lastInsertId();
    +
    +        // Check user settings is double json_encoded
    +        $row = $connection->select(['user_settings'], 'be_users', ['uid' => $userId])->fetchAssociative();
    +        self::assertNotEmpty($row['user_settings'], 'user_settings should not be empty');
    +        $rawValue = $row['user_settings'];
    +        self::assertTrue(str_starts_with($rawValue, '{'));
    +        self::assertTrue(str_ends_with($rawValue, '}'));
    +        $decoded = json_decode(json: $rawValue, flags: JSON_THROW_ON_ERROR | JSON_OBJECT_AS_ARRAY);
    +        self::assertIsArray($decoded);
    +        self::assertArrayHasKey('password', $decoded);
    +
    +        $subject = $this->get(UserSettingsScrubbingMigration::class);
    +        self::assertTrue($subject->updateNecessary());
    +        self::assertTrue($subject->executeUpdate());
    +
    +        // Check user settings is now single json_encoded
    +        $row = $connection->select(['user_settings'], 'be_users', ['uid' => $userId])->fetchAssociative();
    +        self::assertNotEmpty($row['user_settings'], 'user_settings should not be empty');
    +        $rawValue = $row['user_settings'];
    +        self::assertFalse(str_starts_with($rawValue, '"{'));
    +        self::assertFalse(str_ends_with($rawValue, '}"'));
    +        $decoded = json_decode(json: $rawValue, flags: JSON_THROW_ON_ERROR | JSON_OBJECT_AS_ARRAY);
    +        self::assertIsArray($decoded);
    +        self::assertArrayNotHasKey('password', $decoded);
    +    }
    +}
    
  • typo3/sysext/core/Classes/Authentication/UserSettingsSchema.php+49 2 modified
    @@ -17,6 +17,8 @@
     
     namespace TYPO3\CMS\Core\Authentication;
     
    +use TYPO3\CMS\Core\Utility\GeneralUtility;
    +
     /**
      * Provides unified access to backend user settings configuration.
      *
    @@ -81,7 +83,8 @@ public function getTca(): array
         {
             $columns = $GLOBALS['TCA']['be_users']['columns']['user_settings']['columns'] ?? [];
             foreach ($columns as $fieldName => $columnConfig) {
    -            $columns[$fieldName] = $this->resolveInheritFromParent($fieldName, $columnConfig);
    +            $partitionedFieldName = $this->getTcaFieldName($fieldName);
    +            $columns[$partitionedFieldName] = $this->resolveInheritFromParent($fieldName, $columnConfig);
             }
     
             return [
    @@ -100,9 +103,53 @@ public function getTca(): array
         }
     
         /**
    -     * Get the showitem string (merged from TCA and legacy global).
    +     * Returns a partitioned field name for use in TCA.
    +     * - e.g. `be_users__password`, reflecting `be_users` values
    +     * - e.g. `user_settings__titleLen`, reflecting JSON values
    +     */
    +    public function getTcaFieldName(string $fieldName): string
    +    {
    +        $configuration = $this->getColumn($fieldName);
    +        $partition = ($configuration['table'] ?? null) === 'be_users' ? 'be_users' : 'user_settings';
    +        return $partition . '__' . $fieldName;
    +    }
    +
    +    private function resolveTcaFieldName(string $fieldName, bool $strict = true): string
    +    {
    +        $configuration = $this->getColumn($fieldName);
    +        if ($configuration !== null) {
    +            $partition = ($configuration['table'] ?? null) === 'be_users' ? 'be_users' : 'user_settings';
    +            return $partition . '__' . $fieldName;
    +        }
    +        if (!$strict) {
    +            return $fieldName;
    +        }
    +        throw new \LogicException(
    +            sprintf(
    +                'Column "%s" not found in UserSettingsSchema',
    +                $fieldName
    +            ),
    +            1776439141
    +        );
    +    }
    +
    +    /**
    +     * Get the partitioned showitem string to be used as virtual TCA.
          */
         public function getTcaShowitem(): string
    +    {
    +        $items = GeneralUtility::trimExplode(',', $this->getRawShowitem(), true);
    +        $items = array_map(
    +            fn(string $fieldName): string => $this->resolveTcaFieldName($fieldName, false),
    +            $items
    +        );
    +        return implode(',', $items);
    +    }
    +
    +    /**
    +     * Get the raw showitem string (merged from TCA and legacy global).
    +     */
    +    public function getRawShowitem(): string
         {
             $tcaShowitem = trim($GLOBALS['TCA']['be_users']['columns']['user_settings']['showitem'] ?? '');
             // @deprecated since TYPO3 v14, remove in TYPO3 v15
    
  • typo3/sysext/core/Documentation/Changelog/14.3/Important-109585-SerializedCredentialDataInBeUsersDatabaseTable.rst+34 0 added
    @@ -0,0 +1,34 @@
    +..  include:: /Includes.rst.txt
    +
    +..  _important-109585-1776329549:
    +
    +====================================================================
    +Important: #109585 - Serialized Credential Data in be_users settings
    +====================================================================
    +
    +See :issue:`109585`
    +
    +Description
    +===========
    +
    +The new mechanism of using serialized JSON data for storing
    +backend user settings since TYPO3 14.2 has introduced a vulnerability
    +that stored the "password" and "verify password" input data
    +when changing a user's password inside the serialized user
    +settings representation.
    +
    +These passwords are no longer stored in the database columns
    +:sql:`be_users.uc` and :sql:`be_users.user_settings` anymore,
    +but may exist in database records during the period where
    +TYPO3 v14.2 was used.
    +
    +An upgrade wizard has been added that will remove these credentials
    +from the serialized representation.
    +
    +This upgrade wizard will detect possible records that contain
    +the string `"password` or `:"password` and then unserialize
    +the data, remove the two fields and re-serialize the data. It
    +is important to execute this wizard for safety. If the wizard
    +does not show up, no serialized credential data is found.
    +
    +..  index:: Backend, PHP-API, ext:backend, NotScanned
    

Vulnerability mechanics

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

References

5

News mentions

0

No linked articles in our index yet.