CodeIgniter Shield Password Shucking Vulnerability
Description
CodeIgniter Shield provides authentication and authorization for the CodeIgniter 4 PHP framework. An improper implementation was found in the password storage process. All hashed passwords stored in Shield v1.0.0-beta.3 or earlier are easier to crack than expected due to the vulnerability. Therefore, they should be removed as soon as possible. If an attacker gets (1) the user's hashed password by Shield, and (2) the hashed password (SHA-384 hash without salt) from somewhere, the attacker may easily crack the user's password. Upgrade to Shield v1.0.0-beta.4 or later to fix this issue. After upgrading, all users’ hashed passwords should be updated (saved to the database). There are no known workarounds.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
codeigniter4/shieldPackagist | < 1.0.0-beta.4 | 1.0.0-beta.4 |
Affected products
1- Range: < 1.0.0-beta.4
Patches
1ea9688dd01d1Merge pull request from GHSA-c5vj-f36q-p9vg
26 files changed · +435 −17
docs/guides/strengthen_password.md+122 −0 added@@ -0,0 +1,122 @@ +# How to Strengthen the Password + +Shield allows you to customize password-related settings to make your passwords more secure. + +## Minimum Password Length + +The most important factor when it comes to passwords is the number of characters in the password. +You can check password strength with [Password Strength Testing Tool](https://bitwarden.com/password-strength/). +Short passwords may be cracked in less than one day. + +In Shield, you can set the users' minimum password length. The setting is +`$minimumPasswordLength` in `app/Config/Auth.php`. The default value is 8 characters. +It is the recommended minimum value by NIST. However, some organizations recommend +12 to 14 characters. + +The longer the password, the stronger it is. Consider increasing the value. + +> **Note** +> +> This checking works when you validate passwords with the `strong_password` +> validation rule. +> +> If you disable `CompositionValidator` (enabled by default) in `$passwordValidators`, +> this checking will not work. + +## Password Hashing Algorithm + +You can change the password hashing algorithm by `$hashAlgorithm` in `app/Config/Auth.php`. +The default value is `PASSWORD_DEFAULT` that is `PASSWORD_BCRYPT` at the time of writing. + +`PASSWORD_BCRYPT` means to create new password hashes using the bcrypt algorithm. + +You can use `PASSWORD_ARGON2ID` if your PHP has been compiled with Argon2 support. + +### PASSWORD_BCRYPT + +`PASSWORD_BCRYPT` has one configuration `$hashCost`. The bigger the cost, hashed passwords will be the stronger. + +You can find your appropriate cost with the following code: + +```php +<?php +/** + * This code will benchmark your server to determine how high of a cost you can + * afford. You want to set the highest cost that you can without slowing down + * you server too much. 8-10 is a good baseline, and more is good if your servers + * are fast enough. The code below aims for ≤ 50 milliseconds stretching time, + * which is a good baseline for systems handling interactive logins. + * + * From: https://www.php.net/manual/en/function.password-hash.php#refsect1-function.password-hash-examples + */ +$timeTarget = 0.05; // 50 milliseconds + +$cost = 8; +do { + $cost++; + $start = microtime(true); + password_hash("test", PASSWORD_BCRYPT, ["cost" => $cost]); + $end = microtime(true); +} while (($end - $start) < $timeTarget); + +echo "Appropriate Cost Found: " . $cost; +``` + +#### Limitations + +There are two limitations when you use `PASSWORD_BCRYPT`: + +1. the password will be truncated to a maximum length of 72 bytes. +2. the password will be truncated at the first NULL byte (`\0`). + +##### 72 byte issue + +If a user submits a password longer than 72 bytes, the validation error will occur. +If this behavior is unacceptable, consider: + +1. change the hashing algorithm to `PASSWORD_ARGON2ID`. It does not have such a limitation. + +##### NULL byte issue + +This is because `PASSWORD_BCRYPT` is not binary-safe. Normal users cannot +send NULL bytes in a password string, so this is not a problem in most cases. + +But if this behavior is unacceptable, consider: + +1. adding a validation rule to prohibit NULL bytes or control codes. +2. or change the hashing algorithm to `PASSWORD_ARGON2ID`. It is binary-safe. + +### PASSWORD_ARGON2ID + +`PASSWORD_ARGON2ID` has three configuration `$hashMemoryCost`, `$hashTimeCost`, +and `$hashThreads`. + +If you use `PASSWORD_ARGON2ID`, you should use PHP's constants: + +```php + public int $hashMemoryCost = PASSWORD_ARGON2_DEFAULT_MEMORY_COST; + + public int $hashTimeCost = PASSWORD_ARGON2_DEFAULT_TIME_COST; + public int $hashThreads = PASSWORD_ARGON2_DEFAULT_THREADS; +``` + +## Maximum Password Length + +By default, Shield has the validation rules for maximum password length. + +- 72 bytes for PASSWORD_BCRYPT +- 255 characters for others + +You can customize the validation rule. See [Customizing Shield](../customization.md). + +## $supportOldDangerousPassword + +In `app/Config/Auth.php` there is `$supportOldDangerousPassword`, which is a +setting for using passwords stored in older versions of Shield that were [vulnerable](https://github.com/codeigniter4/shield/security/advisories/GHSA-c5vj-f36q-p9vg). + +This setting is deprecated. If you have this setting set to `true`, you should change +it to `false` as soon as possible, and remove old hashed password in your database. + +> **Note** +> +> This setting will be removed in v1.0.0 official release.
docs/index.md+1 −0 modified@@ -14,3 +14,4 @@ ## Guides * [Protecting an API with Access Tokens](guides/api_tokens.md) * [Mobile Authentication with Access Tokens](guides/mobile_apps.md) +* [How to Strengthen the Password](guides/strengthen_password.md)
mkdocs.yml+1 −0 modified@@ -51,3 +51,4 @@ nav: - Guides: - guides/api_tokens.md - guides/mobile_apps.md + - guides/strengthen_password.md
README.md+1 −0 modified@@ -86,3 +86,4 @@ within this library, in no particular order: - [Google Cloud: 13 best practices for user account, authentication, and password management, 2021 edition](https://cloud.google.com/blog/products/identity-security/account-authentication-and-password-management-best-practices) - [NIST Digital Identity Guidelines](https://pages.nist.gov/800-63-3/sp800-63b.html) - [Implementing Secure User Authentication in PHP Applications with Long-Term Persistence (Login with "Remember Me" Cookies) ](https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence) +- [Password Storage - OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
src/Authentication/Authenticators/Session.php+16 −5 modified@@ -336,19 +336,30 @@ public function check(array $credentials): Result /** @var Passwords $passwords */ $passwords = service('passwords'); + // This is only for supportOldDangerousPassword. + $needsRehash = false; + // Now, try matching the passwords. if (! $passwords->verify($givenPassword, $user->password_hash)) { - return new Result([ - 'success' => false, - 'reason' => lang('Auth.invalidPassword'), - ]); + if ( + ! setting('Auth.supportOldDangerousPassword') + || ! $passwords->verifyDanger($givenPassword, $user->password_hash) // @phpstan-ignore-line + ) { + return new Result([ + 'success' => false, + 'reason' => lang('Auth.invalidPassword'), + ]); + } + + // Passed with old dangerous password. + $needsRehash = true; } // Check to see if the password needs to be rehashed. // This would be due to the hash algorithm or hash // cost changing since the last time that a user // logged in. - if ($passwords->needsRehash($user->password_hash)) { + if ($passwords->needsRehash($user->password_hash) || $needsRehash) { $user->password_hash = $passwords->hash($givenPassword); $this->provider->save($user); }
src/Authentication/Passwords.php+49 −8 modified@@ -31,26 +31,42 @@ public function __construct(Auth $config) */ public function hash(string $password) { - if ((defined('PASSWORD_ARGON2I') && $this->config->hashAlgorithm === PASSWORD_ARGON2I) + return password_hash($password, $this->config->hashAlgorithm, $this->getHashOptions()); + } + + private function getHashOptions(): array + { + if ( + (defined('PASSWORD_ARGON2I') && $this->config->hashAlgorithm === PASSWORD_ARGON2I) || (defined('PASSWORD_ARGON2ID') && $this->config->hashAlgorithm === PASSWORD_ARGON2ID) ) { - $hashOptions = [ + return [ 'memory_cost' => $this->config->hashMemoryCost, 'time_cost' => $this->config->hashTimeCost, 'threads' => $this->config->hashThreads, ]; - } else { - $hashOptions = [ - 'cost' => $this->config->hashCost, - ]; } + return [ + 'cost' => $this->config->hashCost, + ]; + } + + /** + * Hash a password. + * + * @return false|string|null + * + * @deprecated This is only for backward compatibility. + */ + public function hashDanger(string $password) + { return password_hash( base64_encode( hash('sha384', $password, true) ), $this->config->hashAlgorithm, - $hashOptions + $this->getHashOptions() ); } @@ -61,6 +77,19 @@ public function hash(string $password) * @param string $hash The previously hashed password */ public function verify(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Verifies a password against a previously hashed password. + * + * @param string $password The password we're checking + * @param string $hash The previously hashed password + * + * @deprecated This is only for backward compatibility. + */ + public function verifyDanger(string $password, string $hash): bool { return password_verify(base64_encode( hash('sha384', $password, true) @@ -72,7 +101,7 @@ public function verify(string $password, string $hash): bool */ public function needsRehash(string $hashedPassword): bool { - return password_needs_rehash($hashedPassword, $this->config->hashAlgorithm); + return password_needs_rehash($hashedPassword, $this->config->hashAlgorithm, $this->getHashOptions()); } /** @@ -110,4 +139,16 @@ public function check(string $password, ?User $user = null): Result 'success' => true, ]); } + + /** + * Returns the validation rule for max length. + */ + public static function getMaxLenghtRule(): string + { + if (config('Auth')->hashAlgorithm === PASSWORD_BCRYPT) { + return 'max_byte[72]'; + } + + return 'max_length[255]'; + } }
src/Authentication/Passwords/ValidationRules.php+8 −0 modified@@ -55,6 +55,14 @@ public function strong_password(string $value, ?string &$error1 = null, array $d return $result->isOk(); } + /** + * Returns true if $str is $val or fewer bytes in length. + */ + public function max_byte(?string $str, string $val): bool + { + return is_numeric($val) && $val >= strlen($str ?? ''); + } + /** * Builds a new user instance from the global request. */
src/Config/Auth.php+10 −0 modified@@ -360,6 +360,16 @@ class Auth extends BaseConfig */ public int $hashCost = 10; + /** + * If you need to support passwords saved in versions prior to Shield v1.0.0-beta.4. + * set this to true. + * + * See https://github.com/codeigniter4/shield/security/advisories/GHSA-c5vj-f36q-p9vg + * + * @deprecated This is only for backward compatibility. + */ + public bool $supportOldDangerousPassword = false; + /** * //////////////////////////////////////////////////////////////////// * OTHER SETTINGS
src/Controllers/LoginController.php+6 −2 modified@@ -7,6 +7,7 @@ use App\Controllers\BaseController; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\Shield\Authentication\Authenticators\Session; +use CodeIgniter\Shield\Authentication\Passwords; use CodeIgniter\Shield\Traits\Viewable; class LoginController extends BaseController @@ -90,8 +91,11 @@ protected function getValidationRules(): array 'rules' => config('AuthSession')->emailValidationRules, ], 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required', + 'label' => 'Auth.password', + 'rules' => 'required|' . Passwords::getMaxLenghtRule(), + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes', + ], ], ]; }
src/Controllers/RegisterController.php+6 −2 modified@@ -10,6 +10,7 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Shield\Authentication\Authenticators\Session; +use CodeIgniter\Shield\Authentication\Passwords; use CodeIgniter\Shield\Config\Auth; use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Exceptions\ValidationException; @@ -195,8 +196,11 @@ protected function getValidationRules(): array 'rules' => $registrationEmailRules, ], 'password' => [ - 'label' => 'Auth.password', - 'rules' => 'required|strong_password', + 'label' => 'Auth.password', + 'rules' => 'required|' . Passwords::getMaxLenghtRule() . '|strong_password', + 'errors' => [ + 'max_byte' => 'Auth.errorPasswordTooLongBytes', + ], ], 'password_confirm' => [ 'label' => 'Auth.passwordConfirm',
src/Language/de/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'Das Passwort {0} wurde aufgrund einer Datenschutzverletzung aufgedeckt und wurde {1, number} Mal in {2} kompromittierten Passwörtern gesehen.', 'suggestPasswordPwned' => '{0} sollte niemals als Passwort verwendet werden. Wenn Sie es irgendwo verwenden, ändern Sie es sofort.', 'errorPasswordEmpty' => 'Ein Passwort ist erforderlich.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'Passwort erfolgreich geändert', 'userDoesNotExist' => 'Passwort wurde nicht geändert. Der Benutzer existiert nicht', 'resetTokenExpired' => 'Tut mir leid. Ihr Reset-Token ist abgelaufen.',
src/Language/en/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'The password {0} has been exposed due to a data breach and has been seen {1, number} times in {2} of compromised passwords.', 'suggestPasswordPwned' => '{0} should never be used as a password. If you are using it anywhere change it immediately.', 'errorPasswordEmpty' => 'A Password is required.', + 'errorPasswordTooLongBytes' => 'Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'Password changed successfully', 'userDoesNotExist' => 'Password was not changed. User does not exist', 'resetTokenExpired' => 'Sorry. Your reset token has expired.',
src/Language/es/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'La contraseña {0} ha quedado expuesta debido a una violación de datos y se ha visto comprometida {1, número} veces en {2} contraseñas.', 'suggestPasswordPwned' => '{0} no se debe usar nunca como contraseña. Si la estás usando en algún sitio, cámbiala inmediatamente.', 'errorPasswordEmpty' => 'Se necesita una contraseña.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'Contraseña modificada correctamente', 'userDoesNotExist' => 'No se ha cambiado la contraseña. No existe el usuario', 'resetTokenExpired' => 'Lo sentimos. Tu token de reseteo ha caducado.',
src/Language/fa/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'رمز عبور {0} با توجه به نقض داده ها و دیده شدن {1, number} بارها داخل رمز های عبور {2} به پسورد های ناامن تبدیل شده و در معرض قرار گرفته است.', 'suggestPasswordPwned' => '{0} هرگز نباید به عنوان رمز عبور استفاده شود. اگر در هر جایی از آن استفاده می کنید سریعا آن را تغییر دهید.', 'errorPasswordEmpty' => 'رمز عبور الزامی است.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'رمز عبور با موفقیت تغییر کرد', 'userDoesNotExist' => 'رمز عبور تغییر نکرد. کاربر وجود ندارد.', 'resetTokenExpired' => 'متاسفانه، توکن بازنشانی شما منقضی شده است.',
src/Language/fr/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'Le mot de passe {0} a été exposé à la suite d\'une violation de données et a été vu {1, number} fois dans {2} des mots de passe compromis.', 'suggestPasswordPwned' => '{0} ne devrait jamais être utilisé comme mot de passe. Si vous l\'utilisez quelque part, changez-le immédiatement.', 'errorPasswordEmpty' => 'Un mot de passe est obligatoire.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'Mot de passe modifié avec succès', 'userDoesNotExist' => 'Le mot de passe n\'a pas été modifié. L\'utilisateur n\'existe pas', 'resetTokenExpired' => 'Désolé. Votre jeton de réinitialisation a expiré.',
src/Language/id/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'Kata sandi {0} telah bocor karena pelanggaran data dan telah dilihat {1, number} kali dalam {2} sandi yang disusupi.', 'suggestPasswordPwned' => '{0} tidak boleh digunakan sebagai kata sandi. Jika Anda menggunakannya di mana saja, segera ubah.', 'errorPasswordEmpty' => 'Kata sandi wajib diisi.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'Kata sandi berhasil diubah', 'userDoesNotExist' => 'Kata sandi tidak diubah. User tidak ditemukan', 'resetTokenExpired' => 'Maaf, token setel ulang Anda sudah habis waktu.',
src/Language/it/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'La password {0} è stata esposta ad un furto di dati ed è stata vista {1, number} volte in {2} di password compromesse.', 'suggestPasswordPwned' => '{0} non dovrebbe mai essere usata come password. Se la stai utilizzando da qualche parte, cambiala immediatamente.', 'errorPasswordEmpty' => 'Una password è richiesta.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'La password è stata cambiata con successo', 'userDoesNotExist' => 'La password non è stata cambiata. L\'utente non esiste', 'resetTokenExpired' => 'Spiacente. Il tuo reset token è scaduto.',
src/Language/ja/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'パスワード {0} はデータ漏洩により公開されており、{2} の漏洩したパスワード中で {1, number} 回見られます。', // 'The password {0} has been exposed due to a data breach and has been seen {1, number} times in {2} of compromised passwords.', 'suggestPasswordPwned' => '{0} は絶対にパスワードとして使ってはいけません。もしどこかで使っていたら、すぐに変更してください。', // '{0} should never be used as a password. If you are using it anywhere change it immediately.', 'errorPasswordEmpty' => 'パスワードが必要です。', // 'A Password is required.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'パスワードの変更に成功しました', // 'Password changed successfully', 'userDoesNotExist' => 'パスワードは変更されていません。ユーザーは存在しません', // 'Password was not changed. User does not exist', 'resetTokenExpired' => '申し訳ありません。リセットトークンの有効期限が切れました。', // 'Sorry. Your reset token has expired.',
src/Language/pt-BR/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'A senha {0} foi exposta devido a uma violação de dados e foi vista {1, number} vezes em {2} de senhas comprometidas.', 'suggestPasswordPwned' => '{0} nunca deve ser usado como uma senha. Se você estiver usando em algum lugar, altere imediatamente.', 'errorPasswordEmpty' => 'É necessária uma senha.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'Senha alterada com sucesso', 'userDoesNotExist' => 'Senha não foi alterada. Usuário não existe', 'resetTokenExpired' => 'Desculpe. Seu token de redefinição expirou.',
src/Language/sk/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => 'Heslo {0} bolo odhalené z dôvodu porušenia ochrany údajov a bolo videné {1, number}-krát z {2} prelomených hesiel.', 'suggestPasswordPwned' => '{0} by sa nikdy nemalo používať ako heslo. Ak ho niekde používate, okamžite ho zmeňte.', 'errorPasswordEmpty' => 'Vyžaduje sa heslo.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'Heslo bolo úspešne zmenené', 'userDoesNotExist' => 'Heslo nebolo zmenené. Používateľ neexistuje', 'resetTokenExpired' => 'Prepáčte. Platnosť vášho resetovacieho tokenu vypršala.',
src/Language/tr/Auth.php+1 −0 modified@@ -61,6 +61,7 @@ 'errorPasswordPwned' => '{0} şifresi, bir veri ihlali nedeniyle açığa çıktı ve güvenliği ihlal edilmiş şifrelerin {2} tanesinde {1, sayı} kez görüldü.', 'suggestPasswordPwned' => '{0} asla şifre olarak kullanılmamalıdır. Herhangi bir yerde kullanıyorsanız hemen değiştirin.', 'errorPasswordEmpty' => 'Şifre gerekli.', + 'errorPasswordTooLongBytes' => '(To be translated) Password cannot exceed {param} bytes in length.', 'passwordChangeSuccess' => 'Şifre başarıyla değiştirildi.', 'userDoesNotExist' => 'Şifre değiştirilmedi. Kullanıcı yok.', 'resetTokenExpired' => 'Üzgünüz. Sıfırlama anahtarınızın süresi doldu.',
tests/Authentication/Authenticators/SessionAuthenticatorTest.php+29 −0 modified@@ -12,6 +12,7 @@ use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Exceptions\LogicException; use CodeIgniter\Shield\Models\RememberModel; +use CodeIgniter\Shield\Models\UserIdentityModel; use CodeIgniter\Shield\Models\UserModel; use CodeIgniter\Shield\Result; use CodeIgniter\Test\Mock\MockEvents; @@ -303,6 +304,34 @@ public function testCheckSuccess(): void $this->assertSame($this->user->id, $foundUser->id); } + public function testCheckSuccessOldDangerousPassword(): void + { + /** @var Auth $config */ + $config = config('Auth'); + $config->supportOldDangerousPassword = true; // @phpstan-ignore-line + + fake( + UserIdentityModel::class, + [ + 'user_id' => $this->user->id, + 'type' => Session::ID_TYPE_EMAIL_PASSWORD, + 'secret' => 'foo@example.com', + 'secret2' => '$2y$10$WswjNNcR24cJvsXvBc5TveVVVQ9/EYC0eq.Ad9e/2cVnmeSEYBOEm', + ] + ); + + $result = $this->auth->check([ + 'email' => 'foo@example.com', + 'password' => 'passw0rd!', + ]); + + $this->assertInstanceOf(Result::class, $result); + $this->assertTrue($result->isOK()); + + $foundUser = $result->extraInfo(); + $this->assertSame($this->user->id, $foundUser->id); + } + public function testAttemptCannotFindUser(): void { $result = $this->auth->attempt([
tests/Controllers/LoginTest.php+47 −0 modified@@ -8,6 +8,7 @@ use CodeIgniter\Config\Factories; use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\Actions\Email2FA; +use CodeIgniter\Shield\Config\Auth; use CodeIgniter\Test\FeatureTestTrait; use Config\Services; use Config\Validation; @@ -61,6 +62,52 @@ public function testLoginBadEmail(): void $this->assertSame(lang('Auth.badAttempt'), session('error')); } + public function testLoginTooLongPasswordDefault(): void + { + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $result = $this->post('/login', [ + 'email' => 'fooled@example.com', + 'password' => str_repeat('a', 73), + ]); + + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionHas( + 'errors', + ['password' => 'Password cannot exceed 72 bytes in length.'] + ); + } + + public function testLoginTooLongPasswordArgon2id(): void + { + /** @var Auth $config */ + $config = config('Auth'); + $config->hashAlgorithm = PASSWORD_ARGON2ID; + + $this->user->createEmailIdentity([ + 'email' => 'foo@example.com', + 'password' => 'secret123', + ]); + + $result = $this->post('/login', [ + 'email' => 'fooled@example.com', + 'password' => str_repeat('a', 256), + ]); + + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionHas( + 'errors', + ['password' => 'The Password field cannot exceed 255 characters in length.'] + ); + } + public function testLoginActionEmailSuccess(): void { if (version_compare(CodeIgniter::CI_VERSION, '4.3.0', '>=')) {
tests/Controllers/RegisterTest.php+41 −0 modified@@ -8,6 +8,7 @@ use CodeIgniter\Shield\Authentication\Actions\EmailActivator; use CodeIgniter\Shield\Authentication\Authenticators\Session; use CodeIgniter\Shield\Authentication\Passwords\ValidationRules; +use CodeIgniter\Shield\Config\Auth; use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Models\UserModel; use CodeIgniter\Test\FeatureTestTrait; @@ -81,6 +82,46 @@ public function testRegisterActionSuccess(): void $this->assertTrue($user->active); } + public function testRegisterTooLongPasswordDefault(): void + { + $result = $this->withSession()->post('/register', [ + 'username' => 'JohnDoe', + 'email' => 'john.doe@example.com', + 'password' => str_repeat('a', 73), + 'password_confirm' => str_repeat('a', 73), + ]); + + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionHas( + 'errors', + ['password' => 'Password cannot exceed 72 bytes in length.'] + ); + } + + public function testRegisterTooLongPasswordArgon2id(): void + { + /** @var Auth $config */ + $config = config('Auth'); + $config->hashAlgorithm = PASSWORD_ARGON2ID; + + $result = $this->withSession()->post('/register', [ + 'username' => 'JohnDoe', + 'email' => 'john.doe@example.com', + 'password' => str_repeat('a', 256), + 'password_confirm' => str_repeat('a', 256), + ]); + + $result->assertStatus(302); + $result->assertRedirect(); + $result->assertSessionMissing('error'); + $result->assertSessionHas( + 'errors', + ['password' => 'The Password field cannot exceed 255 characters in length.'] + ); + } + public function testRegisterDisplaysForm(): void { $result = $this->withSession()->get('/register');
tests/Unit/PasswordsTest.php+36 −0 modified@@ -23,4 +23,40 @@ public function testEmptyPassword(): void $this->assertFalse($result->isOK()); $this->assertSame('A Password is required.', $result->reason()); } + + public function testHash(): string + { + $config = new AuthConfig(); + $passwords = new Passwords($config); + + $plainPassword = 'passw0rd!'; + $hashedPassword = $passwords->hash($plainPassword); + + $user = new User([ + 'id' => 1, + 'username' => 'John', + ]); + $user->email = 'john@example.org'; + $user->password_hash = $hashedPassword; + + $result = $passwords->check($plainPassword, $user); + + $this->assertTrue($result->isOK()); + + return $hashedPassword; + } + + /** + * @depends testHash + */ + public function testNeedsRehashTakesCareOptions(string $hashedPassword): void + { + $config = new AuthConfig(); + $config->hashCost = 12; + $passwords = new Passwords($config); + + $result = $passwords->needsRehash($hashedPassword); + + $this->assertTrue($result); + } }
UPGRADING.md+51 −0 added@@ -0,0 +1,51 @@ +# Upgrade Guide + +## Version 1.0.0-beta.3 to 1.0.0-beta.4 + +### Important Password Changes + +#### Password Incompatibility + +Shield 1.0.0-beta.4 fixes a [vulnerability related to password storage](https://github.com/codeigniter4/shield/security/advisories/GHSA-c5vj-f36q-p9vg). +As a result, hashed passwords already stored in the database are no longer compatible +and cannot be used by default. + +All hashed passwords stored in Shield v1.0.0-beta.3 or earlier are easier to +crack than expected due to the above vulnerability. Therefore, they should be +removed as soon as possible. + +Existing users will no longer be able to log in with their passwords and will +need to log in with the magic link and then set their passwords again. + +#### If You Want to Allow Login with Existing Passwords + +If you want to use passwords saved in Shield v1.0.0-beta.3 or earlier, +you must add the following property in `app/Config/Auth.php`: + +```php + public bool $supportOldDangerousPassword = true; +``` + +After upgrading, with the above setting, once a user logs in with the password, +the hashed password is updated and stored in the database. + +In this case, the existing hashed passwords are still easier to crack than expected. +Therefore, this setting should not be used for an extended period of time. +So you should change the setting to `false` as soon as possible, and remove old +hashed password. + +> **Note** +> +> This setting is deprecated. It will be removed in v1.0.0 official release. + +#### Limitations for the Default Password Handling + +By default, Shield uses the hashing algorithm `PASSWORD_DEFAULT` (see `app/Config/Auth.php`), +that is, `PASSWORD_BCRYPT` at the time of writing. + +Now there are two limitations when you use `PASSWORD_BCRYPT`. + +1. the password will be truncated to a maximum length of 72 bytes. +2. the password will be truncated at the first NULL byte (`\0`). + +If these behaviors are unacceptable, see [How to Strengthen the Password](https://github.com/codeigniter4/shield/blob/develop/docs/guides/strengthen_password.md).
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
8- github.com/advisories/GHSA-c5vj-f36q-p9vgghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-27580ghsaADVISORY
- blog.ircmaxell.com/2015/03/security-issue-combining-bcrypt-with.htmlghsax_refsource_MISCWEB
- cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.htmlghsax_refsource_MISCWEB
- github.com/codeigniter4/shield/blob/develop/UPGRADING.mdghsax_refsource_MISCWEB
- github.com/codeigniter4/shield/commit/ea9688dd01d100193d834117dbfc2cfabcf9ea0bghsax_refsource_MISCWEB
- github.com/codeigniter4/shield/security/advisories/GHSA-c5vj-f36q-p9vgghsax_refsource_CONFIRMWEB
- www.scottbrady91.com/authentication/beware-of-password-shuckingghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.