Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<?= csrf_field() ?>` (legacy `.php` / `.phtml`) inside the `<form>` |
| JS that posts to `index.php` | Send `_token` in the body, or set the `X-CSRF-TOKEN` header. Read it from `<meta name="csrf-token">` (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
Expand All @@ -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.
8 changes: 8 additions & 0 deletions assets/plugins/transalias/transalias.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions core/config/session.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
8 changes: 4 additions & 4 deletions core/src/Controllers/Frame.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
'',
'',
Expand All @@ -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'],
'',
'',
Expand Down Expand Up @@ -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'),
'',
'',
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion core/src/Controllers/TmplvarRank.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, ';'));
Expand Down
167 changes: 164 additions & 3 deletions core/src/Middleware/VerifyCsrfToken.php
Original file line number Diff line number Diff line change
@@ -1,15 +1,176 @@
<?php namespace EvolutionCMS\Middleware;

use Closure;
use Illuminate\Support\Facades\Response;

/**
* Rejects Manager requests that do not carry a valid CSRF token.
*
* The check fails closed: a request that is subject to verification is rejected when the
* token is absent, when the session holds no token, or when the two do not match. Earlier
* releases only compared the token when the request happened to supply one, which meant a
* cross-site request that simply omitted `_token` was never checked at all.
*
* @since 3.5.8
*/
class VerifyCsrfToken
{
/**
* Request methods that never change state and therefore never need a token.
*/
protected const READ_ONLY_METHODS = ['HEAD', 'OPTIONS'];

/**
* Manager actions that change state even when they are reached through GET.
*
* The legacy dispatcher routes these to processors - and to page controllers, which are
* easy to overlook because they carry no processor file - that delete, duplicate, publish,
* execute or otherwise mutate content straight from the query string. Restricting
* verification to POST would leave every one of them reachable from a plain `<img>` 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']);
}
}
74 changes: 74 additions & 0 deletions core/tests/Unit/Manager/FileManagerZipDownloadLoaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

/*
|--------------------------------------------------------------------------
| The manager loader must not outlive a download
|--------------------------------------------------------------------------
|
| The loader is switched on from beforeunload and switched off by the next page's load handler.
| A download never produces that load event - the browser sees Content-Disposition, cancels the
| navigation and leaves the current document in place - so the loader used to spin forever after
| "Download ZIP" in the file manager.
|
| main.js is a plain script rather than a module, so these assertions are made against its
| source, in the same style as the other manager script tests.
|
*/

/**
* Reads a manager script.
*/
function managerScriptSource(string $relative): string
{
return (string)file_get_contents(dirname(__DIR__, 4) . '/manager/' . $relative);
}

it('does not register an unload listener', function () {
// Chrome refuses 'unload' under the Permissions Policy: the listener never ran and logged a
// violation on every manager page.
$source = managerScriptSource('media/script/main.js');

expect($source)
->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('/<a[^>]*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"/');
});
Loading
Loading