From d003e103ffd25b07ce77d468d6cd1de18d5df70d Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 11 Aug 2026 18:00:39 +0200 Subject: [PATCH] fix(core): CSRF fixes --- AGENTS.md | 36 ++ .../plugins/transalias/transalias.class.php | 8 + core/config/session.php | 8 +- core/src/Controllers/Frame.php | 8 +- core/src/Controllers/TmplvarRank.php | 2 +- core/src/Middleware/VerifyCsrfToken.php | 167 ++++- .../FileManagerZipDownloadLoaderTest.php | 74 +++ .../Unit/Security/ManagerCsrfCoverageTest.php | 288 +++++++++ .../Security/ManagerCsrfMiddlewareTest.php | 234 +++++++ .../Unit/Security/ManagerReflectedXssTest.php | 133 ++++ .../controllers/connection/databasetest.php | 12 +- install/src/functions.php | 13 +- manager/actions/bkmanager.static.php | 11 +- .../actions/category_mgr/skin/add.tpl.phtml | 1 + .../category_mgr/skin/categorize.tpl.phtml | 1 + .../skin/chunks/db_setup.tpl.phtml | 1 + .../actions/category_mgr/skin/edit.tpl.phtml | 1 + .../category_mgr/skin/js/categories.js | 3 +- .../actions/category_mgr/skin/sort.tpl.phtml | 1 + .../category_mgr/skin/translate.tpl.phtml | 1 + manager/actions/files.dynamic.php | 9 +- manager/actions/logging.static.php | 609 +++++++++--------- manager/actions/mutate_content.dynamic.php | 17 +- .../actions/mutate_menuindex_sort.dynamic.php | 1 + manager/actions/mutate_module.dynamic.php | 9 +- .../mutate_module_resources.dynamic.php | 1 + .../mutate_template_tv_rank.dynamic.php | 3 +- manager/actions/mutate_web_user.dynamic.php | 55 +- manager/actions/resource_selector.static.php | 25 +- .../actions/web_user_management.static.php | 3 +- manager/includes/active_user_locks.inc.php | 2 +- manager/media/script/main.js | 27 +- manager/media/script/mutate_settings.js | 2 + .../default/chunks/welcome/StartUpScript.tpl | 3 +- .../default/snippets/welcome/RecentInfo.php | 230 +++---- .../processors/delete_tmplvars.processor.php | 2 +- .../processors/save_settings.processor.php | 4 + manager/views/frame/1.blade.php | 1 + manager/views/page/3.blade.php | 12 +- manager/views/page/chunk.blade.php | 5 +- manager/views/page/eventlog.blade.php | 7 +- manager/views/page/eventlog_details.blade.php | 2 +- manager/views/page/modules.blade.php | 6 +- manager/views/page/move_document.blade.php | 1 + manager/views/page/password.blade.php | 1 + manager/views/page/plugin.blade.php | 5 +- manager/views/page/plugin_priority.blade.php | 1 + manager/views/page/refresh_site.blade.php | 2 +- manager/views/page/search.blade.php | 1 + manager/views/page/snippet.blade.php | 5 +- manager/views/page/system_settings.blade.php | 1 + manager/views/page/template.blade.php | 5 +- manager/views/page/tmplvar.blade.php | 5 +- manager/views/page/tmplvar_rank.blade.php | 1 + manager/views/page/update_tree.blade.php | 1 + .../page/user_roles/permission.blade.php | 1 + .../user_roles/permissions_groups.blade.php | 1 + .../views/page/user_roles/user_role.blade.php | 1 + .../page/web_access_permissions.blade.php | 11 +- manager/views/partials/header.blade.php | 2 + 60 files changed, 1556 insertions(+), 527 deletions(-) create mode 100644 core/tests/Unit/Manager/FileManagerZipDownloadLoaderTest.php create mode 100644 core/tests/Unit/Security/ManagerCsrfCoverageTest.php create mode 100644 core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php create mode 100644 core/tests/Unit/Security/ManagerReflectedXssTest.php diff --git a/AGENTS.md b/AGENTS.md index 6a9d052b62..f7f7d4d866 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,6 +276,41 @@ manager. Prefer these, in this order: that is HTML by contract and outside the CMS's control (plugin event results, `phpinfo()`, registered client scripts). +### CSRF rules for manager actions + +Manager routes run through `EvolutionCMS\Middleware\VerifyCsrfToken` (registered in the `mgr` +group in `core/config/app.php`). It **fails closed**: once a manager session exists, every +non-GET request must present a `_token` that matches `$_SESSION['_token']`, or it is rejected +with 403. Adding a state-changing entry point without a token does not degrade quietly — it +breaks. + +When you add or change a manager action, work through this: + +| If you are adding… | You must | +|---|---| +| A form that changes anything | Emit `@csrf` (Blade) or `` (legacy `.php` / `.phtml`) inside the `
` | +| JS that posts to `index.php` | Send `_token` in the body, or set the `X-CSRF-TOKEN` header. Read it from `` (in `manager/views/partials/header.blade.php`) or from a form field on the page | +| An action that changes state and reads `$_GET` or `$_REQUEST` | Add its action id to `VerifyCsrfToken::MUTATING_GET_ACTIONS`, **and** append `&_token=` to every link that triggers it | +| Code that walks the whole `$_POST` body | Skip the `_token` key, or it will be saved as data | + +Two traps worth stating outright, because both have already caused bugs here: + +- **Page controllers count, not just processors.** An action id can map straight to a class in + `ManagerTheme::$actions` with no file under `manager/processors/`. Auditing only the processor + directory misses them — that is how `a=90` (`DeleteUser`, deletes a user straight from + `$_GET`), `a=92`, `a=52` and `a=26` were initially left unguarded. +- **`$_REQUEST` means GET works.** A processor whose UI only ever posts is still reachable by + query string if it reads `$_REQUEST`, so it belongs in `MUTATING_GET_ACTIONS` too. + +Already covered, do not add a second scheme: requests with no manager session (the login flow is +deliberately exempt), the theme AJAX endpoints that require `X-Requested-With: XMLHttpRequest` +plus POST (`manager/media/style/*/ajax.php`), and the file manager's own single-use +`checkToken()` / `makeToken()` pair in `core/functions/actions/files.php`. + +Both halves are pinned by `core/tests/Unit/Security/ManagerCsrfCoverageTest.php`, which scans the +shipped views for untokenised forms and links. It checks against hand-maintained lists, so extend +those lists when you add an action — a green suite is not proof that a new action is guarded. + --- ## Dependencies of Note @@ -297,4 +332,5 @@ registered client scripts). - **Do not modify `core/config/`** for site-specific settings — use `core/custom/config/` instead. - **Do not add files to `assets/cache/`** — it is auto-generated and git-ignored. - **Do not use `$modx`** — it is deprecated. Use `evo()` helper. +- **Do not add a manager action that changes state without a CSRF token.** See *CSRF rules for manager actions*. A state-changing action reachable by GET must also be listed in `VerifyCsrfToken::MUTATING_GET_ACTIONS`. - **PHPStan error count must not increase.** Run `composer analyze` before submitting a PR. diff --git a/assets/plugins/transalias/transalias.class.php b/assets/plugins/transalias/transalias.class.php index 218f2accfc..2bb5512162 100755 --- a/assets/plugins/transalias/transalias.class.php +++ b/assets/plugins/transalias/transalias.class.php @@ -182,6 +182,14 @@ function loadTable($name, $remove_periods = 'No') { if (empty($name) || !is_string($name)) { return false; } + // The name is interpolated into an include() below. It normally comes from the plugin's + // own configuration, but when the "Override TV name" property is set it comes from a + // template variable, which any content editor can write - so a traversal here would be + // an include of an attacker-chosen file. Table names are plain file stems; anything + // that could climb out of the transliterations directory is rejected outright. + if ($name !== basename($name) || strpbrk($name, "/\\\0:") !== false) { + return false; + } if (isset ($this->_tables[$name] )) { $this->_useTable = $name; return true; diff --git a/core/config/session.php b/core/config/session.php index 550881215a..6434fea78d 100644 --- a/core/config/session.php +++ b/core/config/session.php @@ -172,11 +172,13 @@ |-------------------------------------------------------------------------- | | This option determines how your cookies behave when cross-site requests - | take place, and can be used to mitigate CSRF attacks. By default, we - | do not enable this as other CSRF protection services are in place. + | take place. "lax" keeps the session cookie off cross-site POST requests, + | which is defence in depth behind the CSRF token check in + | EvolutionCMS\Middleware\VerifyCsrfToken - not a replacement for it, since + | browsers disagree on the default and it does not cover same-site content. | | Supported: "lax", "strict" | */ - 'same_site' => null, + 'same_site' => 'lax', ]; diff --git a/core/src/Controllers/Frame.php b/core/src/Controllers/Frame.php index 08738da38a..f3926f6beb 100644 --- a/core/src/Controllers/Frame.php +++ b/core/src/Controllers/Frame.php @@ -701,7 +701,7 @@ protected function menuRunModules() 'module' . $row['id'], 'modules', $this->moduleIconHtml($row['icon']) . e($row['name']), - 'index.php?a=112&id=' . $row['id'], + 'index.php?a=112&_token=' . csrf_token() . '&id=' . $row['id'], e($row['name']), '', '', @@ -721,7 +721,7 @@ protected function menuRunModules() 'module' . $module['id'], 'modules', $this->moduleIconHtml($module['icon']) . $module['name'], - !empty($module['properties']['routes']) ? 'modules/' . $module['id'] : 'index.php?a=112&id=' . $module['id'], + !empty($module['properties']['routes']) ? 'modules/' . $module['id'] : 'index.php?a=112&_token=' . csrf_token() . '&id=' . $module['id'], $module['name'], '', '', @@ -809,7 +809,7 @@ protected function menuRefreshSite() 'refresh_site', 'tools', $this->menuIcon('icon_recycle') . $this->managerTheme->getLexicon('refresh_site'), - 'index.php?a=26', + 'index.php?a=26&_token=' . csrf_token(), $this->managerTheme->getLexicon('refresh_site'), '', '', @@ -825,7 +825,7 @@ protected function menuRefreshSite() // href 'btn btn-secondary', // class or btn-success - "evo.popup({url:'index.php?a=26', title:'" . $this->managerTheme->getLexicon('refresh_site') . "', icon: 'fa-recycle', iframe: 'ajax', selector: '.tab-page>.container', position: 'right top', width: 'auto', maxheight: '50%%', wrap: 'body' })", + "evo.popup({url:'index.php?a=26&_token=" . csrf_token() . "', title:'" . $this->managerTheme->getLexicon('refresh_site') . "', icon: 'fa-recycle', iframe: 'ajax', selector: '.tab-page>.container', position: 'right top', width: 'auto', maxheight: '50%%', wrap: 'body' })", // onclick $this->managerTheme->getLexicon('refresh_site'), // title diff --git a/core/src/Controllers/TmplvarRank.php b/core/src/Controllers/TmplvarRank.php index d129f9c4fd..bbec69db28 100644 --- a/core/src/Controllers/TmplvarRank.php +++ b/core/src/Controllers/TmplvarRank.php @@ -57,7 +57,7 @@ protected function update() if (isset($_POST['listSubmitted'])) { foreach ($_POST as $listName => $listValue) { - if ($listName == 'listSubmitted' || $listName == 'reset') { + if ($listName == 'listSubmitted' || $listName == 'reset' || $listName == '_token') { continue; } $orderArray = explode(';', rtrim($listValue, ';')); diff --git a/core/src/Middleware/VerifyCsrfToken.php b/core/src/Middleware/VerifyCsrfToken.php index 869480e809..d2c40f20d0 100644 --- a/core/src/Middleware/VerifyCsrfToken.php +++ b/core/src/Middleware/VerifyCsrfToken.php @@ -1,15 +1,176 @@ ` tag. + */ + protected const MUTATING_GET_ACTIONS = [ + 6, // delete_content + 21, // delete_template + 24, // save_snippet (?disabled= toggle) + 25, // delete_snippet + 26, // RefreshSite - publishes/unpublishes pending documents and clears the cache + 52, // MoveDocument - reparents from $_REQUEST; the UI posts, but GET reaches it too + 54, // optimize_table + 55, // empty_table + 61, // publish_content + 62, // unpublish_content + 63, // undelete_content + 64, // remove_content + 67, // remove_locks + 79, // save_htmlsnippet (?disabled= toggle) + 80, // delete_htmlsnippet + 90, // DeleteUser - deletes a manager user straight from $_GET + 92, // web_access_groups - add/remove group couplings from $_REQUEST['operation'] + 94, // duplicate_content + 96, // duplicate_template + 97, // duplicate_htmlsnippet + 98, // duplicate_snippet + 103, // save_plugin (?disabled= toggle) + 104, // delete_plugin + 105, // duplicate_plugin + 109, // save_module (?disabled= toggle) + 110, // delete_module + 111, // duplicate_module + 112, // execute_module + 116, // delete_eventlog + 119, // purge_plugin + 303, // delete_tmplvars + 304, // duplicate_tmplvars + 501, // delete_category + ]; + + /** + * @param \Illuminate\Http\Request $request + * @param Closure $next + * + * @return mixed + */ public function handle($request, Closure $next) { - if ($request->has('_token') && isset($_SESSION['_token']) && $_SESSION['_token'] !== $request->input('_token')) { - return \Response::json(['error' => 'CSRF token mismatch'], '403'); - + if ($this->requiresVerification($request) && !$this->tokensMatch($request)) { + return $this->reject($request); } + return $next($request); } + + /** + * Decides whether the request has to present a token. + */ + protected function requiresVerification($request): bool + { + // Anonymous requests carry no privileges to abuse, and the login form is rendered + // before a Manager session exists. Verifying them would break the login flow + // without closing any cross-site vector. + if (empty($_SESSION['mgrValidated'])) { + return false; + } + + $method = strtoupper((string)$request->method()); + + if (in_array($method, self::READ_ONLY_METHODS, true)) { + return false; + } + + if ($method !== 'GET') { + return true; + } + + return in_array($this->getActionId(), self::MUTATING_GET_ACTIONS, true); + } + + /** + * Compares the submitted token against the one held in the session. + */ + protected function tokensMatch($request): bool + { + $sessionToken = (string)($_SESSION['_token'] ?? ''); + $requestToken = $this->getRequestToken($request); + + if ($sessionToken === '' || $requestToken === '') { + return false; + } + + return hash_equals($sessionToken, $requestToken); + } + + /** + * Reads the token from the request body, the query string or the AJAX header. + */ + protected function getRequestToken($request): string + { + $token = $request->input('_token', ''); + + if (!is_scalar($token) || (string)$token === '') { + $token = $request->header('X-CSRF-TOKEN', ''); + } + + return is_scalar($token) ? (string)$token : ''; + } + + /** + * Resolves the requested action the same way the Manager dispatcher does. + * + * @return int|null + */ + protected function getActionId() + { + if (isset($_GET['a'])) { + $value = $_GET['a']; + } elseif (isset($_POST['a'])) { + $value = $_POST['a']; + } else { + return null; + } + + if (!is_scalar($value)) { + return null; + } + + $action = filter_var($value, FILTER_VALIDATE_INT, [ + 'options' => [ + 'min_range' => 1, + 'max_range' => 2000, + ], + ]); + + return $action === false ? null : (int)$action; + } + + /** + * Builds the rejection response, matching the format the caller expects. + */ + protected function reject($request) + { + if ($request->ajax() || $request->wantsJson()) { + return Response::json(['error' => 'CSRF token mismatch'], 403); + } + + return Response::make('CSRF token mismatch', 403, ['Content-Type' => 'text/plain']); + } } diff --git a/core/tests/Unit/Manager/FileManagerZipDownloadLoaderTest.php b/core/tests/Unit/Manager/FileManagerZipDownloadLoaderTest.php new file mode 100644 index 0000000000..633ede4158 --- /dev/null +++ b/core/tests/Unit/Manager/FileManagerZipDownloadLoaderTest.php @@ -0,0 +1,74 @@ +not->toContain("addEventListener('unload'") + ->and($source)->toContain("addEventListener('pagehide'"); +}); + +it('still clears the pending loader timer when the page really goes away', function () { + $source = managerScriptSource('media/script/main.js'); + + expect($source)->toMatch("/addEventListener\('pagehide', function \(\) \{\s*clearTimeout\(timerForUnload\);/"); +}); + +it('bounds the loader when a navigation never commits', function () { + // Without the timer a cancelled navigation - a download, a failed load, a dismissed + // unsaved-changes prompt - leaves the loader visible with nothing left to switch it off. + $source = managerScriptSource('media/script/main.js'); + + expect($source) + ->toContain('var UNLOAD_LOADER_TIMEOUT =') + ->toContain('timerForUnload = setTimeout(stopWorker, UNLOAD_LOADER_TIMEOUT);'); +}); + +it('consumes the worker suppression flag so it cannot silence later navigation', function () { + // A download leaves the document in place, so a flag that is never reset would keep the + // loader hidden for every later navigation from that page. + $source = managerScriptSource('media/script/main.js'); + + expect($source)->toMatch('/if \(dontShowWorker\) \{\s*dontShowWorker = false;/'); +}); + +it('suppresses the loader on the file manager zip download link', function () { + $source = managerScriptSource('actions/files.dynamic.php'); + + expect($source)->toMatch('/]*onclick="dontShowWorker = true;"[^>]*mode=downloadzip/'); +}); + +it('keeps the zip download reachable in the main frame so errors stay visible', function () { + // Retargeting the link at a hidden iframe would also hide the "invalid token" and + // "zip unavailable" responses, which are rendered as an ordinary page. + $source = managerScriptSource('actions/files.dynamic.php'); + + expect($source) + ->toContain('mode=downloadzip') + ->and($source)->not->toMatch('/mode=downloadzip[^>]*target="fileDownloader"/'); +}); diff --git a/core/tests/Unit/Security/ManagerCsrfCoverageTest.php b/core/tests/Unit/Security/ManagerCsrfCoverageTest.php new file mode 100644 index 0000000000..bdbbcf2c66 --- /dev/null +++ b/core/tests/Unit/Security/ManagerCsrfCoverageTest.php @@ -0,0 +1,288 @@ + tests -> core -> repository root + return str_replace('\\', '/', dirname(__DIR__, 4)); +} + +/** + * Collects Manager template sources, skipping bundled third-party assets. + */ +function managerTemplateFiles(): array +{ + $found = []; + + foreach (['manager', 'views'] as $dir) { + $base = evoRoot() . '/' . $dir; + if (!is_dir($base)) { + continue; + } + + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base)); + + foreach ($iterator as $file) { + if (!$file->isFile()) { + continue; + } + + $path = str_replace('\\', '/', $file->getPathname()); + + // KCFinder ships its own forms and its own request handling. + if (str_contains($path, '/media/browser/') || str_contains($path, '/vendor/')) { + continue; + } + + if (preg_match('/\.(php|phtml|tpl)$/', $path)) { + $found[] = $path; + } + } + } + + sort($found); + + return $found; +} + +/** + * The login screens are rendered before a Manager session exists, so they hold no token and + * the middleware deliberately exempts them. + */ +function isLoginTemplate(string $path): bool +{ + return str_contains($path, 'login.tpl') || str_contains($path, 'manager.lockout.tpl'); +} + +it('emits a CSRF field in every state-changing manager form', function () { + $missing = []; + + foreach (managerTemplateFiles() as $path) { + if (isLoginTemplate($path)) { + continue; + } + + $lines = explode("\n", (string)file_get_contents($path)); + + foreach ($lines as $index => $line) { + if (!preg_match('/toBe([]); +}); + +it('appends a token to every link that triggers a state-changing GET action', function () { + $reflection = new ReflectionClass(VerifyCsrfToken::class); + $guarded = $reflection->getConstant('MUTATING_GET_ACTIONS'); + + expect($guarded)->not->toBeEmpty(); + + $pattern = '/index\.php\?[^"\'\s]*\ba=(' . implode('|', $guarded) . ')\b/'; + $untokenised = []; + + $files = managerTemplateFiles(); + $files[] = evoRoot() . '/core/src/Controllers/Frame.php'; + + foreach ($files as $path) { + $lines = explode("\n", (string)file_get_contents($path)); + + foreach ($lines as $index => $line) { + if (!preg_match_all($pattern, $line, $matches, PREG_SET_ORDER)) { + continue; + } + + // A link is tokenised when `_token` is emitted on the same statement, whether as a + // literal, a Blade echo or a PHP concatenation. + if (str_contains($line, '_token')) { + continue; + } + + $relative = str_replace(evoRoot() . '/', '', $path); + $untokenised[] = $relative . ':' . ($index + 1) . ' (a=' . $matches[0][1] . ')'; + } + } + + expect($untokenised)->toBe([]); +}); + +it('guards every processor that mutates state straight from the query string', function () { + // A processor that reads $_GET or $_REQUEST and writes is reachable from an tag, so + // its action id has to appear in the middleware's GET list. + $guarded = (new ReflectionClass(VerifyCsrfToken::class))->getConstant('MUTATING_GET_ACTIONS'); + + $mutatingProcessors = [ + 'delete_content' => 6, + 'delete_template' => 21, + 'delete_snippet' => 25, + 'optimize_table' => 54, + 'empty_table' => 55, + 'publish_content' => 61, + 'unpublish_content' => 62, + 'undelete_content' => 63, + 'remove_content' => 64, + 'remove_locks' => 67, + 'delete_htmlsnippet' => 80, + 'duplicate_content' => 94, + 'duplicate_template' => 96, + 'duplicate_htmlsnippet' => 97, + 'duplicate_snippet' => 98, + 'delete_plugin' => 104, + 'duplicate_plugin' => 105, + 'delete_module' => 110, + 'duplicate_module' => 111, + 'execute_module' => 112, + 'delete_eventlog' => 116, + 'purge_plugin' => 119, + 'delete_tmplvars' => 303, + 'duplicate_tmplvars' => 304, + 'delete_category' => 501, + ]; + + $unguarded = []; + + foreach ($mutatingProcessors as $name => $action) { + $path = evoRoot() . '/manager/processors/' . $name . '.processor.php'; + + expect(is_file($path))->toBeTrue(); + + if (!in_array($action, $guarded, true)) { + $unguarded[] = $name . ' (a=' . $action . ')'; + } + } + + expect($unguarded)->toBe([]); +}); + +it('guards the page controllers that mutate state from the request', function () { + // These are easy to miss when auditing manager/processors/, because a page controller has + // no processor file at all - the action id maps straight to a class in ManagerTheme. + $guarded = (new ReflectionClass(VerifyCsrfToken::class))->getConstant('MUTATING_GET_ACTIONS'); + + $mutatingControllers = [ + 26 => 'RefreshSite', // publishes/unpublishes pending documents, clears the cache + 52 => 'MoveDocument', // reparents a document from $_REQUEST + 90 => 'Users/DeleteUser', // deletes a manager user from $_GET + ]; + + $unguarded = []; + + foreach ($mutatingControllers as $action => $class) { + expect(is_file(evoRoot() . '/core/src/Controllers/' . $class . '.php'))->toBeTrue(); + + if (!in_array($action, $guarded, true)) { + $unguarded[] = $class . ' (a=' . $action . ')'; + } + } + + // web_access_groups is a processor, but it is reached only through a controller-style + // ?operation= switch rather than a save/delete action, so it is checked here. + $accessGroups = (string)file_get_contents(evoRoot() . '/manager/processors/web_access_groups.processor.php'); + expect($accessGroups)->toContain("\$_REQUEST['operation']"); + + if (!in_array(92, $guarded, true)) { + $unguarded[] = 'web_access_groups (a=92)'; + } + + expect($unguarded)->toBe([]); +}); + +it('guards the disable toggle branch of the element save processors', function () { + // Each save_* processor acts on ?disabled= before it ever looks at the POST body. + $toggles = [ + 'save_snippet' => 24, + 'save_htmlsnippet' => 79, + 'save_plugin' => 103, + 'save_module' => 109, + ]; + + $guarded = (new ReflectionClass(VerifyCsrfToken::class))->getConstant('MUTATING_GET_ACTIONS'); + $unguarded = []; + + foreach ($toggles as $name => $action) { + $source = (string)file_get_contents(evoRoot() . '/manager/processors/' . $name . '.processor.php'); + + expect($source)->toContain("isset(\$_GET['disabled'])"); + + if (!in_array($action, $guarded, true)) { + $unguarded[] = $name . ' (a=' . $action . ')'; + } + } + + expect($unguarded)->toBe([]); +}); + +/* +|-------------------------------------------------------------------------- +| The token must not leak into saved data +|-------------------------------------------------------------------------- +| +| Several legacy processors walk the whole POST body instead of reading named keys, so adding +| a token field to their forms would have written `_token` into the database. +| +*/ + +it('does not persist the token as a system setting', function () { + // save_settings writes every POST key it does not recognise into system_settings. + $source = (string)file_get_contents(evoRoot() . '/manager/processors/save_settings.processor.php'); + + expect($source)->toMatch("/case '_token':\s*\n(\s*\/\/.*\n)?\s*\\\$k = '';/"); +}); + +it('skips the token in the sort processors that walk the whole POST body', function () { + $walkers = [ + '/manager/actions/mutate_template_tv_rank.dynamic.php', + '/core/src/Controllers/TmplvarRank.php', + ]; + + foreach ($walkers as $relative) { + $source = (string)file_get_contents(evoRoot() . $relative); + + expect($source) + ->toContain('foreach ($_POST as $listName => $listValue)') + ->toContain("\$listName == '_token'"); + } +}); + +it('keeps the session cookie off cross-site posts as defence in depth', function () { + // The file cannot simply be required here: it calls storage_path(), which needs a booted + // application, so the declaration is read from the source instead. + $source = (string)file_get_contents(evoRoot() . '/core/config/session.php'); + + expect($source)->toMatch("/'same_site'\s*=>\s*'lax'/"); +}); + +it('exposes the token to scripts that post without an enclosing form', function () { + $header = (string)file_get_contents(evoRoot() . '/manager/views/partials/header.blade.php'); + + expect($header)->toContain('name="csrf-token"'); +}); diff --git a/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php b/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php new file mode 100644 index 0000000000..b20a9b9772 --- /dev/null +++ b/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php @@ -0,0 +1,234 @@ + 'json', 'data' => $data, 'status' => $status]; + } + + public function make($content = '', $status = 200, array $headers = []) + { + return ['kind' => 'text', 'data' => $content, 'status' => $status, 'headers' => $headers]; + } +} + +beforeEach(function () { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(new Container()); + Response::swap(new CsrfResponseFactoryStub()); + + $_SESSION = ['mgrValidated' => 1, '_token' => str_repeat('a', 40)]; + $_GET = []; + $_POST = []; +}); + +afterEach(function () { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + $_SESSION = []; + $_GET = []; + $_POST = []; +}); + +/** + * Builds a request and mirrors its parameters into the superglobals the Manager dispatcher + * reads, so the middleware resolves the action exactly as the real request would. + */ +function csrfRequest(string $method, array $params = [], array $server = []): Request +{ + $method = strtoupper($method); + $query = $method === 'GET' ? $params : []; + $body = $method === 'GET' ? [] : $params; + + $_GET = $query; + $_POST = $body; + + return Request::create('/manager/index.php', $method, $params, [], [], $server); +} + +/** + * Runs the middleware and reports whether the request reached the application. + */ +function csrfPassed(Request $request): bool +{ + $reached = false; + + $result = (new VerifyCsrfToken())->handle($request, function () use (&$reached) { + $reached = true; + + return 'next'; + }); + + if ($reached) { + expect($result)->toBe('next'); + } + + return $reached; +} + +/* +|-------------------------------------------------------------------------- +| The reported vulnerability +|-------------------------------------------------------------------------- +*/ + +it('rejects a state-changing POST that carries no token at all', function () { + // The reported attack: a cross-site form posting a=109 (save_module) with attacker PHP in + // `post`, and deliberately no `_token` field. + $request = csrfRequest('POST', [ + 'a' => 109, + 'mode' => 'new', + 'post' => '', + ]); + + expect(csrfPassed($request))->toBeFalse(); +}); + +it('rejects a POST whose token does not match the session', function () { + $request = csrfRequest('POST', ['a' => 109, '_token' => str_repeat('b', 40)]); + + expect(csrfPassed($request))->toBeFalse(); +}); + +it('rejects a state-changing POST when the session holds no token', function () { + // Failing open on a missing session token would reopen the hole for any session that had + // not yet rendered a form. + unset($_SESSION['_token']); + + $request = csrfRequest('POST', ['a' => 109, '_token' => str_repeat('a', 40)]); + + expect(csrfPassed($request))->toBeFalse(); +}); + +it('accepts a POST carrying the session token', function () { + $request = csrfRequest('POST', ['a' => 109, '_token' => str_repeat('a', 40)]); + + expect(csrfPassed($request))->toBeTrue(); +}); + +/* +|-------------------------------------------------------------------------- +| GET actions that change state +|-------------------------------------------------------------------------- +*/ + +it('rejects destructive GET actions without a token', function (int $action) { + $request = csrfRequest('GET', ['a' => $action, 'id' => 3]); + + expect(csrfPassed($request))->toBeFalse(); +})->with([ + 6, // delete_content + 61, // publish_content + 94, // duplicate_content + 110, // delete_module + 112, // execute_module - runs stored PHP through eval + 116, // delete_eventlog + 303, // delete_tmplvars +]); + +it('accepts destructive GET actions carrying the session token', function () { + $request = csrfRequest('GET', ['a' => 112, 'id' => 3, '_token' => str_repeat('a', 40)]); + + expect(csrfPassed($request))->toBeTrue(); +}); + +it('leaves ordinary GET navigation alone', function (int $action) { + // Editing forms and list pages are reached constantly by the manager UI and must not need + // a token, otherwise every bookmark and back button in the panel breaks. + $request = csrfRequest('GET', ['a' => $action, 'id' => 3]); + + expect(csrfPassed($request))->toBeTrue(); +})->with([ + 2, // welcome + 3, // resource view + 27, // edit resource + 76, // element list + 107, // edit module +]); + +/* +|-------------------------------------------------------------------------- +| The login flow +|-------------------------------------------------------------------------- +*/ + +it('does not require a token before the manager session is validated', function () { + // The login form is rendered without a manager session, so enforcing here would lock + // everyone out while closing no cross-site vector. + unset($_SESSION['mgrValidated']); + + $request = csrfRequest('POST', ['a' => 0, 'username' => 'admin', 'password' => 'secret']); + + expect(csrfPassed($request))->toBeTrue(); +}); + +it('does not require a token when there is no session at all', function () { + $_SESSION = []; + + $request = csrfRequest('POST', ['a' => 0]); + + expect(csrfPassed($request))->toBeTrue(); +}); + +/* +|-------------------------------------------------------------------------- +| Token transport and response shape +|-------------------------------------------------------------------------- +*/ + +it('accepts the token from the X-CSRF-TOKEN header', function () { + $request = csrfRequest('POST', ['a' => 109], ['HTTP_X_CSRF_TOKEN' => str_repeat('a', 40)]); + + expect(csrfPassed($request))->toBeTrue(); +}); + +it('answers XHR callers with JSON and page requests with plain text', function () { + $xhr = csrfRequest('POST', ['a' => 109], ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']); + $page = csrfRequest('GET', ['a' => 6, 'id' => 3]); + + $middleware = new VerifyCsrfToken(); + $noop = fn () => 'next'; + + expect($middleware->handle($xhr, $noop)) + ->toMatchArray(['kind' => 'json', 'status' => 403]) + ->and($middleware->handle($page, $noop)) + ->toMatchArray(['kind' => 'text', 'status' => 403]); +}); + +it('lets HEAD and OPTIONS through without looking at a token', function (string $method) { + unset($_SESSION['_token']); + + $request = csrfRequest($method, ['a' => 6, 'id' => 3]); + + expect(csrfPassed($request))->toBeTrue(); +})->with(['HEAD', 'OPTIONS']); + +it('compares tokens in constant time', function () { + // A plain string comparison leaks the token a byte at a time under timing analysis. + $source = file_get_contents(__DIR__ . '/../../../src/Middleware/VerifyCsrfToken.php'); + + expect($source)->toContain('hash_equals('); +}); diff --git a/core/tests/Unit/Security/ManagerReflectedXssTest.php b/core/tests/Unit/Security/ManagerReflectedXssTest.php new file mode 100644 index 0000000000..b7e9397943 --- /dev/null +++ b/core/tests/Unit/Security/ManagerReflectedXssTest.php @@ -0,0 +1,133 @@ + blocks, where +| attribute escaping would not have been enough anyway. +| +| These assertions read the shipped sources. They are deliberately about the *source* of each +| value rather than the sink: constraining it where it is read covers every sink it feeds. +| +*/ + +function managerSource(string $relative): string +{ + return (string)file_get_contents(dirname(__DIR__, 4) . '/' . $relative); +} + +it('casts the backup manager refresh count before writing it into a script block', function () { + // ?r= was concatenated straight into doRefresh(...) inside

