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
2Patches
19a6e913f7076[SECURITY] Do not store password in serialized user settings
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- github.com/TYPO3/typo3/commit/9a6e913f70767f63b322ae3e2d2f4e302624c291nvdPatch
- github.com/advisories/GHSA-xvv6-p4wf-mvx7ghsaADVISORY
- typo3.org/security/advisory/typo3-core-sa-2026-005nvdVendor Advisory
- github.com/TYPO3/typo3/security/advisories/GHSA-xvv6-p4wf-mvx7ghsa
- nvd.nist.gov/vuln/detail/CVE-2026-6553ghsa
News mentions
0No linked articles in our index yet.