@@ -189,6 +189,7 @@ function isLoginTemplate(string $path): bool
189189 $ guarded = (new ReflectionClass (VerifyCsrfToken::class))->getConstant ('MUTATING_GET_ACTIONS ' );
190190
191191 $ mutatingControllers = [
192+ 8 => 'Users/LogInOut ' , // logout destroys the manager session
192193 26 => 'RefreshSite ' , // publishes/unpublishes pending documents, clears the cache
193194 52 => 'MoveDocument ' , // reparents a document from $_REQUEST
194195 90 => 'Users/DeleteUser ' , // deletes a manager user from $_GET
@@ -216,6 +217,98 @@ function isLoginTemplate(string $path): bool
216217 expect ($ unguarded )->toBe ([]);
217218});
218219
220+ it ('guards the page actions that delete when a query parameter is present ' , function () {
221+ // Role, permission and category pages are also plain edit screens, so their action ids
222+ // cannot go into MUTATING_GET_ACTIONS; the triggering parameter is guarded instead.
223+ $ parameters = (new ReflectionClass (VerifyCsrfToken::class))->getConstant ('MUTATING_GET_PARAMETERS ' );
224+
225+ $ deleteBranches = [
226+ 'core/src/Controllers/UserRoles/UserRole.php ' => [[35 , 36 , 38 ], 'action ' , "\$_GET['action'] == 'delete' " ],
227+ 'core/src/Controllers/UserRoles/Permission.php ' => [[135 ], 'action ' , "\$_GET['action'] == 'delete' " ],
228+ 'core/src/Controllers/UserRoles/PermissionsGroups.php ' => [[136 ], 'action ' , "\$_GET['action'] == 'delete' " ],
229+ 'core/src/Controllers/SystemInfo.php ' => [[53 ], 'opcache_reset ' , "request()->boolean('opcache_reset') " ],
230+ 'manager/actions/mutate_module_resources.dynamic.php ' => [[113 ], 'op ' , "switch ( \$_REQUEST['op']) " ],
231+ 'manager/actions/category_mgr/inc/request_trigger.inc.php ' => [
232+ [120 , 121 ],
233+ 'module_categories_manager ' ,
234+ "\$_GET[ \$cm->get('request_key')]['delete'] " ,
235+ ],
236+ ];
237+
238+ $ unguarded = [];
239+
240+ foreach ($ deleteBranches as $ relative => [$ actions , $ parameter , $ trigger ]) {
241+ expect ((string )file_get_contents (evoRoot () . '/ ' . $ relative ))->toContain ($ trigger );
242+
243+ foreach ($ actions as $ action ) {
244+ if (($ parameters [$ action ] ?? null ) !== $ parameter ) {
245+ $ unguarded [] = $ relative . ' (a= ' . $ action . ') ' ;
246+ }
247+ }
248+ }
249+
250+ $ categories = (string )file_get_contents (evoRoot () . '/manager/actions/mutate_categories.dynamic.php ' );
251+ expect ($ categories )->toContain ("'request_key' => 'module_categories_manager' " );
252+
253+ expect ($ unguarded )->toBe ([]);
254+ });
255+
256+ it ('appends a token to the OPcache reset link ' , function () {
257+ // The link is built in the controller, so the template scan below does not see it.
258+ $ source = (string )file_get_contents (evoRoot () . '/core/src/Controllers/SystemInfo.php ' );
259+
260+ preg_match_all ('/^.*opcache_reset=1.*$/m ' , $ source , $ links );
261+
262+ expect ($ links [0 ])->not ->toBeEmpty ();
263+
264+ foreach ($ links [0 ] as $ link ) {
265+ expect ($ link )->toContain ('_token= ' );
266+ }
267+ });
268+
269+ it ('appends a token to the logout links built outside the templates ' , function () {
270+ // The template scan covers the Blade links; these are assembled in PHP and in the theme script.
271+ $ theme = (string )file_get_contents (evoRoot () . '/core/src/ManagerTheme.php ' );
272+ $ script = (string )file_get_contents (evoRoot () . '/manager/media/style/default/js/evo.js ' );
273+
274+ expect ($ theme )->toContain ("'logouturl' => EVO_MANAGER_URL . 'index.php?a=8' . (isset( \$_SESSION) ? '&_token=' . rawurlencode(csrf_token()) : '') " )
275+ ->and ($ script )->toContain ("'?a=8&_token=' + encodeURIComponent(evo.tabsCsrfToken()) " )
276+ ->and ($ script )->not ->toMatch ("/\?a=8';/ " );
277+ });
278+
279+ it ('appends a token to every role, permission and category delete link ' , function () {
280+ $ patterns = [
281+ '/\ba=(35|36|38|135|136)&action=delete/ ' ,
282+ "/makeUrl\('actions\.delete'\) }}&action=delete/ " ,
283+ "/request_key'\); \?>\[delete\]=/ " ,
284+ ];
285+
286+ $ found = 0 ;
287+ $ untokenised = [];
288+
289+ foreach (managerTemplateFiles () as $ path ) {
290+ $ lines = explode ("\n" , (string )file_get_contents ($ path ));
291+
292+ foreach ($ lines as $ index => $ line ) {
293+ foreach ($ patterns as $ pattern ) {
294+ if (!preg_match ($ pattern , $ line )) {
295+ continue ;
296+ }
297+
298+ $ found ++;
299+
300+ if (!str_contains ($ line , '_token ' )) {
301+ $ untokenised [] = str_replace (evoRoot () . '/ ' , '' , $ path ) . ': ' . ($ index + 1 );
302+ }
303+ }
304+ }
305+ }
306+
307+ // user_role, permission, permissions_groups, element_permission and the category editor.
308+ expect ($ found )->toBeGreaterThanOrEqual (5 )
309+ ->and ($ untokenised )->toBe ([]);
310+ });
311+
219312it ('guards the disable toggle branch of the element save processors ' , function () {
220313 // Each save_* processor acts on ?disabled= before it ever looks at the POST body.
221314 $ toggles = [
0 commit comments