@@ -244,6 +245,7 @@ function showhide(a) {
+

@@ -346,13 +348,13 @@ class="form-check-input" EvolutionCMS()->getDatabase()->getConfig('prefix') . 'manager_log', ]; if (EvolutionCMS()->hasPermission('settings') && in_array($db_status['Name'], $truncateable) && $db_status['Rows'] > 0) { - echo '' . niceSize($db_status['Data_length'] + $db_status['Data_free']) . '' . '' . "\n"; + echo '' . niceSize($db_status['Data_length'] + $db_status['Data_free']) . '' . '' . "\n"; } else { echo '' . niceSize($db_status['Data_length'] + $db_status['Data_free']) . '' . "\n"; } if (EvolutionCMS()->hasPermission('settings')) { - echo '' . ($db_status['Data_free'] > 0 ? '' . niceSize($db_status['Data_free']) . '' : '-') . '' . "\n"; + echo '' . ($db_status['Data_free'] > 0 ? '' . niceSize($db_status['Data_free']) . '' : '-') . '' . "\n"; } else { echo '' . ($db_status['Data_free'] > 0 ? niceSize($db_status['Data_free']) : '-') . '' . "\n"; } @@ -395,6 +397,7 @@ class="form-check-input"

+ "> "snapshot_path=".EvolutionCMS()->getConfig('snapshot_path')]) ?> + @@ -496,6 +500,7 @@ class=""> + diff --git a/manager/actions/category_mgr/skin/add.tpl.phtml b/manager/actions/category_mgr/skin/add.tpl.phtml index 559c4a4f93..e13b2d48d8 100755 --- a/manager/actions/category_mgr/skin/add.tpl.phtml +++ b/manager/actions/category_mgr/skin/add.tpl.phtml @@ -1,4 +1,5 @@ +
txt('cm_create_new_category'); ?> diff --git a/manager/actions/category_mgr/skin/categorize.tpl.phtml b/manager/actions/category_mgr/skin/categorize.tpl.phtml index 69e5a21921..798087ed5e 100755 --- a/manager/actions/category_mgr/skin/categorize.tpl.phtml +++ b/manager/actions/category_mgr/skin/categorize.tpl.phtml @@ -1,4 +1,5 @@ +
txt('cm_save_categorization'); ?> diff --git a/manager/actions/category_mgr/skin/chunks/db_setup.tpl.phtml b/manager/actions/category_mgr/skin/chunks/db_setup.tpl.phtml index 02aec99186..3d8a976e0a 100755 --- a/manager/actions/category_mgr/skin/chunks/db_setup.tpl.phtml +++ b/manager/actions/category_mgr/skin/chunks/db_setup.tpl.phtml @@ -9,6 +9,7 @@

