mirror of
https://github.com/FreshRSS/FreshRSS.git
synced 2026-09-13 05:58:03 -04:00
This is done by getting rid of the CSRF check allowlist, and instead of refreshing the CSRF token on every unauthenticated page load, only refreshing the token when needed (on login, logout, account delete, HTTP auth user change) In addition, the actualize actions are now correctly protected as well. The most notable part is that the user create action could potentially be exploited by another website for XSS on a given instance, by submitting the register form with known credentials, and then by the attacker logging into the same account forcibly registered by a malicious website, and setting custom User JS code. Login action was likely not exploitable in any meaningful way, because of the JS nonce. It's worth noting one risk of merging this PR, some vulnerabilities discovered in the past had reduced impact due to CSRF token refreshing on every page load: https://github.com/FreshRSS/FreshRSS/security/advisories/GHSA-jf4v-f8p2-8xvq#:~:text=It%27s%20important,FreshRSS%5FAuth%3A%3AremoveAccess%28%29%2E
286 lines
7.8 KiB
PHP
286 lines
7.8 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* This class handles all authentication process.
|
|
*/
|
|
class FreshRSS_Auth {
|
|
/**
|
|
* Determines if user is connected.
|
|
*/
|
|
public const DEFAULT_COOKIE_DURATION = 7_776_000;
|
|
|
|
private static bool $login_ok = false;
|
|
|
|
/**
|
|
* This method initializes authentication system.
|
|
*/
|
|
public static function init(): bool {
|
|
if (isset($_SESSION['REMOTE_USER']) && $_SESSION['REMOTE_USER'] !== FreshRSS_http_Util::httpAuthUser()) {
|
|
//HTTP REMOTE_USER has changed
|
|
self::removeAccess();
|
|
Minz_Session::_param('csrf', false);
|
|
}
|
|
|
|
self::$login_ok = Minz_Session::paramBoolean('loginOk');
|
|
$current_user = Minz_User::name();
|
|
if ($current_user === null) {
|
|
$current_user = FreshRSS_Context::systemConf()->default_user;
|
|
Minz_Session::_params([
|
|
Minz_User::CURRENT_USER => $current_user,
|
|
]);
|
|
}
|
|
|
|
if (self::$login_ok && self::giveAccess()) {
|
|
return self::$login_ok;
|
|
}
|
|
if (self::accessControl() && self::giveAccess()) {
|
|
FreshRSS_UserDAO::touch();
|
|
return self::$login_ok;
|
|
}
|
|
// Be sure all accesses are removed!
|
|
self::removeAccess();
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* This method checks if user is allowed to connect.
|
|
*
|
|
* Required session parameters are also set in this method (such as
|
|
* currentUser).
|
|
*
|
|
* @return bool true if user can be connected, false otherwise.
|
|
*/
|
|
private static function accessControl(): bool {
|
|
$auth_type = FreshRSS_Context::systemConf()->auth_type;
|
|
switch ($auth_type) {
|
|
case 'form':
|
|
$credentials = FreshRSS_FormAuth::getCredentialsFromCookie();
|
|
$current_user = '';
|
|
if (isset($credentials[1])) {
|
|
$current_user = trim($credentials[0]);
|
|
Minz_Session::_params([
|
|
Minz_User::CURRENT_USER => $current_user,
|
|
'passwordHash' => trim($credentials[1]),
|
|
'csrf' => false,
|
|
]);
|
|
}
|
|
return $current_user != '';
|
|
case 'http_auth':
|
|
$current_user = FreshRSS_http_Util::httpAuthUser();
|
|
if ($current_user == '') {
|
|
return false;
|
|
}
|
|
$login_ok = FreshRSS_UserDAO::exists($current_user);
|
|
if (!$login_ok && FreshRSS_Context::systemConf()->http_auth_auto_register) {
|
|
$email = null;
|
|
if (FreshRSS_Context::systemConf()->http_auth_auto_register_email_field !== '' &&
|
|
is_string($_SERVER[FreshRSS_Context::systemConf()->http_auth_auto_register_email_field] ?? null)) {
|
|
$email = $_SERVER[FreshRSS_Context::systemConf()->http_auth_auto_register_email_field];
|
|
}
|
|
$language = Minz_Translate::getLanguage(null, Minz_Request::getPreferredLanguages(), FreshRSS_Context::systemConf()->language);
|
|
Minz_Translate::init($language);
|
|
$login_ok = FreshRSS_user_Controller::createUser($current_user, $email, '', [
|
|
'language' => $language,
|
|
]);
|
|
}
|
|
if ($login_ok) {
|
|
Minz_Session::_params([
|
|
Minz_User::CURRENT_USER => $current_user,
|
|
'csrf' => false,
|
|
]);
|
|
}
|
|
return $login_ok;
|
|
case 'none':
|
|
return true;
|
|
default:
|
|
// TODO load extension
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gives access to the current user.
|
|
*/
|
|
public static function giveAccess(): bool {
|
|
FreshRSS_Context::initUser();
|
|
if (!FreshRSS_Context::hasUserConf() || !FreshRSS_Context::userConf()->enabled) {
|
|
self::$login_ok = false;
|
|
return false;
|
|
}
|
|
|
|
switch (FreshRSS_Context::systemConf()->auth_type) {
|
|
case 'form':
|
|
self::$login_ok = Minz_Session::paramString('passwordHash') === FreshRSS_Context::userConf()->passwordHash;
|
|
break;
|
|
case 'http_auth':
|
|
$current_user = Minz_User::name() ?? '';
|
|
self::$login_ok = strcasecmp($current_user, FreshRSS_http_Util::httpAuthUser()) === 0;
|
|
break;
|
|
case 'none':
|
|
self::$login_ok = true;
|
|
break;
|
|
default:
|
|
// TODO: extensions
|
|
self::$login_ok = false;
|
|
}
|
|
|
|
Minz_Session::_params([
|
|
'loginOk' => self::$login_ok,
|
|
'REMOTE_USER' => FreshRSS_http_Util::httpAuthUser(),
|
|
]);
|
|
return self::$login_ok;
|
|
}
|
|
|
|
/**
|
|
* Returns if current user has access to the given scope.
|
|
*
|
|
* @param string $scope general (default) or admin
|
|
* @return bool true if user has corresponding access, false else.
|
|
*/
|
|
public static function hasAccess(string $scope = 'general'): bool {
|
|
if (!FreshRSS_Context::hasUserConf()) {
|
|
return false;
|
|
}
|
|
$currentUser = Minz_User::name();
|
|
$isAdmin = FreshRSS_Context::userConf()->is_admin;
|
|
$default_user = FreshRSS_Context::systemConf()->default_user;
|
|
$ok = self::$login_ok;
|
|
switch ($scope) {
|
|
case 'general':
|
|
break;
|
|
case 'admin':
|
|
$ok &= $default_user === $currentUser || $isAdmin;
|
|
break;
|
|
default:
|
|
$ok = false;
|
|
}
|
|
return (bool)$ok;
|
|
}
|
|
|
|
/**
|
|
* Removes all accesses for the current user.
|
|
*/
|
|
public static function removeAccess(): void {
|
|
self::$login_ok = false;
|
|
Minz_Session::_params([
|
|
'loginOk' => false,
|
|
'lastReauth' => false,
|
|
// 'csrf' => false, // Must be refreshed separately
|
|
'REMOTE_USER' => false,
|
|
]);
|
|
|
|
$username = Minz_Request::paramString('user');
|
|
if (!Minz_Request::tokenIsOk()) {
|
|
$username = FreshRSS_Context::systemConf()->default_user;
|
|
}
|
|
Minz_User::change($username);
|
|
|
|
switch (FreshRSS_Context::systemConf()->auth_type) {
|
|
case 'form':
|
|
Minz_Session::_param('passwordHash');
|
|
FreshRSS_FormAuth::deleteCookie();
|
|
break;
|
|
case 'http_auth':
|
|
case 'none':
|
|
// Nothing to do…
|
|
break;
|
|
default:
|
|
// TODO: extensions
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return if authentication is enabled on this instance of FRSS.
|
|
*/
|
|
public static function accessNeedsLogin(): bool {
|
|
return FreshRSS_Context::systemConf()->auth_type !== 'none';
|
|
}
|
|
|
|
/**
|
|
* Return if authentication requires a PHP action.
|
|
*/
|
|
public static function accessNeedsAction(): bool {
|
|
return FreshRSS_Context::systemConf()->auth_type === 'form';
|
|
}
|
|
|
|
public static function csrfToken(): string {
|
|
$csrf = Minz_Session::paramString('csrf');
|
|
if ($csrf == '') {
|
|
$csrf = hash('sha256', FreshRSS_Context::systemConf()->salt . random_bytes(32));
|
|
Minz_Session::_param('csrf', $csrf);
|
|
}
|
|
return $csrf;
|
|
}
|
|
|
|
public static function isCsrfOk(?string $token = null): bool {
|
|
$csrf = Minz_Session::paramString('csrf');
|
|
if ($token === null) {
|
|
$token = is_string($_POST['_csrf'] ?? null) ? $_POST['_csrf'] : '';
|
|
}
|
|
return $token != '' && hash_equals($csrf, $token);
|
|
}
|
|
|
|
public static function needsReauth(): bool {
|
|
$auth_type = FreshRSS_Context::systemConf()->auth_type;
|
|
$reauth_required = FreshRSS_Context::systemConf()->reauth_required;
|
|
$reauth_time = FreshRSS_Context::systemConf()->reauth_time;
|
|
|
|
if (!$reauth_required) {
|
|
return false;
|
|
}
|
|
|
|
$last_reauth = Minz_Session::paramInt('lastReauth');
|
|
|
|
if ($auth_type !== 'none' && time() - $last_reauth > $reauth_time) {
|
|
if ($auth_type === 'http_auth') {
|
|
// TODO: not implemented - just let the user through
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Return if user needs reauth and got redirected to login page.
|
|
*
|
|
* @param array{c?: string, a?: string, params?: array<string, mixed>}|null $redirect
|
|
*/
|
|
public static function requestReauth(?array $redirect = null): bool {
|
|
if (self::needsReauth()) {
|
|
if (Minz_Request::paramBoolean('ajax')) {
|
|
// Send 403 and exit instead of redirect with Minz_Error::error()
|
|
header('HTTP/1.1 403 Forbidden');
|
|
exit();
|
|
}
|
|
|
|
$redirect = Minz_Url::serialize($redirect ?? Minz_Request::currentRequest());
|
|
|
|
Minz_Request::forward([
|
|
'c' => 'auth',
|
|
'a' => 'reauth',
|
|
'params' => [
|
|
'r' => $redirect,
|
|
],
|
|
], true);
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public static function allowAnonymous(): bool {
|
|
$defaultUser = FreshRSS_Context::systemConf()->default_user;
|
|
$requestedUser = Minz_Request::paramString('user');
|
|
return FreshRSS_Context::systemConf()->allow_anonymous &&
|
|
Minz_User::name() === $defaultUser &&
|
|
($requestedUser === '' || $requestedUser === $defaultUser);
|
|
}
|
|
|
|
public static function allowAnonymousRefresh(): bool {
|
|
return FreshRSS_Context::systemConf()->allow_anonymous_refresh && self::allowAnonymous();
|
|
}
|
|
}
|