From 227ee75151328dcb220c81faf434c33cd80014e9 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Thu, 29 Oct 2026 15:42:48 +0100 Subject: [PATCH 1/2] fix(manager): csrf token message and site behind proxy (referrer same-origin fix) --- assets/plugins/updater/plugin.updater.php | 5 +- core/functions/preload.php | 69 +++++---- core/lang/az/global.php | 2 + core/lang/be/global.php | 2 + core/lang/bg/global.php | 2 + core/lang/cs/global.php | 2 + core/lang/da/global.php | 2 + core/lang/de/global.php | 2 + core/lang/en/global.php | 2 + core/lang/es/global.php | 2 + core/lang/fa/global.php | 2 + core/lang/fi/global.php | 2 + core/lang/fr/global.php | 2 + core/lang/he/global.php | 2 + core/lang/it/global.php | 2 + core/lang/ja/global.php | 2 + core/lang/nl/global.php | 2 + core/lang/nn/global.php | 2 + core/lang/pl/global.php | 2 + core/lang/pt/global.php | 2 + core/lang/sk/global.php | 2 + core/lang/sv/global.php | 2 + core/lang/uk/global.php | 2 + core/lang/zh/global.php | 2 + core/src/Middleware/VerifyCsrfToken.php | 5 +- core/src/Support/CsrfMessage.php | 129 +++++++++++++++++ core/src/Support/ManagerReferer.php | 60 ++++++++ core/tests/Unit/Security/CsrfMessageTest.php | 136 ++++++++++++++++++ .../Security/ManagerCsrfMiddlewareTest.php | 14 ++ .../Unit/Security/ManagerRefererTest.php | 100 +++++++++++++ core/vendor/composer/autoload_classmap.php | 2 + core/vendor/composer/autoload_static.php | 2 + manager/actions/files.dynamic.php | 31 ++-- manager/index.php | 2 + manager/media/browser/mcpuk/browse.php | 2 +- .../media/browser/mcpuk/tpl/tpl_browser.php | 1 + .../script/tests/document-save-helper.test.js | 4 +- manager/media/style/common/login.tpl | 20 ++- .../media/style/common/manager.lockout.tpl | 1 + manager/media/style/default/login.tpl | 20 ++- .../media/style/default/manager.lockout.tpl | 1 + manager/views/frame/1.blade.php | 1 + manager/views/partials/header.blade.php | 1 + 43 files changed, 602 insertions(+), 48 deletions(-) create mode 100644 core/src/Support/CsrfMessage.php create mode 100644 core/src/Support/ManagerReferer.php create mode 100644 core/tests/Unit/Security/CsrfMessageTest.php create mode 100644 core/tests/Unit/Security/ManagerRefererTest.php diff --git a/assets/plugins/updater/plugin.updater.php b/assets/plugins/updater/plugin.updater.php index 617f7b3cea..f7064422c5 100644 --- a/assets/plugins/updater/plugin.updater.php +++ b/assets/plugins/updater/plugin.updater.php @@ -366,7 +366,10 @@ function updaterHandleSystemTaskRequest() updaterJsonResponse([ 'ok' => false, 'error_code' => 'TOKEN_INVALID', - 'message' => 'System task token is invalid.', + // the plugin may run on a core older than this class while it is being updated + 'message' => class_exists(\EvolutionCMS\Support\CsrfMessage::class) + ? \EvolutionCMS\Support\CsrfMessage::text() + : 'System task token is invalid.', ]); } } diff --git a/core/functions/preload.php b/core/functions/preload.php index d89093322f..2aee8fdb94 100644 --- a/core/functions/preload.php +++ b/core/functions/preload.php @@ -26,38 +26,57 @@ function evo() if (IN_MANAGER_MODE == true && IN_INSTALL_MODE == false && EVO_API_MODE != true) { // attempt to foil some simple types of CSRF attacks - if ((int)$evo->getConfig('validate_referer') !== 0) { - if (isset($_SERVER['HTTP_REFERER'])) { + if ((int)$evo->getConfig('validate_referer') !== 0 + && !\EvolutionCMS\Support\ManagerReferer::allows( + $_SERVER, + (string)EVO_SITE_URL, + (string)$evo->getConfig('server_protocol', 'http') + )) { + rejectManagerReferer($evo); + } + } - $referer = $_SERVER['HTTP_REFERER']; + return $evo; + } +} - if (!empty($referer)) { - if (!preg_match('/^' . preg_quote(EVO_SITE_URL, '/') . '/i', $referer)) { - $evo->webAlertAndQuit( - "A possible CSRF attempt was detected from referer: {$referer}.", - "/" . MGR_DIR . "/index.php" - ); - } - } else { - $evo->webAlertAndQuit( - "A possible CSRF attempt was detected. No referer was provided by the client.", - "/" . MGR_DIR . "/index.php" - ); - } - } else { +if (!function_exists('rejectManagerReferer')) { + /** + * Refuse a manager request whose referer is missing or foreign (`validate_referer`). + * A script caller shows the body itself, so it gets the localized message as JSON, not the alert page. + * + * @param \EvolutionCMS\Interfaces\CoreInterface $evo + */ + function rejectManagerReferer($evo): never + { + // every evo() call repeats the check, and shutdown callbacks call evo(): answer once + static $refused = false; + if ($refused) { + exit; + } + $refused = true; - if (mb_strtoupper($_SERVER['REQUEST_METHOD']) !== 'GET') { - $evo->webAlertAndQuit( - "A possible CSRF attempt was detected. No referer was provided by the server.", - "/" . MGR_DIR . "/index.php" - ); - } + // evo() is still being built: the language comes from its configuration, never from evo() + $language = (string)$evo->getConfig('manager_language', 'en'); - } + if (\EvolutionCMS\Support\CsrfMessage::wantsScriptResponse($_SERVER, $_POST)) { + if (!headers_sent()) { + http_response_code(403); + header('Content-Type: application/json; charset=UTF-8'); + header('Cache-Control: no-store'); } + echo json_encode( + \EvolutionCMS\Support\CsrfMessage::payload(\EvolutionCMS\Support\CsrfMessage::REFERER, $language), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + exit; } - return $evo; + $evo->webAlertAndQuit( + \EvolutionCMS\Support\CsrfMessage::text(\EvolutionCMS\Support\CsrfMessage::REFERER, $language), + '/' . MGR_DIR . '/index.php' + ); + exit; } } diff --git a/core/lang/az/global.php b/core/lang/az/global.php index f09b4c69e2..ef9799fb7b 100644 --- a/core/lang/az/global.php +++ b/core/lang/az/global.php @@ -1318,6 +1318,8 @@ $_lang["upload_limits_hint"] = 'Hər fayl üçün %1$s, hər yükləmə üçün %2$s-a qədər.'; $_lang["upload_maxsize_message"] = 'Fayl meneceri vasitəsilə yüklənə bilən maksimum fayl ölçüsünü daxil edin. Yüklənəcək fayl ölçüsü baytlarda göstərilməlidir. QEYD: Böyük faylların yüklənməsi çox uzun çəkə bilər!'; $_lang["upload_maxsize_title"] = 'Maksimum yükləmə ölçüsü'; +$_lang['csrf_token_mismatch'] = 'Təhlükəsizlik nişanının müddəti bitib. Səhifəni yeniləyin və yenidən cəhd edin.'; +$_lang['csrf_referer_mismatch'] = 'Sorğu bloklandı: brauzer onun bu saytdan göndərildiyini təsdiqləmədi.'; $_lang["upload_request_back"] = 'Geri'; $_lang["upload_request_too_large"] = 'Sorğu qəbul edilmədi: onun ölçüsü %1$s-dır, lakin bu server bir sorğuda ən çox %2$s qəbul edir (PHP post_max_size). Heç nə saxlanılmadı.'; $_lang["upload_request_too_large_hint"] = 'Daha az və ya daha kiçik fayllar yükləyin, yaxud administratordan post_max_size və upload_max_filesize dəyərlərini artırmağı xahiş edin.'; diff --git a/core/lang/be/global.php b/core/lang/be/global.php index 32de3d21e8..22b8f43629 100644 --- a/core/lang/be/global.php +++ b/core/lang/be/global.php @@ -1308,6 +1308,8 @@ $_lang["upload_limits_hint"] = 'Да %1$s на файл, %2$s на загрузку.'; $_lang["upload_maxsize_message"] = 'Калі ласка, увядзіце максімальны памер файла, які можна загрузіць праз файлавы менеджэр. Памер загружанага файла павінен быць уведзены ў байтах. УВАГА: загрузка вялікіх файлаў можа заняць вельмі шмат часу!'; $_lang["upload_maxsize_title"] = 'Максімальны памер загрузкі'; +$_lang['csrf_token_mismatch'] = 'Тэрмін дзеяння ахоўнага токена скончыўся. Абнавіце старонку і паўтарыце спробу.'; +$_lang['csrf_referer_mismatch'] = 'Запыт заблакаваны: браўзер не пацвердзіў, што ён адпраўлены з гэтага сайта.'; $_lang["upload_request_back"] = 'Назад'; $_lang["upload_request_too_large"] = 'Запыт не прыняты: яго памер %1$s, а гэты сервер прымае не больш за %2$s на запыт (PHP post_max_size). Нічога не захавана.'; $_lang["upload_request_too_large_hint"] = 'Загрузіце менш файлаў або файлы меншага памеру, або папрасіце адміністратара павялічыць post_max_size і upload_max_filesize.'; diff --git a/core/lang/bg/global.php b/core/lang/bg/global.php index 37d3780b98..4d526f5427 100644 --- a/core/lang/bg/global.php +++ b/core/lang/bg/global.php @@ -1059,6 +1059,8 @@ $_lang["upload_limits_hint"] = 'До %1$s на файл, %2$s на качване.'; $_lang["upload_maxsize_message"] = 'Въведете максималния размер на файла, който може да бъде ъплоуднат с файловия мениджър. Размера на файла за ъплоудване трябва да бъде написан в байтове. ЗАБЕЛЕЖКА: Големите файлове ще се ъплоудват по-дълго време!'; $_lang["upload_maxsize_title"] = 'Максимален размер на файла за ъплоудване'; +$_lang['csrf_token_mismatch'] = 'Срокът на защитния токен изтече. Презаредете страницата и опитайте отново.'; +$_lang['csrf_referer_mismatch'] = 'Заявката е блокирана: браузърът не потвърди, че идва от този сайт.'; $_lang["upload_request_back"] = 'Назад'; $_lang["upload_request_too_large"] = 'Заявката не беше приета: тя е %1$s, но този сървър приема най-много %2$s на заявка (PHP post_max_size). Нищо не беше записано.'; $_lang["upload_request_too_large_hint"] = 'Качете по-малко или по-малки файлове или помолете администратора да увеличи post_max_size и upload_max_filesize.'; diff --git a/core/lang/cs/global.php b/core/lang/cs/global.php index 86b61026e5..976b7055b5 100644 --- a/core/lang/cs/global.php +++ b/core/lang/cs/global.php @@ -1063,6 +1063,8 @@ $_lang["upload_limits_hint"] = 'Až %1$s na soubor, %2$s na jedno nahrání.'; $_lang["upload_maxsize_message"] = 'Zadejte maximální velikost souboru, který může být nahrán prostřednictvím souborového správce. Velikost nahraného souboru musí být zadána v bajtech. POZNÁMKA: Velké soubory se můžou nahrávat velmi dlohou dobu!'; $_lang["upload_maxsize_title"] = 'Maximální velikost nahrávaného souboru'; +$_lang['csrf_token_mismatch'] = 'Platnost bezpečnostního tokenu vypršela. Obnovte stránku a zkuste to znovu.'; +$_lang['csrf_referer_mismatch'] = 'Požadavek zablokován: prohlížeč nepotvrdil, že pochází z tohoto webu.'; $_lang["upload_request_back"] = 'Zpět'; $_lang["upload_request_too_large"] = 'Požadavek nebyl přijat: má %1$s, ale tento server přijímá nejvýše %2$s na požadavek (PHP post_max_size). Nic nebylo uloženo.'; $_lang["upload_request_too_large_hint"] = 'Nahrajte méně nebo menší soubory, nebo požádejte správce o zvýšení post_max_size a upload_max_filesize.'; diff --git a/core/lang/da/global.php b/core/lang/da/global.php index 8cc4c8d120..0ebf91ba09 100644 --- a/core/lang/da/global.php +++ b/core/lang/da/global.php @@ -1062,6 +1062,8 @@ $_lang["upload_limits_hint"] = 'Op til %1$s pr. fil, %2$s pr. upload.'; $_lang["upload_maxsize_message"] = 'Indtast den maksimale filstørrelse som der må uploades med filbrowseren. Størrelsen skal angives i bytes. Bemærk: Det kan tage lang tid at uploade store filer!'; $_lang["upload_maxsize_title"] = 'Maksimal filstørrelse ved upload'; +$_lang['csrf_token_mismatch'] = 'Sikkerhedstokenet er udløbet. Genindlæs siden, og prøv igen.'; +$_lang['csrf_referer_mismatch'] = 'Anmodningen blev blokeret: browseren bekræftede ikke, at den kom fra dette websted.'; $_lang["upload_request_back"] = 'Tilbage'; $_lang["upload_request_too_large"] = 'Forespørgslen blev ikke accepteret: den fylder %1$s, men denne server modtager højst %2$s pr. forespørgsel (PHP post_max_size). Intet blev gemt.'; $_lang["upload_request_too_large_hint"] = 'Upload færre eller mindre filer, eller bed administratoren om at hæve post_max_size og upload_max_filesize.'; diff --git a/core/lang/de/global.php b/core/lang/de/global.php index bae4f9f586..186b9ddec6 100644 --- a/core/lang/de/global.php +++ b/core/lang/de/global.php @@ -1143,6 +1143,8 @@ $_lang["upload_limits_hint"] = 'Bis zu %1$s pro Datei, %2$s pro Upload.'; $_lang["upload_maxsize_message"] = 'Geben Sie hier die maximale Dateigröße für den Datei-Upload mit dem Datei-Manager an. Die Größenangabe ist in Bytes. Achtung: je größer die Datei desto länger dauert der Upload!'; $_lang["upload_maxsize_title"] = 'Maximale Upload-Dateigröße'; +$_lang['csrf_token_mismatch'] = 'Das Sicherheitstoken ist abgelaufen. Laden Sie die Seite neu und versuchen Sie es erneut.'; +$_lang['csrf_referer_mismatch'] = 'Anfrage blockiert: Der Browser hat nicht bestätigt, dass sie von dieser Website stammt.'; $_lang["upload_request_back"] = 'Zurück'; $_lang["upload_request_too_large"] = 'Die Anfrage wurde nicht angenommen: Sie ist %1$s groß, dieser Server akzeptiert aber höchstens %2$s pro Anfrage (PHP post_max_size). Es wurde nichts gespeichert.'; $_lang["upload_request_too_large_hint"] = 'Laden Sie weniger oder kleinere Dateien hoch oder bitten Sie den Administrator, post_max_size und upload_max_filesize zu erhöhen.'; diff --git a/core/lang/en/global.php b/core/lang/en/global.php index 198827af70..0f8fb195c6 100644 --- a/core/lang/en/global.php +++ b/core/lang/en/global.php @@ -1190,6 +1190,8 @@ $_lang["upload_limits_hint"] = 'Up to %1$s per file, %2$s per upload.'; $_lang["upload_maxsize_message"] = 'Enter the maximum file size that can be uploaded via the file manager. Upload file size must be entered in bytes. NOTE: Large files can take a very long time to upload!'; $_lang["upload_maxsize_title"] = 'Maximum upload size'; +$_lang['csrf_token_mismatch'] = 'Security token expired. Reload the page and try again.'; +$_lang['csrf_referer_mismatch'] = 'Request blocked: the browser did not confirm it came from this site.'; $_lang["upload_request_back"] = 'Back'; $_lang["upload_request_too_large"] = 'The request was not accepted: it is %1$s, but this server takes at most %2$s per request (PHP post_max_size). Nothing was saved.'; $_lang["upload_request_too_large_hint"] = 'Upload fewer or smaller files, or ask the administrator to raise post_max_size and upload_max_filesize.'; diff --git a/core/lang/es/global.php b/core/lang/es/global.php index c3eee72ca7..38ededb5b9 100644 --- a/core/lang/es/global.php +++ b/core/lang/es/global.php @@ -1145,6 +1145,8 @@ $_lang["upload_limits_hint"] = 'Hasta %1$s por archivo, %2$s por subida.'; $_lang["upload_maxsize_message"] = 'Ingresa el tamaño máximo de archivo a ser subido por el Administrador de archivos. El tamaño de archivo a subir debe de ser ingresado en bytes. NOTA: ¡Archivos grandes pueden demorar mucho tiempo para subir!'; $_lang["upload_maxsize_title"] = 'Tamaño máximo de subida'; +$_lang['csrf_token_mismatch'] = 'El token de seguridad ha caducado. Recargue la página e inténtelo de nuevo.'; +$_lang['csrf_referer_mismatch'] = 'Solicitud bloqueada: el navegador no confirmó que procede de este sitio.'; $_lang["upload_request_back"] = 'Volver'; $_lang["upload_request_too_large"] = 'La solicitud no fue aceptada: ocupa %1$s, pero este servidor admite como máximo %2$s por solicitud (PHP post_max_size). No se ha guardado nada.'; $_lang["upload_request_too_large_hint"] = 'Sube menos archivos o archivos más pequeños, o pide al administrador que aumente post_max_size y upload_max_filesize.'; diff --git a/core/lang/fa/global.php b/core/lang/fa/global.php index 089ed2a9f0..dadca76c12 100644 --- a/core/lang/fa/global.php +++ b/core/lang/fa/global.php @@ -1060,6 +1060,8 @@ $_lang["upload_limits_hint"] = 'حداکثر %1$s برای هر فایل، %2$s برای هر آپلود.'; $_lang["upload_maxsize_message"] = 'نهایت اندازه مجاز فایل برای آپلود بوسیله ی بخش فایلها را به بایت در اینجا وارد کنید. تذکر : فایلهای بزرگ زمان بیشتری را برای آپلود میطلبد!'; $_lang["upload_maxsize_title"] = 'نهایت اندازه برای آپلود :'; +$_lang['csrf_token_mismatch'] = 'توکن امنیتی منقضی شده است. صفحه را دوباره بارگذاری و مجدداً تلاش کنید.'; +$_lang['csrf_referer_mismatch'] = 'درخواست مسدود شد: مرورگر تأیید نکرد که از این سایت ارسال شده است.'; $_lang["upload_request_back"] = 'بازگشت'; $_lang["upload_request_too_large"] = 'درخواست پذیرفته نشد: حجم آن %1$s است، اما این سرور حداکثر %2$s در هر درخواست می‌پذیرد (PHP post_max_size). چیزی ذخیره نشد.'; $_lang["upload_request_too_large_hint"] = 'فایل‌های کمتر یا کوچک‌تری آپلود کنید، یا از مدیر سیستم بخواهید post_max_size و upload_max_filesize را افزایش دهد.'; diff --git a/core/lang/fi/global.php b/core/lang/fi/global.php index 5cc0b72618..3e22f21b43 100644 --- a/core/lang/fi/global.php +++ b/core/lang/fi/global.php @@ -1058,6 +1058,8 @@ $_lang["upload_limits_hint"] = 'Enintään %1$s tiedostoa kohden, %2$s siirtoa kohden.'; $_lang["upload_maxsize_message"] = 'Enimmäiskoko tiedostoselaimen kautta siirrettäville tiedostoille. Tiedostokoko tulee ilmoittaa tavuina. HUOMAA: Suurien tiedostojen siirrossa voi kestää erittäin kauan!'; $_lang["upload_maxsize_title"] = 'Siirrettävän tiedoston enimmäiskoko'; +$_lang['csrf_token_mismatch'] = 'Suojaustunnus on vanhentunut. Lataa sivu uudelleen ja yritä uudelleen.'; +$_lang['csrf_referer_mismatch'] = 'Pyyntö estettiin: selain ei vahvistanut, että se tuli tältä sivustolta.'; $_lang["upload_request_back"] = 'Takaisin'; $_lang["upload_request_too_large"] = 'Pyyntöä ei hyväksytty: sen koko on %1$s, mutta tämä palvelin ottaa vastaan enintään %2$s pyyntöä kohden (PHP post_max_size). Mitään ei tallennettu.'; $_lang["upload_request_too_large_hint"] = 'Siirrä vähemmän tai pienempiä tiedostoja tai pyydä ylläpitäjää kasvattamaan arvoja post_max_size ja upload_max_filesize.'; diff --git a/core/lang/fr/global.php b/core/lang/fr/global.php index 889cb31147..20e6b30996 100644 --- a/core/lang/fr/global.php +++ b/core/lang/fr/global.php @@ -1029,6 +1029,8 @@ $_lang["upload_limits_hint"] = 'Jusqu\'à %1$s par fichier, %2$s par téléchargement.'; $_lang["upload_maxsize_message"] = 'Entrez ici la taille maximale des fichiers pouvant être téléchargés à l\'aide du Gestionnaire de Fichiers. La taille doit être spécifiée en octets. NOTE: Les fichiers volumineux peuvent être longs à télécharger!'; $_lang["upload_maxsize_title"] = 'Taille maximale de téléchargement'; +$_lang['csrf_token_mismatch'] = 'Le jeton de sécurité a expiré. Rechargez la page et réessayez.'; +$_lang['csrf_referer_mismatch'] = 'Requête bloquée : le navigateur n\'a pas confirmé qu\'elle provient de ce site.'; $_lang["upload_request_back"] = 'Retour'; $_lang["upload_request_too_large"] = 'La requête n\'a pas été acceptée : elle fait %1$s, mais ce serveur accepte au maximum %2$s par requête (PHP post_max_size). Rien n\'a été enregistré.'; $_lang["upload_request_too_large_hint"] = 'Téléchargez moins de fichiers ou des fichiers plus petits, ou demandez à l\'administrateur d\'augmenter post_max_size et upload_max_filesize.'; diff --git a/core/lang/he/global.php b/core/lang/he/global.php index a1c82f235d..ddfc0f3d2e 100644 --- a/core/lang/he/global.php +++ b/core/lang/he/global.php @@ -1060,6 +1060,8 @@ $_lang["upload_limits_hint"] = 'עד %1$s לקובץ, %2$s להעלאה.'; $_lang["upload_maxsize_message"] = 'Enter the maximum file size that can be uploaded via the file manager. Upload file size must be entered in bytes. NOTE: Large files can take a very long time to upload!'; $_lang["upload_maxsize_title"] = 'Maximum upload size'; +$_lang['csrf_token_mismatch'] = 'פג תוקף אסימון האבטחה. יש לטעון מחדש את הדף ולנסות שוב.'; +$_lang['csrf_referer_mismatch'] = 'הבקשה נחסמה: הדפדפן לא אישר שהיא נשלחה מאתר זה.'; $_lang["upload_request_back"] = 'חזרה'; $_lang["upload_request_too_large"] = 'הבקשה לא התקבלה: גודלה %1$s, אך שרת זה מקבל לכל היותר %2$s לבקשה (PHP post_max_size). דבר לא נשמר.'; $_lang["upload_request_too_large_hint"] = 'העלו פחות קבצים או קבצים קטנים יותר, או בקשו ממנהל המערכת להגדיל את post_max_size ואת upload_max_filesize.'; diff --git a/core/lang/it/global.php b/core/lang/it/global.php index 5b7456b4e2..c8149e28c9 100644 --- a/core/lang/it/global.php +++ b/core/lang/it/global.php @@ -1154,6 +1154,8 @@ $_lang["upload_limits_hint"] = 'Fino a %1$s per file, %2$s per caricamento.'; $_lang["upload_maxsize_message"] = 'Qui potete inserire le dimensioni massime dei file che si possono caricare tramite il sistema di gestione file. Le dimensioni devo essere impostate in bytes. NOTA: File troppo grandi impiegano molto tempo a caricare!'; $_lang["upload_maxsize_title"] = 'Massima dimensione caricamento:'; +$_lang['csrf_token_mismatch'] = 'Il token di sicurezza è scaduto. Ricarica la pagina e riprova.'; +$_lang['csrf_referer_mismatch'] = 'Richiesta bloccata: il browser non ha confermato che proviene da questo sito.'; $_lang["upload_request_back"] = 'Indietro'; $_lang["upload_request_too_large"] = 'La richiesta non è stata accettata: è di %1$s, ma questo server accetta al massimo %2$s per richiesta (PHP post_max_size). Non è stato salvato nulla.'; $_lang["upload_request_too_large_hint"] = 'Caricate meno file o file più piccoli, oppure chiedete all\'amministratore di aumentare post_max_size e upload_max_filesize.'; diff --git a/core/lang/ja/global.php b/core/lang/ja/global.php index 73d6ef81a3..a8630dda2e 100644 --- a/core/lang/ja/global.php +++ b/core/lang/ja/global.php @@ -1177,6 +1177,8 @@ $_lang["upload_limits_hint"] = '1 ファイルあたり最大 %1$s、1 回のアップロードあたり最大 %2$s。'; $_lang["upload_maxsize_message"] = '管理画面を通じてアップロード可能なファイルの最大サイズ。このサーバでは上限%sまで設定できます。
※ブラウザ側で制限をかけるための設定です。サーバ側の調整が必要な場合は.htaccessやphp.iniでupload_max_filesizeの設定を変更してください。'; $_lang["upload_maxsize_title"] = 'アップロードサイズの上限'; +$_lang['csrf_token_mismatch'] = 'セキュリティトークンの有効期限が切れました。ページを再読み込みして、もう一度お試しください。'; +$_lang['csrf_referer_mismatch'] = 'リクエストはブロックされました:ブラウザはこのサイトから送信されたことを確認しませんでした。'; $_lang["upload_request_back"] = '戻る'; $_lang["upload_request_too_large"] = 'リクエストは受け付けられませんでした: サイズは %1$s ですが、このサーバーが 1 リクエストで受け付けるのは最大 %2$s です (PHP post_max_size)。何も保存されませんでした。'; $_lang["upload_request_too_large_hint"] = 'ファイルの数を減らすか、より小さいファイルをアップロードしてください。または、post_max_size と upload_max_filesize を引き上げるよう管理者に依頼してください。'; diff --git a/core/lang/nl/global.php b/core/lang/nl/global.php index 468e5e3a1b..e18363725b 100644 --- a/core/lang/nl/global.php +++ b/core/lang/nl/global.php @@ -1133,6 +1133,8 @@ $_lang["upload_limits_hint"] = 'Maximaal %1$s per bestand, %2$s per upload.'; $_lang["upload_maxsize_message"] = 'Voer de maximale bestandsgrootte in voor bestanden die via Bestandsbeheer geupload kunnen worden. De bestandsgrootte wordt opgegeven in bytes.
NB: Het kan enige tijd duren om grote bestanden te uploaden!'; $_lang["upload_maxsize_title"] = 'Maximale upload bestandsgrootte'; +$_lang['csrf_token_mismatch'] = 'Het beveiligingstoken is verlopen. Laad de pagina opnieuw en probeer het nogmaals.'; +$_lang['csrf_referer_mismatch'] = 'Verzoek geblokkeerd: de browser heeft niet bevestigd dat het van deze site komt.'; $_lang["upload_request_back"] = 'Terug'; $_lang["upload_request_too_large"] = 'Het verzoek is niet geaccepteerd: het is %1$s, maar deze server accepteert maximaal %2$s per verzoek (PHP post_max_size). Er is niets opgeslagen.'; $_lang["upload_request_too_large_hint"] = 'Upload minder of kleinere bestanden, of vraag de beheerder om post_max_size en upload_max_filesize te verhogen.'; diff --git a/core/lang/nn/global.php b/core/lang/nn/global.php index 4c0bb53750..9053e916b7 100644 --- a/core/lang/nn/global.php +++ b/core/lang/nn/global.php @@ -1026,6 +1026,8 @@ $_lang["upload_limits_hint"] = 'Opptil %1$s per fil, %2$s per opplasting.'; $_lang["upload_maxsize_message"] = 'Skriv den største filstørrelsen som kan lastes opp via filhåndtereren. Størelsen må angis i bytes.
OBS: Store filer kan ta veldig lang tid å laste opp!'; $_lang["upload_maxsize_title"] = 'Max størrelse for opplastinger'; +$_lang['csrf_token_mismatch'] = 'Tryggingstokenet er utgått. Last sida på nytt og prøv igjen.'; +$_lang['csrf_referer_mismatch'] = 'Førespurnaden vart blokkert: nettlesaren stadfesta ikkje at han kom frå denne nettstaden.'; $_lang["upload_request_back"] = 'Tilbake'; $_lang["upload_request_too_large"] = 'Førespurnaden vart ikkje godteken: han er %1$s, men denne tenaren tek imot høgst %2$s per førespurnad (PHP post_max_size). Ingenting vart lagra.'; $_lang["upload_request_too_large_hint"] = 'Last opp færre eller mindre filer, eller be administratoren auke post_max_size og upload_max_filesize.'; diff --git a/core/lang/pl/global.php b/core/lang/pl/global.php index dfd326b666..ca554bb906 100644 --- a/core/lang/pl/global.php +++ b/core/lang/pl/global.php @@ -1147,6 +1147,8 @@ $_lang["upload_limits_hint"] = 'Do %1$s na plik, %2$s na jedno wgrywanie.'; $_lang["upload_maxsize_message"] = 'Wprowadź maksymalny rozmiar pliku, który może zostać załadowany przez menedżera plików. Wartość musi być podana w bajtach. Uwaga: Ładowanie dużych plików może zająć bardzo dużo czasu!'; $_lang["upload_maxsize_title"] = 'Maksymalny rozmiar pliku'; +$_lang['csrf_token_mismatch'] = 'Token bezpieczeństwa wygasł. Odśwież stronę i spróbuj ponownie.'; +$_lang['csrf_referer_mismatch'] = 'Żądanie zablokowane: przeglądarka nie potwierdziła, że pochodzi z tej witryny.'; $_lang["upload_request_back"] = 'Wstecz'; $_lang["upload_request_too_large"] = 'Żądanie nie zostało przyjęte: ma %1$s, a ten serwer przyjmuje najwyżej %2$s na żądanie (PHP post_max_size). Nic nie zostało zapisane.'; $_lang["upload_request_too_large_hint"] = 'Wgraj mniej plików lub mniejsze pliki albo poproś administratora o zwiększenie post_max_size i upload_max_filesize.'; diff --git a/core/lang/pt/global.php b/core/lang/pt/global.php index 496d11c817..d9f1c4d908 100644 --- a/core/lang/pt/global.php +++ b/core/lang/pt/global.php @@ -1059,6 +1059,8 @@ $_lang["upload_limits_hint"] = 'Até %1$s por ficheiro, %2$s por envio.'; $_lang["upload_maxsize_message"] = 'Digite o tamanho máximo que um ficheiro pode ter para ser enviado via Gestor de Ficheiros. Este valor deve ser digitado em bytes. Importante: Ficheiros muito grandes podem demorar muito tempo a ser transferidos!'; $_lang["upload_maxsize_title"] = 'Tamanho máximo de envio (upload)'; +$_lang['csrf_token_mismatch'] = 'O token de segurança expirou. Recarregue a página e tente novamente.'; +$_lang['csrf_referer_mismatch'] = 'Pedido bloqueado: o navegador não confirmou que veio deste site.'; $_lang["upload_request_back"] = 'Voltar'; $_lang["upload_request_too_large"] = 'O pedido não foi aceite: tem %1$s, mas este servidor aceita no máximo %2$s por pedido (PHP post_max_size). Nada foi guardado.'; $_lang["upload_request_too_large_hint"] = 'Envie menos ficheiros ou ficheiros mais pequenos, ou peça ao administrador para aumentar post_max_size e upload_max_filesize.'; diff --git a/core/lang/sk/global.php b/core/lang/sk/global.php index 024f815b55..9655125587 100644 --- a/core/lang/sk/global.php +++ b/core/lang/sk/global.php @@ -1178,6 +1178,8 @@ $_lang["upload_limits_hint"] = 'Do %1$s na súbor, %2$s na jedno nahrávanie.'; $_lang["upload_maxsize_message"] = 'Nastavte maximálnu veľkosť nahrávaných súborov. Hodnota musí byť v bajtoch.
Poznámka: veľké súbory sa spravidla nahrávajú VEĽMI dlho'; $_lang["upload_maxsize_title"] = 'Maximálna veľkosť nahrávania:'; +$_lang['csrf_token_mismatch'] = 'Platnosť bezpečnostného tokenu vypršala. Obnovte stránku a skúste to znova.'; +$_lang['csrf_referer_mismatch'] = 'Požiadavka zablokovaná: prehliadač nepotvrdil, že pochádza z tohto webu.'; $_lang["upload_request_back"] = 'Späť'; $_lang["upload_request_too_large"] = 'Požiadavka nebola prijatá: má %1$s, ale tento server prijíma najviac %2$s na požiadavku (PHP post_max_size). Nič nebolo uložené.'; $_lang["upload_request_too_large_hint"] = 'Nahrajte menej súborov alebo menšie súbory, prípadne požiadajte správcu o zvýšenie post_max_size a upload_max_filesize.'; diff --git a/core/lang/sv/global.php b/core/lang/sv/global.php index 2a7dad2791..731639f975 100644 --- a/core/lang/sv/global.php +++ b/core/lang/sv/global.php @@ -1060,6 +1060,8 @@ $_lang["upload_limits_hint"] = 'Upp till %1$s per fil, %2$s per uppladdning.'; $_lang["upload_maxsize_message"] = 'Skriv den största filstorleken som kan laddas upp via filhanteraren. Storleken måste anges i bytes.
OBS: Stora filer kan ta väldigt lång tid att ladda upp!'; $_lang["upload_maxsize_title"] = 'Största storlek för uppladdningar'; +$_lang['csrf_token_mismatch'] = 'Säkerhetstoken har upphört att gälla. Läs in sidan igen och försök på nytt.'; +$_lang['csrf_referer_mismatch'] = 'Begäran blockerades: webbläsaren bekräftade inte att den kom från den här webbplatsen.'; $_lang["upload_request_back"] = 'Tillbaka'; $_lang["upload_request_too_large"] = 'Begäran accepterades inte: den är %1$s, men den här servern tar emot högst %2$s per begäran (PHP post_max_size). Inget sparades.'; $_lang["upload_request_too_large_hint"] = 'Ladda upp färre eller mindre filer, eller be administratören att höja post_max_size och upload_max_filesize.'; diff --git a/core/lang/uk/global.php b/core/lang/uk/global.php index 08a8e9531a..700f147f6c 100644 --- a/core/lang/uk/global.php +++ b/core/lang/uk/global.php @@ -1185,6 +1185,8 @@ $_lang["upload_limits_hint"] = 'До %1$s на файл, %2$s на завантаження.'; $_lang["upload_maxsize_message"] = 'Задайте максимальний розмір файлів. Значення має бути в байтах.
Примітка: великі файли завантажуються, як правило, ДУЖЕ довго'; $_lang["upload_maxsize_title"] = 'Максимальний розмір завантаження:'; +$_lang['csrf_token_mismatch'] = 'Термін дії захисного токена минув. Оновіть сторінку та повторіть спробу.'; +$_lang['csrf_referer_mismatch'] = 'Запит заблоковано: браузер не підтвердив, що його надіслано з цього сайту.'; $_lang["upload_request_back"] = 'Назад'; $_lang["upload_request_too_large"] = 'Запит не прийнято: його розмір %1$s, а цей сервер приймає не більше %2$s на запит (PHP post_max_size). Нічого не збережено.'; $_lang["upload_request_too_large_hint"] = 'Завантажте менше файлів або менші файли, або попросіть адміністратора збільшити post_max_size і upload_max_filesize.'; diff --git a/core/lang/zh/global.php b/core/lang/zh/global.php index 3be861d63a..14e9d97c3f 100644 --- a/core/lang/zh/global.php +++ b/core/lang/zh/global.php @@ -1060,6 +1060,8 @@ $_lang["upload_limits_hint"] = '每个文件最大 %1$s,每次上传最大 %2$s。'; $_lang["upload_maxsize_message"] = '输入通过文件管理上传文件的大小的最大值. 大小按 byte计算. 注意: 大文件会占用太多的传输时间!'; $_lang["upload_maxsize_title"] = '最大上传大小'; +$_lang['csrf_token_mismatch'] = '安全令牌已过期。请重新加载页面后再试。'; +$_lang['csrf_referer_mismatch'] = '请求已被阻止:浏览器未确认其来自本站。'; $_lang["upload_request_back"] = '返回'; $_lang["upload_request_too_large"] = '请求未被接受:其大小为 %1$s,但此服务器每个请求最多接受 %2$s (PHP post_max_size)。未保存任何内容。'; $_lang["upload_request_too_large_hint"] = '请上传更少或更小的文件,或请管理员调大 post_max_size 和 upload_max_filesize。'; diff --git a/core/src/Middleware/VerifyCsrfToken.php b/core/src/Middleware/VerifyCsrfToken.php index 5bbd8e4b3e..1d64b7fc16 100644 --- a/core/src/Middleware/VerifyCsrfToken.php +++ b/core/src/Middleware/VerifyCsrfToken.php @@ -1,6 +1,7 @@ ajax() || $request->wantsJson()) { - return Response::json(['error' => 'CSRF token mismatch', 'code' => 'csrf_token_mismatch'], 403); + return Response::json(CsrfMessage::payload(), 403, ['Cache-Control' => 'no-store']); } // Rejected operations, including mutating GET actions, must never be replayed. return Response::make( - 'The session token has changed or is missing. Reopen the manager, then review and retry your action. The action was not performed.', + CsrfMessage::text(), 403, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store'] ); diff --git a/core/src/Support/CsrfMessage.php b/core/src/Support/CsrfMessage.php new file mode 100644 index 0000000000..5d8bd2caab --- /dev/null +++ b/core/src/Support/CsrfMessage.php @@ -0,0 +1,129 @@ + 'Security token expired. Reload the page and try again.', + self::REFERER => 'Request blocked: the browser did not confirm it came from this site.', + ]; + + private const ERRORS = [ + self::TOKEN => 'CSRF token mismatch', + self::REFERER => 'CSRF referer mismatch', + ]; + + /** @var array> */ + private static array $lexicons = []; + + /** + * The localized message. `$language` is a manager language code (`en`, `de`...); null asks the + * current configuration, which already holds the user's own manager_language. + */ + public static function text(string $key = self::TOKEN, ?string $language = null): string + { + $key = isset(self::FALLBACK[$key]) ? $key : self::TOKEN; + $text = self::lexicon($language ?? self::managerLanguage())[$key] ?? null; + if (!is_string($text) || trim($text) === '') { + $text = self::FALLBACK[$key]; + } + + return $text; + } + + /** + * The JSON body of a refusal: `error` and `code` stay machine-readable for existing callers, + * `message` is what to show. + * + * @return array{error: string, code: string, message: string} + */ + public static function payload(string $key = self::TOKEN, ?string $language = null): array + { + $key = isset(self::FALLBACK[$key]) ? $key : self::TOKEN; + + return [ + 'error' => self::ERRORS[$key], + 'code' => $key, + 'message' => self::text($key, $language), + ]; + } + + /** + * Whether the caller is a script that shows the response body itself: an XHR, a JSON client, + * or a legacy form that posts `ajax=1` (the login form) and alerts whatever comes back. + * + * @param array $server A `$_SERVER`-shaped array. + * @param array $post + */ + public static function wantsScriptResponse(array $server, array $post = []): bool + { + return strcasecmp((string)($server['HTTP_X_REQUESTED_WITH'] ?? ''), 'XMLHttpRequest') === 0 + || stripos((string)($server['HTTP_ACCEPT'] ?? ''), 'json') !== false + || (is_scalar($post['ajax'] ?? null) && (string)$post['ajax'] === '1'); + } + + /** @return array */ + private static function lexicon(string $language): array + { + // a language code only: the value names a file to include + if (!preg_match('/^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})?$/i', $language)) { + $language = 'en'; + } + if (isset(self::$lexicons[$language])) { + return self::$lexicons[$language]; + } + + $dir = dirname(__DIR__, 2) . '/lang/'; + $lexicon = self::read($dir . 'en/global.php'); + if ($language !== 'en') { + $lexicon = array_merge($lexicon, self::read($dir . $language . '/global.php')); + } + + return self::$lexicons[$language] = $lexicon; + } + + /** @return array */ + private static function read(string $file): array + { + if (!is_file($file)) { + return []; + } + + return (static function (string $file): array { + $_lang = []; + include $file; + + return $_lang; + })($file); + } + + private static function managerLanguage(): string + { + // the instance evo() keeps, if it was built: calling evo() here could build it, or re-enter its referer check + $evo = $GLOBALS['evo'] ?? null; + + return is_object($evo) && method_exists($evo, 'getConfig') ? (string)$evo->getConfig('manager_language', 'en') : 'en'; + } +} diff --git a/core/src/Support/ManagerReferer.php b/core/src/Support/ManagerReferer.php new file mode 100644 index 0000000000..152790bba7 --- /dev/null +++ b/core/src/Support/ManagerReferer.php @@ -0,0 +1,60 @@ + $server A `$_SERVER`-shaped array. + */ + public static function allows(array $server, string $siteUrl, string $serverProtocol = 'http'): bool + { + $referer = $server['HTTP_REFERER'] ?? null; + + if (is_string($referer) && $referer !== '') { + foreach (self::siteUrls($siteUrl, $serverProtocol) as $url) { + if (stripos($referer, $url) === 0) { + return true; + } + } + + return false; + } + + if (strtolower((string)($server['HTTP_SEC_FETCH_SITE'] ?? '')) === 'same-origin') { + return true; + } + + // an empty Referer header was refused even on GET; only an absent one lets a GET through + return $referer === null && strtoupper((string)($server['REQUEST_METHOD'] ?? 'GET')) === 'GET'; + } + + /** + * @return list + */ + public static function siteUrls(string $siteUrl, string $serverProtocol = 'http'): array + { + $urls = [$siteUrl]; + if (strtolower(trim($serverProtocol)) === 'https' && stripos($siteUrl, 'http://') === 0) { + $urls[] = 'https://' . substr($siteUrl, 7); + } + + return $urls; + } +} diff --git a/core/tests/Unit/Security/CsrfMessageTest.php b/core/tests/Unit/Security/CsrfMessageTest.php new file mode 100644 index 0000000000..cd69a38b62 --- /dev/null +++ b/core/tests/Unit/Security/CsrfMessageTest.php @@ -0,0 +1,136 @@ +toBe($en['csrf_token_mismatch']) + ->and(CsrfMessage::text(CsrfMessage::REFERER, 'en'))->toBe($en['csrf_referer_mismatch']); +}); + +it('answers in every shipped manager language, falling back to English per entry', function (string $language) { + $en = csrfMessageLexicon('en'); + $own = csrfMessageLexicon($language); + + foreach ([CsrfMessage::TOKEN, CsrfMessage::REFERER] as $key) { + $expected = isset($own[$key]) && trim((string)$own[$key]) !== '' ? $own[$key] : $en[$key]; + $text = CsrfMessage::text($key, $language); + + expect($text)->toBe($expected) + ->and($text)->not->toMatch('/<[a-z!\/]/i') + // a renamed manager folder keeps it from bots: a refusal never says where it is + ->and($text)->not->toMatch('~https?://|/manager|%s|\[\+|MGR_DIR~i'); + } +})->with(fn () => array_map('basename', glob(dirname(__DIR__, 3) . '/lang/*', GLOB_ONLYDIR))); + +it('never includes a file named by an unexpected language value', function (string $language) { + expect(CsrfMessage::text(CsrfMessage::TOKEN, $language))->toBe(csrfMessageLexicon('en')['csrf_token_mismatch']); +})->with(['../../../etc/passwd', 'en/../de', 'english', '', 'zz']); + +it('takes the language of the configuration evo() already holds', function () { + $GLOBALS['evo'] = new class { + public function getConfig($key, $default = null) + { + return $key === 'manager_language' ? 'de' : $default; + } + }; + + expect(CsrfMessage::text())->toBe(CsrfMessage::text(CsrfMessage::TOKEN, 'de')); +}); + +it('falls back to English without a built evo()', function () { + expect(CsrfMessage::text())->toBe(CsrfMessage::text(CsrfMessage::TOKEN, 'en')); +}); + +it('keeps the machine-readable fields of the JSON refusal and adds the message', function () { + expect(CsrfMessage::payload())->toBe([ + 'error' => 'CSRF token mismatch', + 'code' => 'csrf_token_mismatch', + 'message' => CsrfMessage::text(), + ])->and(CsrfMessage::payload(CsrfMessage::REFERER, 'en'))->toBe([ + 'error' => 'CSRF referer mismatch', + 'code' => 'csrf_referer_mismatch', + 'message' => CsrfMessage::text(CsrfMessage::REFERER, 'en'), + ]); +}); + +it('treats an unknown key as the token refusal', function () { + expect(CsrfMessage::text('nope', 'en'))->toBe(CsrfMessage::text(CsrfMessage::TOKEN, 'en')) + ->and(CsrfMessage::payload('nope')['code'])->toBe('csrf_token_mismatch'); +}); + +it('recognises the callers that show the response body themselves', function (array $server, array $post, bool $script) { + expect(CsrfMessage::wantsScriptResponse($server, $post))->toBe($script); +})->with([ + 'XHR' => [['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'], [], true], + 'JSON client' => [['HTTP_ACCEPT' => 'application/json'], [], true], + 'AJAX login form' => [[], ['ajax' => '1'], true], + 'page load' => [['HTTP_ACCEPT' => 'text/html,*/*'], [], false], + 'ajax array' => [[], ['ajax' => ['1']], false], + 'ajax=0' => [[], ['ajax' => '0'], false], +]); + +it('is used by every CSRF check instead of a hard-coded message', function (string $file, string $removed) { + $source = file_get_contents(dirname(__DIR__, 4) . '/' . $file); + + expect($source)->toContain('CsrfMessage') + ->and($source)->not->toContain($removed); +})->with([ + ['core/src/Middleware/VerifyCsrfToken.php', 'The session token has changed or is missing'], + ['core/functions/preload.php', 'A possible CSRF attempt was detected'], + ['manager/actions/files.dynamic.php', 'Invalid token'], + ['manager/media/browser/mcpuk/browse.php', 'Invalid CSRF token.'], +]); + +it('answers a script caller of the referer check with JSON, not the alert page', function () { + $source = file_get_contents(dirname(__DIR__, 3) . '/functions/preload.php'); + $function = substr($source, strpos($source, 'function rejectManagerReferer')); + + expect(strpos($function, 'wantsScriptResponse'))->toBeLessThan(strpos($function, 'webAlertAndQuit')) + ->and($function)->toContain('http_response_code(403)') + // shutdown callbacks call evo() again after the exit; the refusal must not be printed twice + ->and(strpos($function, 'static $refused'))->toBeLessThan(strpos($function, 'wantsScriptResponse')) + // the language comes from the instance under construction: evo() would run the check again + ->and($function)->toContain("\$evo->getConfig('manager_language'"); +}); + +it('lets the login form alert only the text of a refusal', function (string $template) { + $source = file_get_contents(dirname(__DIR__, 4) . '/manager/media/style/' . $template . '/login.tpl'); + + expect($source)->not->toContain('alert(this.response)') + ->and($source)->toContain('alert(loginResponseMessage(this))') + ->and($source)->toContain('body.message') + ->and($source)->toContain("DOMParser().parseFromString(text, 'text/html')"); +})->with(['default', 'common']); diff --git a/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php b/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php index aec557222b..28bc350561 100644 --- a/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php +++ b/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php @@ -273,6 +273,20 @@ function csrfPassed(Request $request): bool ->toMatchArray(['kind' => 'text', 'status' => 403]); })->with(['text/html', '*/*', 'application/json']); +it('explains the rejection with the localized lexicon text, not markup', function () { + $middleware = new VerifyCsrfToken(); + $noop = fn () => 'next'; + + $json = $middleware->handle(csrfRequest('POST', ['a' => 109], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']), $noop); + $text = $middleware->handle(csrfRequest('GET', ['a' => 6, 'id' => 3]), $noop); + + expect($json['data'])->toBe(\EvolutionCMS\Support\CsrfMessage::payload()) + ->and($json['data']['code'])->toBe('csrf_token_mismatch') + ->and($text['data'])->toBe(\EvolutionCMS\Support\CsrfMessage::text()) + ->and($text['data'])->not->toContain('<') + ->and($text['headers']['Content-Type'])->toBe('text/plain; charset=UTF-8'); +}); + it('answers non-XHR JSON clients with a JSON rejection', function () { $request = csrfRequest('POST', ['a' => 109], ['HTTP_ACCEPT' => 'application/json']); diff --git a/core/tests/Unit/Security/ManagerRefererTest.php b/core/tests/Unit/Security/ManagerRefererTest.php new file mode 100644 index 0000000000..b7a40fac36 --- /dev/null +++ b/core/tests/Unit/Security/ManagerRefererTest.php @@ -0,0 +1,100 @@ + 'POST'], MANAGER_REFERER_SITE, $protocol); +} + +it('accepts a referer from the site itself, in any letter case', function (string $referer) { + expect(managerRefererAllows(['HTTP_REFERER' => $referer]))->toBeTrue(); +})->with(['http://example.test/sub/manager/', 'HTTP://EXAMPLE.TEST/sub/manager/index.php?a=2']); + +it('refuses a foreign referer, whatever else the request claims', function (string $referer) { + expect(managerRefererAllows(['HTTP_REFERER' => $referer, 'HTTP_SEC_FETCH_SITE' => 'same-origin']))->toBeFalse() + ->and(managerRefererAllows(['HTTP_REFERER' => $referer, 'REQUEST_METHOD' => 'GET']))->toBeFalse(); +})->with([ + 'other site' => 'http://evil.test/', + 'look-alike host' => 'http://example.test.evil.test/sub/', + 'site as a path' => 'http://evil.test/http://example.test/sub/', + 'outside the base path' => 'http://example.test/other/', + 'other port' => 'http://example.test:8080/sub/', +]); + +it('accepts a POST without a referer that the browser marks as same-origin', function () { + expect(managerRefererAllows(['HTTP_SEC_FETCH_SITE' => 'same-origin']))->toBeTrue() + ->and(managerRefererAllows(['HTTP_REFERER' => '', 'HTTP_SEC_FETCH_SITE' => 'Same-Origin']))->toBeTrue(); +}); + +it('refuses a POST without a referer from anywhere but the same origin', function (?string $site) { + $server = $site === null ? [] : ['HTTP_SEC_FETCH_SITE' => $site]; + + expect(managerRefererAllows($server))->toBeFalse() + ->and(managerRefererAllows($server + ['HTTP_REFERER' => '']))->toBeFalse(); +})->with([null, 'same-site', 'cross-site', 'none', '']); + +it('keeps letting a GET without a referer through, but not one with an empty referer', function () { + expect(managerRefererAllows(['REQUEST_METHOD' => 'GET']))->toBeTrue() + ->and(managerRefererAllows(['REQUEST_METHOD' => 'GET', 'HTTP_REFERER' => '']))->toBeFalse(); +}); + +it('accepts the https referer of an http site URL once the site is declared https', function () { + $referer = ['HTTP_REFERER' => 'https://example.test/sub/manager/']; + + expect(managerRefererAllows($referer, 'http'))->toBeFalse() + ->and(managerRefererAllows($referer, 'https'))->toBeTrue() + ->and(ManagerReferer::siteUrls(MANAGER_REFERER_SITE, 'https')) + ->toBe([MANAGER_REFERER_SITE, 'https://example.test/sub/']); +}); + +it('never lets http count for an https site', function () { + expect(ManagerReferer::allows( + ['REQUEST_METHOD' => 'POST', 'HTTP_REFERER' => 'http://example.test/sub/manager/'], + 'https://example.test/sub/', + 'https' + ))->toBeFalse(); +}); + +it('is what evo() asks, with the Server type setting', function () { + $source = file_get_contents(dirname(__DIR__, 3) . '/functions/preload.php'); + + expect($source)->toContain('ManagerReferer::allows(') + ->and($source)->toContain("getConfig('server_protocol'") + ->and($source)->not->toContain("preg_quote(EVO_SITE_URL"); +}); + +it('asks the browser for a same-origin referrer on every manager page that posts', function (string $file) { + expect(file_get_contents(dirname(__DIR__, 4) . '/' . $file)) + ->toContain(''); +})->with([ + 'manager/media/style/default/login.tpl', + 'manager/media/style/common/login.tpl', + 'manager/media/style/default/manager.lockout.tpl', + 'manager/media/style/common/manager.lockout.tpl', + 'manager/views/frame/1.blade.php', + 'manager/views/partials/header.blade.php', + 'manager/media/browser/mcpuk/tpl/tpl_browser.php', +]); + +it('sends the same policy as a header before the referer check runs', function () { + $index = file_get_contents(dirname(__DIR__, 4) . '/manager/index.php'); + + expect($index)->toContain("header('Referrer-Policy: same-origin');") + ->and(strpos($index, "header('Referrer-Policy: same-origin');"))->toBeLessThan(strpos($index, '= evo();')); +}); diff --git a/core/vendor/composer/autoload_classmap.php b/core/vendor/composer/autoload_classmap.php index 105b3d84d9..9e57aa9fe3 100644 --- a/core/vendor/composer/autoload_classmap.php +++ b/core/vendor/composer/autoload_classmap.php @@ -1425,6 +1425,7 @@ 'EvolutionCMS\\Support\\ChunkFileStore' => $baseDir . '/src/Support/ChunkFileStore.php', 'EvolutionCMS\\Support\\ClientIp' => $baseDir . '/src/Support/ClientIp.php', 'EvolutionCMS\\Support\\ContextMenu' => $baseDir . '/src/Support/ContextMenu.php', + 'EvolutionCMS\\Support\\CsrfMessage' => $baseDir . '/src/Support/CsrfMessage.php', 'EvolutionCMS\\Support\\DataGrid' => $baseDir . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => $baseDir . '/src/Support/DataSetPager.php', 'EvolutionCMS\\Support\\DocBlock' => $baseDir . '/src/Support/DocBlock.php', @@ -1443,6 +1444,7 @@ 'EvolutionCMS\\Support\\MailTestMailer' => $baseDir . '/src/Support/MailTestMailer.php', 'EvolutionCMS\\Support\\MailTestSmtp' => $baseDir . '/src/Support/MailTestSmtp.php', 'EvolutionCMS\\Support\\MakeTable' => $baseDir . '/src/Support/MakeTable.php', + 'EvolutionCMS\\Support\\ManagerReferer' => $baseDir . '/src/Support/ManagerReferer.php', 'EvolutionCMS\\Support\\Menu' => $baseDir . '/src/Support/Menu.php', 'EvolutionCMS\\Support\\ModuleAccess' => $baseDir . '/src/Support/ModuleAccess.php', 'EvolutionCMS\\Support\\MoveDocumentTargetGuard' => $baseDir . '/src/Support/MoveDocumentTargetGuard.php', diff --git a/core/vendor/composer/autoload_static.php b/core/vendor/composer/autoload_static.php index 0cb2fd5c1c..3e2e63361d 100644 --- a/core/vendor/composer/autoload_static.php +++ b/core/vendor/composer/autoload_static.php @@ -2092,6 +2092,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\ChunkFileStore' => __DIR__ . '/../..' . '/src/Support/ChunkFileStore.php', 'EvolutionCMS\\Support\\ClientIp' => __DIR__ . '/../..' . '/src/Support/ClientIp.php', 'EvolutionCMS\\Support\\ContextMenu' => __DIR__ . '/../..' . '/src/Support/ContextMenu.php', + 'EvolutionCMS\\Support\\CsrfMessage' => __DIR__ . '/../..' . '/src/Support/CsrfMessage.php', 'EvolutionCMS\\Support\\DataGrid' => __DIR__ . '/../..' . '/src/Support/DataGrid.php', 'EvolutionCMS\\Support\\DataSetPager' => __DIR__ . '/../..' . '/src/Support/DataSetPager.php', 'EvolutionCMS\\Support\\DocBlock' => __DIR__ . '/../..' . '/src/Support/DocBlock.php', @@ -2110,6 +2111,7 @@ class ComposerStaticInit925fea465a58fa69f06ccf2629003e87 'EvolutionCMS\\Support\\MailTestMailer' => __DIR__ . '/../..' . '/src/Support/MailTestMailer.php', 'EvolutionCMS\\Support\\MailTestSmtp' => __DIR__ . '/../..' . '/src/Support/MailTestSmtp.php', 'EvolutionCMS\\Support\\MakeTable' => __DIR__ . '/../..' . '/src/Support/MakeTable.php', + 'EvolutionCMS\\Support\\ManagerReferer' => __DIR__ . '/../..' . '/src/Support/ManagerReferer.php', 'EvolutionCMS\\Support\\Menu' => __DIR__ . '/../..' . '/src/Support/Menu.php', 'EvolutionCMS\\Support\\ModuleAccess' => __DIR__ . '/../..' . '/src/Support/ModuleAccess.php', 'EvolutionCMS\\Support\\MoveDocumentTargetGuard' => __DIR__ . '/../..' . '/src/Support/MoveDocumentTargetGuard.php', diff --git a/manager/actions/files.dynamic.php b/manager/actions/files.dynamic.php index 537a330915..e9b2614711 100755 --- a/manager/actions/files.dynamic.php +++ b/manager/actions/files.dynamic.php @@ -7,6 +7,7 @@ } $token_check = checkToken(); $newToken = makeToken(); +$tokenRefusedMessage = '' . e(\EvolutionCMS\Support\CsrfMessage::text()) . '

'; // settings $theme_image_path = EVO_MANAGER_URL . 'media/style/' . evo()->getConfig('manager_theme') . '/images/'; $excludes = [ @@ -157,11 +158,11 @@ function fileManagerDeleteDirectoryZip(array $zipPaths): void fileManagerDeleteDirectoryZip($directoryZipPaths); $directoryZipMessage = '' . $_lang['files_zip_deleted'] . '

'; } else { - $directoryZipMessage = 'Invalid token

'; + $directoryZipMessage = $tokenRefusedMessage; } } elseif (get_by_key($_REQUEST, 'mode') == 'downloadzip') { if (!$token_check) { - $directoryZipMessage = 'Invalid token

'; + $directoryZipMessage = $tokenRefusedMessage; } elseif ($directoryZipBlocked) { $directoryZipMessage = '' . $_lang['files_zip_unavailable'] . '

'; } elseif (fileManagerDirectoryZipExists($directoryZipPaths)) { @@ -333,19 +334,19 @@ function renameFile(file) { if ($token_check) { $information = fileupload(); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } elseif (get_by_key($_POST, 'mode') == 'save') { if ($token_check) { echo textsave(); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } elseif (get_by_key($_REQUEST, 'mode') == 'delete') { if ($token_check) { echo delete_file(); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } elseif (get_by_key($_POST, 'mode') == 'savegroups') { if ($token_check) { @@ -421,7 +422,7 @@ function renameFile(file) { } } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } echo $directoryZipMessage; @@ -521,7 +522,7 @@ function renameFile(file) { } } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // End Unzip - Raymond @@ -549,7 +550,7 @@ function renameFile(file) { \EvolutionCMS\Support\FileManagerAccess::forgetRestrictions(fileManagerAclKey($folderTarget['relative'])); } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Create folder here @@ -572,7 +573,7 @@ function renameFile(file) { } umask($old_umask); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Create file here @@ -595,7 +596,7 @@ function renameFile(file) { umask($old_umask); } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Duplicate file here @@ -634,7 +635,7 @@ function renameFile(file) { } } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Copy folder here, beside the original @@ -715,7 +716,7 @@ function renameFile(file) { } umask($old_umask); } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Delete a link here: the link itself, never what it leads to @@ -741,7 +742,7 @@ function renameFile(file) { echo '' . $_lang['files_link_deleted'] . '

'; } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Rename folder here @@ -782,7 +783,7 @@ function renameFile(file) { umask($old_umask); } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } // Rename file here @@ -820,7 +821,7 @@ function renameFile(file) { } } } else { - echo 'Invalid token

'; + echo $tokenRefusedMessage; } } } // End New Folder - Raymond diff --git a/manager/index.php b/manager/index.php index 79169f1bac..b032b2ca25 100755 --- a/manager/index.php +++ b/manager/index.php @@ -116,6 +116,8 @@ header('Pragma: no-cache'); header('X-UA-Compatible: IE=edge;FF=3;OtherUA=4'); header('X-XSS-Protection: 0'); +// The manager's own POSTs need a Referer (validate_referer), whatever policy a proxy sets; other sites get none +header('Referrer-Policy: same-origin'); $php_ver_comp = version_compare(phpversion(), "8.3"); // -1 if left is less, 0 if equal, +1 if left is higher diff --git a/manager/media/browser/mcpuk/browse.php b/manager/media/browser/mcpuk/browse.php index 14a8b90962..ff7d19d1b1 100755 --- a/manager/media/browser/mcpuk/browse.php +++ b/manager/media/browser/mcpuk/browse.php @@ -49,7 +49,7 @@ function returnNoPermissionsMessage($role) { if ($token === '' || !hash_equals(csrf_token(), $token)) { header('HTTP/1.1 403 Forbidden'); header('Content-Type: text/plain; charset=utf-8'); - die(json_encode(['error' => 'Invalid CSRF token.'])); + die(json_encode(['error' => \EvolutionCMS\Support\CsrfMessage::text()], JSON_UNESCAPED_UNICODE)); } } diff --git a/manager/media/browser/mcpuk/tpl/tpl_browser.php b/manager/media/browser/mcpuk/tpl/tpl_browser.php index b6810b692e..4660b0afad 100755 --- a/manager/media/browser/mcpuk/tpl/tpl_browser.php +++ b/manager/media/browser/mcpuk/tpl/tpl_browser.php @@ -5,6 +5,7 @@ KCFinder: / <?php echo $this->session['dir'] ?> + diff --git a/manager/media/script/tests/document-save-helper.test.js b/manager/media/script/tests/document-save-helper.test.js index 643f0f241c..cde0bbe487 100644 --- a/manager/media/script/tests/document-save-helper.test.js +++ b/manager/media/script/tests/document-save-helper.test.js @@ -52,8 +52,8 @@ test('parseResponse tells a save apart from a refusal and from a non-JSON answer const denied = helper.parseResponse(422, '{"success":false,"message":"duplicate alias"}'); assert.deepEqual(denied, { ok: false, message: 'duplicate alias', unconfirmed: false }); - const csrf = helper.parseResponse(403, '{"error":"CSRF token mismatch","code":"csrf_token_mismatch"}'); - assert.deepEqual(csrf, { ok: false, message: '', unconfirmed: false }); + const csrf = helper.parseResponse(403, '{"error":"CSRF token mismatch","code":"csrf_token_mismatch","message":"Reload the page"}'); + assert.deepEqual(csrf, { ok: false, message: 'Reload the page', unconfirmed: false }); // the login page, a plugin that exited, a fatal error: the save may have run and is never replayed assert.deepEqual(helper.parseResponse(200, 'login'), { ok: false, message: '', unconfirmed: true }); diff --git a/manager/media/style/common/login.tpl b/manager/media/style/common/login.tpl index 71e99bd584..27da22f2d2 100755 --- a/manager/media/style/common/login.tpl +++ b/manager/media/style/common/login.tpl @@ -3,6 +3,7 @@ [+site_name_text+] (Evolution CMS Manager Login) + @@ -91,6 +92,23 @@ window.location = self.document.location; } var form = document.loginfrm; + // A refusal can come back as JSON (CSRF checks) or as a whole page; alert only its text. + function loginResponseMessage(xhr) { + var text = String(xhr.responseText || ''); + try { + var body = JSON.parse(text); + if (body && typeof body.message === 'string' && body.message !== '') { + return body.message; + } + } catch (err) {} + if (/^\s* [+site_name_text+] (Evolution CMS Content Manager) +