+
\ No newline at end of file diff --git a/manager/actions/category_mgr/skin/edit.tpl.phtml b/manager/actions/category_mgr/skin/edit.tpl.phtml index cfa516bb3e..237e717fc0 100755 --- a/manager/actions/category_mgr/skin/edit.tpl.phtml +++ b/manager/actions/category_mgr/skin/edit.tpl.phtml @@ -1,4 +1,5 @@
+
txt('cm_update_categories'); ?> diff --git a/manager/actions/category_mgr/skin/js/categories.js b/manager/actions/category_mgr/skin/js/categories.js index b433546973..3a6e7bdb3a 100755 --- a/manager/actions/category_mgr/skin/js/categories.js +++ b/manager/actions/category_mgr/skin/js/categories.js @@ -39,7 +39,8 @@ document.getElementById('elements-select').onchange = function(e) { }); } }; - xhr.send(request_key + '[ajax]=1&' + request_key + '[task]=categorize_load_elements&' + request_key + '[elements]=' + e.target.value); + let tokenField = document.querySelector('input[name="_token"]'); + xhr.send('_token=' + encodeURIComponent(tokenField ? tokenField.value : '') + '&' + request_key + '[ajax]=1&' + request_key + '[task]=categorize_load_elements&' + request_key + '[elements]=' + e.target.value); }; /** diff --git a/manager/actions/category_mgr/skin/sort.tpl.phtml b/manager/actions/category_mgr/skin/sort.tpl.phtml index e95fd64773..3f4af0e63a 100755 --- a/manager/actions/category_mgr/skin/sort.tpl.phtml +++ b/manager/actions/category_mgr/skin/sort.tpl.phtml @@ -1,4 +1,5 @@ +
txt('cm_save_new_sorting') ?> diff --git a/manager/actions/category_mgr/skin/translate.tpl.phtml b/manager/actions/category_mgr/skin/translate.tpl.phtml index a65ba79709..55d49fe78b 100755 --- a/manager/actions/category_mgr/skin/translate.tpl.phtml +++ b/manager/actions/category_mgr/skin/translate.tpl.phtml @@ -1,4 +1,5 @@ +
txt('cm_translate_module_phrases'); ?> diff --git a/manager/actions/files.dynamic.php b/manager/actions/files.dynamic.php index dce14e0ed8..c4c4466e48 100755 --- a/manager/actions/files.dynamic.php +++ b/manager/actions/files.dynamic.php @@ -813,9 +813,10 @@ function safe_unzip($file, $path) { + - + @@ -840,7 +841,7 @@ function safe_unzip($file, $path) {

- +

@@ -909,6 +910,7 @@ function safe_unzip($file, $path) { } ?> + @@ -972,9 +974,10 @@ function makeFilesPublic(b) { } ?> + - +
diff --git a/manager/actions/logging.static.php b/manager/actions/logging.static.php index 737778ba06..99b8abe427 100755 --- a/manager/actions/logging.static.php +++ b/manager/actions/logging.static.php @@ -1,304 +1,305 @@ -INCLUDE_ORDERING_ERROR

Please use the EVO Content Manager instead of accessing this file directly."); -} -if (!EvolutionCMS()->hasPermission('logs')) { - EvolutionCMS()->webAlertAndQuit($_lang["error_no_privileges"]); -} - -$logs = \EvolutionCMS\Models\ManagerLog::query()->select('internalKey', 'username', 'action', 'itemid', 'itemname')->distinct()->get()->toArray(); - -?> -

- -

- -
-
-
- - - -
-
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-
-
- "/> - " - title=""> -
-
-
-
-
-
-
- "/> - " - title=""> -
-
-
-
-
-
- -
-
- - "> - "> - - " - style="display:none;"/> - - -
-
- - - -
-
- -orderBy('timestamp', 'DESC')->orderBy('id', 'DESC'); - // get the selections the user made. - $sqladd = []; - if (get_by_key($_REQUEST, 'searchuser') != 0) { - $logs = $logs->where('internalKey', (int)get_by_key($_REQUEST, 'searchuser')); - } - if (get_by_key($_REQUEST, 'action') != 0) { - $logs = $logs->where('action', (int)get_by_key($_REQUEST, 'action')); - } - if (get_by_key($_REQUEST, 'itemid') != 0 || get_by_key($_REQUEST, 'itemid') == "-") { - $logs = $logs->where('itemid', get_by_key($_REQUEST, 'itemid')); - } - if (get_by_key($_REQUEST, 'itemname') != '0') { - $logs = $logs->where('itemname', get_by_key($_REQUEST, 'itemname')); - } - if (get_by_key($_REQUEST, 'message') != "") { - $logs = $logs->where('itemname', 'LIKE', '%'.get_by_key($_REQUEST, 'itemname').'%'); - } - // date stuff - if ($_REQUEST['datefrom'] != "") { - $logs = $logs->where('timestamp', '>', EvolutionCMS()->toTimeStamp($_REQUEST['datefrom'])); - - $sqladd[] = "timestamp>" . EvolutionCMS()->toTimeStamp($_REQUEST['datefrom']); - } - if ($_REQUEST['dateto'] != "") { - $logs = $logs->where('timestamp', '<', EvolutionCMS()->toTimeStamp($_REQUEST['dateto'])); - } - - // If current position is not set, set it to zero - if (!isset($_REQUEST['int_cur_position']) || $_REQUEST['int_cur_position'] == 0) { - $int_cur_position = 0; - } else { - $int_cur_position = $_REQUEST['int_cur_position']; - } - - // Number of result to display on the page, will be in the LIMIT of the sql query also - $int_num_result = is_numeric($_REQUEST['nrresults']) ? $_REQUEST['nrresults'] : EvolutionCMS()->getConfig('number_of_logs'); - - $extargv = "&a=13&searchuser=" . get_by_key($_REQUEST, 'searchuser') . "&action=" . get_by_key($_REQUEST, 'action') . "&itemid=" . get_by_key($_REQUEST, 'itemid') . "&itemname=" . get_by_key($_REQUEST, 'itemname') . "&message=" . get_by_key($_REQUEST, 'message') . "&dateto=" . $_REQUEST['dateto'] . "&datefrom=" . $_REQUEST['datefrom'] . "&nrresults=" . $int_num_result . "&log_submit=" . $_REQUEST['log_submit']; // extra argv here (could be anything depending on your page) - - // build the sql - $limit = $num_rows = $logs->count(); - - $rs = $logs->skip($int_cur_position)->take($int_num_result)->get(); - -if ($limit < 1) { - echo '

' . $_lang["mgrlog_emptysrch"] . '

'; -} else { - echo '

' . $_lang["mgrlog_sortinst"] . '

'; - - // New instance of the Paging class, you can modify the color and the width of the html table - $p = new EvolutionCMS\Support\Paginate($num_rows, $int_cur_position, $int_num_result, $extargv); - - // Load up the 2 array in order to display result - $array_paging = $p->getPagingArray(); - $array_row_paging = $p->getPagingRowArray(); - $current_row = $int_cur_position / $int_num_result; - - // Display the result as you like... - print "

" . $_lang["paging_showing"] . " " . $array_paging['lower']; - print " " . $_lang["paging_to"] . " " . $array_paging['upper']; - print " (" . $array_paging['total'] . " " . $_lang["paging_total"] . ")
"; - $paging = $array_paging['first_link'] . $_lang["paging_first"] . (isset($array_paging['first_link']) ? " " : " "); - $paging .= $array_paging['previous_link'] . $_lang["paging_prev"] . (isset($array_paging['previous_link']) ? " " : " "); - $pagesfound = sizeof($array_row_paging); - if ($pagesfound > 6) { - $paging .= $array_row_paging[$current_row - 2]; // ." "; - $paging .= $array_row_paging[$current_row - 1]; // ." "; - $paging .= $array_row_paging[$current_row]; // ." "; - $paging .= $array_row_paging[$current_row + 1]; // ." "; - $paging .= $array_row_paging[$current_row + 2]; // ." "; - } else { - for ($i = 0; $i < $pagesfound; $i++) { - $paging .= $array_row_paging[$i] . " "; - } - } - $paging .= $array_paging['next_link'] . $_lang["paging_next"] . (isset($array_paging['next_link']) ? " " : " ") . " "; - $paging .= $array_paging['last_link'] . $_lang["paging_last"] . (isset($array_paging['last_link']) ? " " : " ") . " "; - // The above exemple print somethings like: - // Results 1 to 20 of 597 <<< 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 >>> - // Of course you can now play with array_row_paging in order to print - // only the results you would like... - ?> - - - -

- -
-
-
- - - - - - - - - - - - - toArray() as $logentry) { - if (!preg_match("/^[0-9]+$/", $logentry['itemid'])) { - $item = '
-
'; - } elseif ($logentry['action'] == 3 || $logentry['action'] == 27 || $logentry['action'] == 5) { - $item = '' . $logentry['itemname'] . ''; - } else { - $item = $logentry['itemname']; - } - //index.php?a=13&searchuser=' . $logentry['internalKey'] . '&action=' . $logentry['action'] . '&itemname=' . $logentry['itemname'] . '&log_submit=true' - $user_drill = 'index.php?a=13&searchuser=' . $logentry['internalKey'] . '&itemname=0&log_submit=true'; - ?> - - - - - - - - - - - -
IPUSER_AGENT
' . $logentry['username'] . '' ?>toDateFormat($logentry['timestamp'] + EvolutionCMS()->getConfig('server_offset_time')) ?>
-
-
- - - -
- - INCLUDE_ORDERING_ERROR

Please use the EVO Content Manager instead of accessing this file directly."); +} +if (!EvolutionCMS()->hasPermission('logs')) { + EvolutionCMS()->webAlertAndQuit($_lang["error_no_privileges"]); +} + +$logs = \EvolutionCMS\Models\ManagerLog::query()->select('internalKey', 'username', 'action', 'itemid', 'itemname')->distinct()->get()->toArray(); + +?> +

+ +

+ +
+
+
+ +
+ + +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + " + title=""> +
+
+
+
+
+
+
+ + " + title=""> +
+
+
+
+
+
+ +
+
+ + "> + "> + + " + style="display:none;"/> +
+ +
+
+ + + +
+
+ +orderBy('timestamp', 'DESC')->orderBy('id', 'DESC'); + // get the selections the user made. + $sqladd = []; + if (get_by_key($_REQUEST, 'searchuser') != 0) { + $logs = $logs->where('internalKey', (int)get_by_key($_REQUEST, 'searchuser')); + } + if (get_by_key($_REQUEST, 'action') != 0) { + $logs = $logs->where('action', (int)get_by_key($_REQUEST, 'action')); + } + if (get_by_key($_REQUEST, 'itemid') != 0 || get_by_key($_REQUEST, 'itemid') == "-") { + $logs = $logs->where('itemid', get_by_key($_REQUEST, 'itemid')); + } + if (get_by_key($_REQUEST, 'itemname') != '0') { + $logs = $logs->where('itemname', get_by_key($_REQUEST, 'itemname')); + } + if (get_by_key($_REQUEST, 'message') != "") { + $logs = $logs->where('itemname', 'LIKE', '%'.get_by_key($_REQUEST, 'itemname').'%'); + } + // date stuff + if ($_REQUEST['datefrom'] != "") { + $logs = $logs->where('timestamp', '>', EvolutionCMS()->toTimeStamp($_REQUEST['datefrom'])); + + $sqladd[] = "timestamp>" . EvolutionCMS()->toTimeStamp($_REQUEST['datefrom']); + } + if ($_REQUEST['dateto'] != "") { + $logs = $logs->where('timestamp', '<', EvolutionCMS()->toTimeStamp($_REQUEST['dateto'])); + } + + // If current position is not set, set it to zero + if (!isset($_REQUEST['int_cur_position']) || $_REQUEST['int_cur_position'] == 0) { + $int_cur_position = 0; + } else { + $int_cur_position = $_REQUEST['int_cur_position']; + } + + // Number of result to display on the page, will be in the LIMIT of the sql query also + $int_num_result = is_numeric($_REQUEST['nrresults']) ? $_REQUEST['nrresults'] : EvolutionCMS()->getConfig('number_of_logs'); + + $extargv = "&a=13&searchuser=" . get_by_key($_REQUEST, 'searchuser') . "&action=" . get_by_key($_REQUEST, 'action') . "&itemid=" . get_by_key($_REQUEST, 'itemid') . "&itemname=" . get_by_key($_REQUEST, 'itemname') . "&message=" . get_by_key($_REQUEST, 'message') . "&dateto=" . $_REQUEST['dateto'] . "&datefrom=" . $_REQUEST['datefrom'] . "&nrresults=" . $int_num_result . "&log_submit=" . $_REQUEST['log_submit']; // extra argv here (could be anything depending on your page) + + // build the sql + $limit = $num_rows = $logs->count(); + + $rs = $logs->skip($int_cur_position)->take($int_num_result)->get(); + +if ($limit < 1) { + echo '

' . $_lang["mgrlog_emptysrch"] . '

'; +} else { + echo '

' . $_lang["mgrlog_sortinst"] . '

'; + + // New instance of the Paging class, you can modify the color and the width of the html table + $p = new EvolutionCMS\Support\Paginate($num_rows, $int_cur_position, $int_num_result, $extargv); + + // Load up the 2 array in order to display result + $array_paging = $p->getPagingArray(); + $array_row_paging = $p->getPagingRowArray(); + $current_row = $int_cur_position / $int_num_result; + + // Display the result as you like... + print "

" . $_lang["paging_showing"] . " " . $array_paging['lower']; + print " " . $_lang["paging_to"] . " " . $array_paging['upper']; + print " (" . $array_paging['total'] . " " . $_lang["paging_total"] . ")
"; + $paging = $array_paging['first_link'] . $_lang["paging_first"] . (isset($array_paging['first_link']) ? " " : " "); + $paging .= $array_paging['previous_link'] . $_lang["paging_prev"] . (isset($array_paging['previous_link']) ? " " : " "); + $pagesfound = sizeof($array_row_paging); + if ($pagesfound > 6) { + $paging .= $array_row_paging[$current_row - 2]; // ." "; + $paging .= $array_row_paging[$current_row - 1]; // ." "; + $paging .= $array_row_paging[$current_row]; // ." "; + $paging .= $array_row_paging[$current_row + 1]; // ." "; + $paging .= $array_row_paging[$current_row + 2]; // ." "; + } else { + for ($i = 0; $i < $pagesfound; $i++) { + $paging .= $array_row_paging[$i] . " "; + } + } + $paging .= $array_paging['next_link'] . $_lang["paging_next"] . (isset($array_paging['next_link']) ? " " : " ") . " "; + $paging .= $array_paging['last_link'] . $_lang["paging_last"] . (isset($array_paging['last_link']) ? " " : " ") . " "; + // The above exemple print somethings like: + // Results 1 to 20 of 597 <<< 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 >>> + // Of course you can now play with array_row_paging in order to print + // only the results you would like... + ?> + + + +

+ +
+
+ + + + + + + + + + + + + + toArray() as $logentry) { + if (!preg_match("/^[0-9]+$/", $logentry['itemid'])) { + $item = '
-
'; + } elseif ($logentry['action'] == 3 || $logentry['action'] == 27 || $logentry['action'] == 5) { + $item = '' . $logentry['itemname'] . ''; + } else { + $item = $logentry['itemname']; + } + //index.php?a=13&searchuser=' . $logentry['internalKey'] . '&action=' . $logentry['action'] . '&itemname=' . $logentry['itemname'] . '&log_submit=true' + $user_drill = 'index.php?a=13&searchuser=' . $logentry['internalKey'] . '&itemname=0&log_submit=true'; + ?> + + + + + + + + + + + +
IPUSER_AGENT
' . entities($logentry['username'], EvolutionCMS()->getConfig('modx_charset')) . '' ?>getConfig('modx_charset')) ?>getConfig('modx_charset')) ?>getConfig('modx_charset')) ?>toDateFormat($logentry['timestamp'] + EvolutionCMS()->getConfig('server_offset_time')) ?>getConfig('modx_charset')) ?>getConfig('modx_charset')) ?>
+
+
+ + + +
+
+ &a=4"; + document.location.href = "index.php?pid=&a=4"; }, newlink: function() { - document.location.href = "index.php?pid=&a=72"; + document.location.href = "index.php?pid=&a=72"; }, save: function() { syncReferenceContent(); @@ -187,7 +191,7 @@ function changestate(el) { }, delete: function() { if(confirm("") === true) { - document.location.href = "index.php?id=" + document.mutate.id.value + "&a=6"; + document.location.href = "index.php?id=" + document.mutate.id.value + "&a=6&_token="; } }, cancel: function() { @@ -196,7 +200,7 @@ function changestate(el) { }, duplicate: function() { if(confirm("") === true) { - document.location.href = "index.php?id=&a=94"; + document.location.href = "index.php?id=&a=94&_token="; } }, view: function() { @@ -557,6 +561,7 @@ function evoRenderTvImageCheck(a) { }
+ invokeEvent('OnDocFormPrerender', [ diff --git a/manager/actions/mutate_menuindex_sort.dynamic.php b/manager/actions/mutate_menuindex_sort.dynamic.php index ae00810c20..1a1c13aa16 100755 --- a/manager/actions/mutate_menuindex_sort.dynamic.php +++ b/manager/actions/mutate_menuindex_sort.dynamic.php @@ -193,6 +193,7 @@ class=""> +
diff --git a/manager/actions/mutate_module.dynamic.php b/manager/actions/mutate_module.dynamic.php index ad033d5fe6..f8bff8d8de 100755 --- a/manager/actions/mutate_module.dynamic.php +++ b/manager/actions/mutate_module.dynamic.php @@ -75,7 +75,7 @@ function loadDependencies() { } documentDirty = false; - window.location.href = "index.php?id=&a=113"; + window.location.href = "index.php?id=&a=113"; } var actions = { @@ -88,13 +88,13 @@ function loadDependencies() { duplicate: function () { if (confirm("") === true) { documentDirty = false; - document.location.href = "index.php?id=&a=111"; + document.location.href = "index.php?id=&a=111&_token="; } }, delete: function () { if (confirm("") === true) { documentDirty = false; - document.location.href = "index.php?id=" + document.mutate.id.value + "&a=110"; + document.location.href = "index.php?id=" + document.mutate.id.value + "&a=110&_token="; } }, cancel: function () { @@ -102,7 +102,7 @@ function loadDependencies() { document.location.href = 'index.php?a=76&tab=5'; }, run: function () { - document.location.href = "index.php?id=&a=112"; + document.location.href = "index.php?id=&a=112&_token="; } }; @@ -121,6 +121,7 @@ function setTextWrap(ctrl, b) {
+ invokeEvent('OnModFormPrerender', ['id' => $id]); diff --git a/manager/actions/mutate_module_resources.dynamic.php b/manager/actions/mutate_module_resources.dynamic.php index 1b6cb92e9d..ff72df71e2 100755 --- a/manager/actions/mutate_module_resources.dynamic.php +++ b/manager/actions/mutate_module_resources.dynamic.php @@ -170,6 +170,7 @@ function openSelector(resource, mode, callback, w, h) { + diff --git a/manager/actions/mutate_template_tv_rank.dynamic.php b/manager/actions/mutate_template_tv_rank.dynamic.php index bf38554405..ec2277d428 100755 --- a/manager/actions/mutate_template_tv_rank.dynamic.php +++ b/manager/actions/mutate_template_tv_rank.dynamic.php @@ -18,7 +18,7 @@ if (isset($_POST['listSubmitted'])) { $updateMsg .= '
' . $_lang['sort_updated'] . '
'; foreach ($_POST as $listName => $listValue) { - if ($listName == 'listSubmitted' || $listName == 'reset') { + if ($listName == 'listSubmitted' || $listName == 'reset' || $listName == '_token') { continue; } $orderArray = explode(';', rtrim($listValue, ';')); @@ -156,6 +156,7 @@ class=""> +
diff --git a/manager/actions/mutate_web_user.dynamic.php b/manager/actions/mutate_web_user.dynamic.php index 6010cb1507..3e06534b45 100755 --- a/manager/actions/mutate_web_user.dynamic.php +++ b/manager/actions/mutate_web_user.dynamic.php @@ -230,7 +230,7 @@ function showHide(what, onoff) { }, delete: function() { if(confirm("") === true) { - window.location.href = "index.php?id=" + document.userform.id.value + "&a=90"; + window.location.href = "index.php?id=" + document.userform.id.value + "&a=90&_token="; } }, cancel: function() { @@ -253,6 +253,7 @@ function evoRenderTvImageCheck(a) {
+ invokeEvent("OnWUsrFormPrerender", ["id" => $user]); @@ -261,7 +262,7 @@ function evoRenderTvImageCheck(a) { } ?> - + " /> @@ -305,7 +306,7 @@ function evoRenderTvImageCheck(a) { getManagerApi()->action == '87' ? $_lang['password'] . ":" : $_lang['change_password_new'] . ":"; ?> getManagerApi()->action == "87" ? " checked disabled" : ""; ?>> - " onChange="documentDirty=true;" /> + " onChange="documentDirty=true;" />
" id="passwordBlock">
@@ -355,7 +356,7 @@ function evoRenderTvImageCheck(a) { * : - + @@ -376,7 +377,7 @@ function evoRenderTvImageCheck(a) { $selectedtext = $row['id'] == $userdata['role'] ? "selected='selected'" : ''; } ?> - + @@ -384,15 +385,15 @@ function evoRenderTvImageCheck(a) { : - + : - + : - + : @@ -404,11 +405,11 @@ function evoRenderTvImageCheck(a) { : - + : - + : @@ -424,7 +425,7 @@ function evoRenderTvImageCheck(a) { : - " onBlur='documentDirty=true;' readonly /> + " onBlur='documentDirty=true;' readonly /> " data-tooltip=""> @@ -455,22 +456,22 @@ function evoRenderTvImageCheck(a) { : - +    [] : time() && $userdata['blockeduntil'] != 0) || ($userdata['blockedafter'] < time() && $userdata['blockedafter'] != 0)) ? " checked='checked'" : ""; ?> /> - + : - " onBlur='documentDirty=true;' readonly /> + " onBlur='documentDirty=true;' readonly /> " data-tooltip=""> : - " onBlur='documentDirty=true;' readonly /> + " onBlur='documentDirty=true;' readonly /> " data-tooltip=""> - + - "> + ">   @@ -763,7 +764,7 @@ function evoRenderTvImageCheck(a) { - "> + ">   @@ -771,7 +772,7 @@ function evoRenderTvImageCheck(a) { - " /> + " />   @@ -928,7 +929,7 @@ function evoRenderTvImageCheck(a) { "> + value="getPhpCompat()->htmlspecialchars(isset($usersettings['upload_images']) ? $usersettings['upload_images'] : ""); ?>">    /> @@ -940,7 +941,7 @@ function evoRenderTvImageCheck(a) { - "> + ">    /> @@ -952,7 +953,7 @@ function evoRenderTvImageCheck(a) { - "> + ">    /> @@ -964,7 +965,7 @@ function evoRenderTvImageCheck(a) { - "> + ">   @@ -995,7 +996,7 @@ function evoRenderTvImageCheck(a) { - " /> + " />   @@ -1003,7 +1004,7 @@ function evoRenderTvImageCheck(a) { - " /> + " />   @@ -1011,7 +1012,7 @@ function evoRenderTvImageCheck(a) { - " /> + " />   @@ -1035,7 +1036,7 @@ function evoRenderTvImageCheck(a) { - + diff --git a/manager/actions/resource_selector.static.php b/manager/actions/resource_selector.static.php index fb747cd589..ff41ab3de9 100644 --- a/manager/actions/resource_selector.static.php +++ b/manager/actions/resource_selector.static.php @@ -16,17 +16,24 @@ * The name of the callback function is passed via the url as &cb */ -// get name of callback function -$cb = $_REQUEST['cb']; +// Every value below is echoed back into this page - $cb and $rt land inside a +

