From d5ad610c3d7a6760915736bd9e00453bd67abe40 Mon Sep 17 00:00:00 2001 From: Gigi <140321494+gigioneggiando@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:21:17 +0300 Subject: [PATCH] Make the login challenge nonce one-time (CWE-294) (#9334) * auth: make the login challenge nonce one-time The per-login challenge nonce stored in the session was never consumed or cleared, so a captured challenge stayed replayable and survived logout (CWE-294). The client (p/scripts/extra.js) already fetches a fresh nonce for every attempt, so consuming it does not affect legitimate retries. Consume the nonce (unset it) as soon as it is read for verification in form login, reauthentication, password change and account self-deletion, and clear it in FreshRSS_Auth::removeAccess so logout invalidates any outstanding challenge. * auth: don't clear the nonce in removeAccess (breaks login) removeAccess() runs on every unauthenticated request via FreshRSS_Auth::init() -- including the login POST itself, before formLoginAction reads the nonce -- so clearing it there rejected every login with 'Invalid session'. Thanks @jamalkamaladdin for the measured catch. The one-time guarantee is preserved by consuming the nonce when it is read for verification (login/reauth/password change/self-deletion); logout no longer needs a separate clear. --- app/Controllers/authController.php | 2 ++ app/Controllers/userController.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/Controllers/authController.php b/app/Controllers/authController.php index 9c0cb9c3e..13d92f5fd 100644 --- a/app/Controllers/authController.php +++ b/app/Controllers/authController.php @@ -118,6 +118,7 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController { if ($isPOST) { $nonce = Minz_Session::paramString('nonce'); + Minz_Session::_param('nonce', false); // One-time: consume the challenge so it cannot be replayed. $username = Minz_Request::paramString('username'); $challenge = Minz_Request::paramString('challenge'); $ip_address = Minz_Request::connectionRemoteAddress(); @@ -215,6 +216,7 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController { if (Minz_Request::isPost()) { $username = Minz_User::name() ?? ''; $nonce = Minz_Session::paramString('nonce'); + Minz_Session::_param('nonce', false); // One-time: consume the challenge so it cannot be replayed. $challenge = Minz_Request::paramString('challenge'); if (!FreshRSS_FormAuth::checkCredentials( $username, FreshRSS_Context::userConf()->passwordHash, $nonce, $challenge diff --git a/app/Controllers/userController.php b/app/Controllers/userController.php index 6857b03ae..db9633dc1 100644 --- a/app/Controllers/userController.php +++ b/app/Controllers/userController.php @@ -174,6 +174,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { if ($challenge !== '') { $username = Minz_User::name(); $nonce = Minz_Session::paramString('nonce'); + Minz_Session::_param('nonce', false); // One-time: consume the challenge so it cannot be replayed. $newPasswordPlain = Minz_Request::paramString('newPasswordPlain', plaintext: true); $confirmPasswordPlain = Minz_Request::paramString('confirmPasswordPlain', plaintext: true); @@ -701,6 +702,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { if ($self_deletion) { // We check the password if it’s a self-destruction $nonce = Minz_Session::paramString('nonce'); + Minz_Session::_param('nonce', false); // One-time: consume the challenge so it cannot be replayed. $challenge = Minz_Request::paramString('challenge'); $ok &= FreshRSS_FormAuth::checkCredentials(