diff --git a/manager/includes/active_user_locks.inc.php b/manager/includes/active_user_locks.inc.php index 8bc295c42b..696c6b7e1c 100755 --- a/manager/includes/active_user_locks.inc.php +++ b/manager/includes/active_user_locks.inc.php @@ -19,7 +19,7 @@ function unlockThisElement(event) var stay = document.getElementById('stay'); // Trigger unlock if ((stay && stay.value !== '2') || !form_save) { - var url = '?a=67&type=&id=&o=' + Math.random(); + var url = '?a=67&type=&id=&_token=&o=' + Math.random(); if (navigator.sendBeacon) { navigator.sendBeacon(url) } else { diff --git a/manager/media/script/main.js b/manager/media/script/main.js index 4b46908c46..caa9dfaa58 100644 --- a/manager/media/script/main.js +++ b/manager/media/script/main.js @@ -361,10 +361,28 @@ function reset_path(elementName) { document.getElementById(elementName).value = document.getElementById('default_' + elementName).innerHTML; } +// A navigation that never commits produces no load event, and the loader is only switched off +// by the next page's load handler. Downloads are the common case: the browser fires +// beforeunload, then sees Content-Disposition and cancels the navigation, leaving this document +// in place and the loader spinning forever. The same happens when a navigation fails or the +// user dismisses the unsaved-changes prompt. Arming a timer here bounds that: if the navigation +// really commits, this document - and the timer with it - is torn down before the timer fires. +var UNLOAD_LOADER_TIMEOUT = 5000; + function document_onunload(e) { - if (!dontShowWorker) { - top.mainMenu.work(); + // One-shot: a caller that knows the click starts a download rather than a page load sets + // dontShowWorker, and it is consumed here. Leaving it set would silence the loader for the + // rest of this document's life, which matters for downloads precisely because the document + // survives the click. + if (dontShowWorker) { + dontShowWorker = false; + + return; } + + top.mainMenu.work(); + clearTimeout(timerForUnload); + timerForUnload = setTimeout(stopWorker, UNLOAD_LOADER_TIMEOUT); } // set tree to default action. @@ -452,7 +470,10 @@ if (typeof window.addEventListener !== 'undefined') { }; } -window.addEventListener('unload', function () { +// 'unload' is refused by the Permissions Policy in current Chrome, so the listener never ran and +// logged a violation on every page. 'pagehide' is the supported replacement and, unlike 'unload', +// is also delivered when the page goes into the back/forward cache. +window.addEventListener('pagehide', function () { clearTimeout(timerForUnload); }); diff --git a/manager/media/script/mutate_settings.js b/manager/media/script/mutate_settings.js index 6d470818f5..8a0865d587 100644 --- a/manager/media/script/mutate_settings.js +++ b/manager/media/script/mutate_settings.js @@ -58,7 +58,9 @@ function confirmLangChange(el, lkey, elupd){ proceed = confirm(lang_chg); } if(proceed) { + var tokenField = document.querySelector('form[name="settings"] input[name="_token"]'); $.post('index.php?a=118', { + _token: tokenField ? tokenField.value : '', action: 'get', lang: lang !== '' ? lang : lang_default, key: lkey diff --git a/manager/media/style/default/chunks/welcome/StartUpScript.tpl b/manager/media/style/default/chunks/welcome/StartUpScript.tpl index f85cbd3526..e6ac5e1245 100644 --- a/manager/media/style/default/chunks/welcome/StartUpScript.tpl +++ b/manager/media/style/default/chunks/welcome/StartUpScript.tpl @@ -9,7 +9,8 @@ fieldset.className = "collapse"; } }; - xhr.send("action=setsetting&key=_hide_configcheck_" + key + "&value=1"); + var tokenMeta = document.querySelector('meta[name="csrf-token"]'); + xhr.send("_token=" + encodeURIComponent(tokenMeta ? tokenMeta.content : "") + "&action=setsetting&key=_hide_configcheck_" + key + "&value=1"); } (function($) { $("[data-toggle='collapse']").click(function(e) { diff --git a/manager/media/style/default/snippets/welcome/RecentInfo.php b/manager/media/style/default/snippets/welcome/RecentInfo.php index 00d5a5ab1e..6fc645e35a 100644 --- a/manager/media/style/default/snippets/welcome/RecentInfo.php +++ b/manager/media/style/default/snippets/welcome/RecentInfo.php @@ -1,115 +1,115 @@ -getConfig('enable_filter'); -evo()->setConfig('enable_filter', true); -$_style = ManagerTheme::getStyle(); -$wrapActionIcon = function (string $svg, string $class = 'action-icon'): string { - return '' . $svg . ''; -}; -$_style['icon_edit'] = $wrapActionIcon(svg('tabler-file-pencil')->toHtml()); -$_style['icon_eye'] = $wrapActionIcon(svg('tabler-eye')->toHtml()); -$_style['icon_trash'] = $wrapActionIcon(svg('tabler-trash')->toHtml(), 'action-icon action-icon-trash'); -$_style['icon_undo'] = $wrapActionIcon(svg('tabler-arrow-back-up')->toHtml()); -$_style['icon_check'] = $wrapActionIcon(svg('tabler-check')->toHtml()); -$_style['icon_close'] = $wrapActionIcon(svg('tabler-x')->toHtml()); -$_style['icon_info'] = $wrapActionIcon(svg('tabler-info-square-rounded')->toHtml()); -$contents = \EvolutionCMS\Models\SiteContent::query()->orderBy('editedon','DESC')->limit(10); - -if ($contents->count() < 1) { - return '[%no_activity_message%]'; -} - -$tpl = ' - [+id+] - [+pagetitle:htmlentities+] - [+edit_date+] - [+username:htmlentities+] - [+edit_btn+][+publish_btn+][+delete_btn+][+info_btn+][+preview_btn+] - - - -
-
    -
  • [%long_title%]: [+longtitle+]
  • -
  • [%description%]: [+description+]
  • -
  • [%resource_summary%]: [+introtext+]
  • -
  • [%type%]: [+doctype+]
  • -
  • [%resource_alias%]: [+alias+]
  • -
  • [%page_data_cacheable%]: [+cacheable+]
  • -
  • [%resource_opt_show_menu%]: [+hidemenu+]
  • -
  • [%page_data_template%]: [+template:templatename:htmlentities+]
  • -
-
- -'; - -$btntpl['edit'] = '' . $_style['icon_edit'] . ' '; -$btntpl['preview_btn'] = '' . $_style['icon_eye'] . ' '; - -$output = []; -foreach ($contents->get()->toArray() as $ph) { - $docid = $ph['id']; - $_ = evo()->getUserInfo($ph['editedby']); - if (isset($_['username'])) { - $ph['username'] = $_['username']; - } - - if ($ph['deleted'] == 1) { - $ph['status'] = 'deleted text-danger'; - } elseif ($ph['published'] == 0) { - $ph['status'] = 'unpublished font-italic text-muted'; - } else { - $ph['status'] = 'published'; - } - - if (evo()->hasPermission('edit_document')) { - $ph['edit_btn'] = str_replace('[+id+]', $docid, $btntpl['edit']); - } else { - $ph['edit_btn'] = ''; - } - - $preview_disabled = ($ph['deleted'] == 1) ? 'disabled' : ''; - $ph['preview_btn'] = str_replace([ - '[+id+]', - '[+preview_disabled+]' - ], [ - $docid, - $preview_disabled - ], $btntpl['preview_btn']); - - if (evo()->hasPermission('delete_document')) { - if ($ph['deleted'] == 0) { - $delete_btn = '' . $_style['icon_trash'] . ' '; - } else { - $delete_btn = '' . $_style['icon_undo'] . ' '; - } - $ph['delete_btn'] = str_replace('[+id+]', $docid, $delete_btn); - } else { - $ph['delete_btn'] = ''; - } - - if ($ph['deleted'] == 1 && $ph['published'] == 0) { - $publish_btn = '' . $_style['icon_check'] . ' '; - } elseif ($ph['deleted'] == 1 && $ph['published'] == 1) { - $publish_btn = '' . $_style['icon_close'] . ' '; - } elseif ($ph['deleted'] == 0 && $ph['published'] == 0) { - $publish_btn = '' . $_style['icon_check'] . ' '; - } else { - $publish_btn = '' . $_style['icon_close'] . ' '; - } - - $ph['publish_btn'] = str_replace('[+id+]', $docid, $publish_btn); - $ph['info_btn'] = str_replace('[+id+]', $docid, '' . $_style['icon_info'] . ''); - $ph['longtitle'] = $ph['longtitle'] == '' ? '([%not_set%])' : entities($ph['longtitle']); - $ph['description'] = $ph['description'] == '' ? '([%not_set%])' : entities($ph['description']); - $ph['introtext'] = $ph['introtext'] == '' ? '([%not_set%])' : entities($ph['introtext']); - $ph['alias'] = $ph['alias'] == '' ? '([%not_set%])' : entities($ph['alias']); - $ph['edit_date'] = evo()->toDateFormat(evo()->timestamp($ph['editedon'])); - $ph['doctype'] = $ph['type'] == 'reference' ? '[%weblink%]' : '[%resource%]'; - $ph['hidemenu'] = $ph['hidemenu'] == 1 ? '[%no%]' : '[%yes%]'; - $ph['cacheable'] = $ph['cacheable'] == 1 ? '[%yes%]' : '[%no%]'; - - $output[] = evo()->parseText($tpl, $ph); -} - -evo()->setConfig('enable_filter', $enable_filter); -return implode("\n", $output); +getConfig('enable_filter'); +evo()->setConfig('enable_filter', true); +$_style = ManagerTheme::getStyle(); +$wrapActionIcon = function (string $svg, string $class = 'action-icon'): string { + return '' . $svg . ''; +}; +$_style['icon_edit'] = $wrapActionIcon(svg('tabler-file-pencil')->toHtml()); +$_style['icon_eye'] = $wrapActionIcon(svg('tabler-eye')->toHtml()); +$_style['icon_trash'] = $wrapActionIcon(svg('tabler-trash')->toHtml(), 'action-icon action-icon-trash'); +$_style['icon_undo'] = $wrapActionIcon(svg('tabler-arrow-back-up')->toHtml()); +$_style['icon_check'] = $wrapActionIcon(svg('tabler-check')->toHtml()); +$_style['icon_close'] = $wrapActionIcon(svg('tabler-x')->toHtml()); +$_style['icon_info'] = $wrapActionIcon(svg('tabler-info-square-rounded')->toHtml()); +$contents = \EvolutionCMS\Models\SiteContent::query()->orderBy('editedon','DESC')->limit(10); + +if ($contents->count() < 1) { + return '[%no_activity_message%]'; +} + +$tpl = ' + [+id+] + [+pagetitle:htmlentities+] + [+edit_date+] + [+username:htmlentities+] + [+edit_btn+][+publish_btn+][+delete_btn+][+info_btn+][+preview_btn+] + + + +
+
    +
  • [%long_title%]: [+longtitle+]
  • +
  • [%description%]: [+description+]
  • +
  • [%resource_summary%]: [+introtext+]
  • +
  • [%type%]: [+doctype+]
  • +
  • [%resource_alias%]: [+alias+]
  • +
  • [%page_data_cacheable%]: [+cacheable+]
  • +
  • [%resource_opt_show_menu%]: [+hidemenu+]
  • +
  • [%page_data_template%]: [+template:templatename:htmlentities+]
  • +
+
+ +'; + +$btntpl['edit'] = '' . $_style['icon_edit'] . ' '; +$btntpl['preview_btn'] = '' . $_style['icon_eye'] . ' '; + +$output = []; +foreach ($contents->get()->toArray() as $ph) { + $docid = $ph['id']; + $_ = evo()->getUserInfo($ph['editedby']); + if (isset($_['username'])) { + $ph['username'] = $_['username']; + } + + if ($ph['deleted'] == 1) { + $ph['status'] = 'deleted text-danger'; + } elseif ($ph['published'] == 0) { + $ph['status'] = 'unpublished font-italic text-muted'; + } else { + $ph['status'] = 'published'; + } + + if (evo()->hasPermission('edit_document')) { + $ph['edit_btn'] = str_replace('[+id+]', $docid, $btntpl['edit']); + } else { + $ph['edit_btn'] = ''; + } + + $preview_disabled = ($ph['deleted'] == 1) ? 'disabled' : ''; + $ph['preview_btn'] = str_replace([ + '[+id+]', + '[+preview_disabled+]' + ], [ + $docid, + $preview_disabled + ], $btntpl['preview_btn']); + + if (evo()->hasPermission('delete_document')) { + if ($ph['deleted'] == 0) { + $delete_btn = '' . $_style['icon_trash'] . ' '; + } else { + $delete_btn = '' . $_style['icon_undo'] . ' '; + } + $ph['delete_btn'] = str_replace('[+id+]', $docid, $delete_btn); + } else { + $ph['delete_btn'] = ''; + } + + if ($ph['deleted'] == 1 && $ph['published'] == 0) { + $publish_btn = '' . $_style['icon_check'] . ' '; + } elseif ($ph['deleted'] == 1 && $ph['published'] == 1) { + $publish_btn = '' . $_style['icon_close'] . ' '; + } elseif ($ph['deleted'] == 0 && $ph['published'] == 0) { + $publish_btn = '' . $_style['icon_check'] . ' '; + } else { + $publish_btn = '' . $_style['icon_close'] . ' '; + } + + $ph['publish_btn'] = str_replace('[+id+]', $docid, $publish_btn); + $ph['info_btn'] = str_replace('[+id+]', $docid, '' . $_style['icon_info'] . ''); + $ph['longtitle'] = $ph['longtitle'] == '' ? '([%not_set%])' : entities($ph['longtitle']); + $ph['description'] = $ph['description'] == '' ? '([%not_set%])' : entities($ph['description']); + $ph['introtext'] = $ph['introtext'] == '' ? '([%not_set%])' : entities($ph['introtext']); + $ph['alias'] = $ph['alias'] == '' ? '([%not_set%])' : entities($ph['alias']); + $ph['edit_date'] = evo()->toDateFormat(evo()->timestamp($ph['editedon'])); + $ph['doctype'] = $ph['type'] == 'reference' ? '[%weblink%]' : '[%resource%]'; + $ph['hidemenu'] = $ph['hidemenu'] == 1 ? '[%no%]' : '[%yes%]'; + $ph['cacheable'] = $ph['cacheable'] == 1 ? '[%yes%]' : '[%no%]'; + + $output[] = evo()->parseText($tpl, $ph); +} + +evo()->setConfig('enable_filter', $enable_filter); +return implode("\n", $output); diff --git a/manager/processors/delete_tmplvars.processor.php b/manager/processors/delete_tmplvars.processor.php index eaaf5620d8..6073d7bb3c 100755 --- a/manager/processors/delete_tmplvars.processor.php +++ b/manager/processors/delete_tmplvars.processor.php @@ -23,7 +23,7 @@ @endpush + @csrf diff --git a/manager/views/page/template.blade.php b/manager/views/page/template.blade.php index 06e3de958d..e26ccad9cd 100644 --- a/manager/views/page/template.blade.php +++ b/manager/views/page/template.blade.php @@ -14,13 +14,13 @@ duplicate: function () { if (confirm("{{ ManagerTheme::getLexicon('confirm_duplicate_record') }}") === true) { documentDirty = false; - document.location.href = "index.php?id={{ $data->getKey() }}&a=96"; + document.location.href = "index.php?id={{ $data->getKey() }}&a=96&_token={{ csrf_token() }}"; } }, delete: function () { if (confirm("{{ ManagerTheme::getLexicon('confirm_delete_template') }}") === true) { documentDirty = false; - document.location.href = 'index.php?id={{ $data->getKey() }}&a=21'; + document.location.href = 'index.php?id={{ $data->getKey() }}&a=21&_token={{ csrf_token() }}'; } }, cancel: function () { @@ -70,6 +70,7 @@ @endpush + @csrf {!! get_by_key($events, 'OnTempFormPrerender') !!} diff --git a/manager/views/page/tmplvar.blade.php b/manager/views/page/tmplvar.blade.php index 489b516ab1..ef421cdf95 100644 --- a/manager/views/page/tmplvar.blade.php +++ b/manager/views/page/tmplvar.blade.php @@ -88,13 +88,13 @@ function check_all(target) duplicate: function() { if (confirm("{{ ManagerTheme::getLexicon('confirm_duplicate_record') }}") === true) { documentDirty = false; - document.location.href = "index.php?id={{ $data->getKey() }}&a=304"; + document.location.href = "index.php?id={{ $data->getKey() }}&a=304&_token={{ csrf_token() }}"; } }, delete: function() { if (confirm("{{ ManagerTheme::getLexicon('confirm_delete_tmplvars') }}") === true) { documentDirty = false; - document.location.href = 'index.php?id=' + document.mutate.id.value + '&a=303'; + document.location.href = 'index.php?id=' + document.mutate.id.value + '&a=303&_token={{ csrf_token() }}'; } }, cancel: function() { @@ -280,6 +280,7 @@ function decode(s) @endpush + @csrf {!! get_by_key($events, 'OnTVFormPrerender') !!} diff --git a/manager/views/page/tmplvar_rank.blade.php b/manager/views/page/tmplvar_rank.blade.php index 180d54e181..912715afdc 100644 --- a/manager/views/page/tmplvar_rank.blade.php +++ b/manager/views/page/tmplvar_rank.blade.php @@ -118,6 +118,7 @@ function resetSortOrder() + @csrf diff --git a/manager/views/page/update_tree.blade.php b/manager/views/page/update_tree.blade.php index 0ad230df61..6a1ba4f438 100644 --- a/manager/views/page/update_tree.blade.php +++ b/manager/views/page/update_tree.blade.php @@ -32,6 +32,7 @@ @endif
+ @csrf
diff --git a/manager/views/page/user_roles/permission.blade.php b/manager/views/page/user_roles/permission.blade.php index 30ffaa42c7..40f86e9168 100644 --- a/manager/views/page/user_roles/permission.blade.php +++ b/manager/views/page/user_roles/permission.blade.php @@ -2,6 +2,7 @@ @section('content')
+ @csrf diff --git a/manager/views/page/user_roles/permissions_groups.blade.php b/manager/views/page/user_roles/permissions_groups.blade.php index 599316e7aa..57f09c3ccf 100644 --- a/manager/views/page/user_roles/permissions_groups.blade.php +++ b/manager/views/page/user_roles/permissions_groups.blade.php @@ -2,6 +2,7 @@ @section('content') + @csrf diff --git a/manager/views/page/user_roles/user_role.blade.php b/manager/views/page/user_roles/user_role.blade.php index f7fe237b92..343c30b729 100644 --- a/manager/views/page/user_roles/user_role.blade.php +++ b/manager/views/page/user_roles/user_role.blade.php @@ -2,6 +2,7 @@ @section('content') + @csrf diff --git a/manager/views/page/web_access_permissions.blade.php b/manager/views/page/web_access_permissions.blade.php index 283b8d193e..5628f41f21 100644 --- a/manager/views/page/web_access_permissions.blade.php +++ b/manager/views/page/web_access_permissions.blade.php @@ -11,10 +11,10 @@ function deletegroup(groupid, type) { if(confirm("{{ ManagerTheme::getLexicon('confirm_delete_group') }}") === true) { if(type === 'usergroup') { - document.location.href = "index.php?a=92&usergroup=" + groupid + "&operation=delete_user_group"; + document.location.href = "index.php?a=92&_token={{ csrf_token() }}&usergroup=" + groupid + "&operation=delete_user_group"; } else if(type === 'documentgroup') { - document.location.href = "index.php?a=92&documentgroup=" + groupid + "&operation=delete_document_group"; + document.location.href = "index.php?a=92&_token={{ csrf_token() }}&documentgroup=" + groupid + "&operation=delete_document_group"; } } } @@ -54,6 +54,7 @@ function deletegroup(groupid, type) {
{{ ManagerTheme::getLexicon('access_permissions_add_user_group') }} + @csrf
@@ -72,6 +73,7 @@ function deletegroup(groupid, type) { @foreach($userGroups as $userGroup)
+ @csrf @@ -107,6 +109,7 @@ function deletegroup(groupid, type) {
{{ ManagerTheme::getLexicon('access_permissions_add_resource_group') }} + @csrf
@@ -124,6 +127,7 @@ function deletegroup(groupid, type) { @foreach($documentGroups as $documentGroup)
+ @csrf @@ -160,6 +164,7 @@ function deletegroup(groupid, type) {
{{ ManagerTheme::getLexicon('access_permissions_group_link') }} + @csrf @@ -198,7 +203,7 @@ function deletegroup(groupid, type) { @foreach($userGroup->documentGroups as $documentGroup)
  • {{ $documentGroup->name }} ({{ $documentGroup->getKey() }}) ({{ $documentGroup->pivot->context ? 'web' : 'mgr' }}) - ({{ ManagerTheme::getLexicon('remove') }}) + ({{ ManagerTheme::getLexicon('remove') }})
  • @endforeach diff --git a/manager/views/partials/header.blade.php b/manager/views/partials/header.blade.php index b5b754c287..8eff64b098 100644 --- a/manager/views/partials/header.blade.php +++ b/manager/views/partials/header.blade.php @@ -7,6 +7,8 @@ + {{-- Lets scripts that post to index.php read the CSRF token without an enclosing form. --}} + @if(class_exists(Tracy\Debugger::class) && evo()->get('config')->get('tracy.active')) {!! Tracy\Debugger::renderLoader() !!} @endif