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
7 changes: 6 additions & 1 deletion core/functions/session_proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ private static function migrateLegacySessionIfNeeded($store): void
*/
private static function readLegacySessionPayload(string $sessionId): ?string
{
// The cookie becomes part of a file name; Windows resolves "sess_\..\" even without a sess_ folder.
if (preg_match('/^[A-Za-z0-9,-]{1,256}$/', $sessionId) !== 1) {
return null;
}

$savePath = session_save_path();
if (!is_string($savePath) || $savePath === '') {
$savePath = sys_get_temp_dir();
Expand All @@ -282,7 +287,7 @@ private static function readLegacySessionPayload(string $sessionId): ?string
}

$file = rtrim($path, "/\\") . DIRECTORY_SEPARATOR . 'sess_' . $sessionId;
if (!is_readable($file)) {
if (!is_file($file) || !is_readable($file)) {
return null;
}

Expand Down
2 changes: 1 addition & 1 deletion core/src/Services/Store/PackageInstallFlowService.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ private function buildDependencyErrorHtml(array $get): string
<meta http-equiv="Content-Type" content="text/html; charset="utf-8" />
<link rel="stylesheet" href="' . EVO_SITE_URL . 'assets/modules/store/installer/style.css" type="text/css" media="screen" /></head>
<body' . $bodyClass . '><div id="contentarea"><div class="container_12"><br>';
$html .= '<h2>Error installation</h2><br><br><p>Before install ' . htmlspecialchars((string) ($get['name'] ?? '')) . '<br> Please install this packages: <br>' . implode('<br>', $arrDependencies) . '</p>';
$html .= '<h2>Error installation</h2><br><br><p>Before install ' . htmlspecialchars((string) ($get['name'] ?? '')) . '<br> Please install this packages: <br>' . implode('<br>', array_map('htmlspecialchars', $arrDependencies)) . '</p>';
$html .= "</div><!-- // content --></div><!-- // contentarea --><br /></body></html>";

return $html;
Expand Down
9 changes: 9 additions & 0 deletions core/tests/Mocks/legacy_session_read_worker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php
// Reads the legacy session payload for an id from a save path in a fresh process
// (session_save_path() refuses changes once output was sent). argv: <save path> <hex id>
[, $savePath, $hexId] = $argv;
require_once dirname(__DIR__, 2) . '/functions/session_proxy.php';

session_save_path($savePath);
$method = new ReflectionMethod(EvoSessionProxy::class, 'readLegacySessionPayload');
echo json_encode($method->invoke(null, hex2bin($hexId)));
24 changes: 24 additions & 0 deletions core/tests/Mocks/store_dependency_error_worker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
// Renders the Store "install these packages first" page for argv[1] with no installed elements,
// so every requested dependency is reported missing. Prints the HTML.
namespace EvolutionCMS\Models {
class StoreWorkerEmptyQuery
{
public function whereIn($key, $values) { return $this; }
public function pluck($key) { return []; }
}
class SiteSnippet { public static function query() { return new StoreWorkerEmptyQuery(); } }
class SitePlugin extends SiteSnippet {}
class SiteModule extends SiteSnippet {}
}

namespace {
define('EVO_SITE_URL', 'https://example.invalid/');
function evo() { return (object) ['config' => ['manager_theme_mode' => 0]]; }

require dirname(__DIR__, 2) . '/src/Services/Store/PackageInstallFlowService.php';

$class = new ReflectionClass(\EvolutionCMS\Services\Store\PackageInstallFlowService::class);
$method = $class->getMethod('buildDependencyErrorHtml');
echo $method->invoke($class->newInstanceWithoutConstructor(), ['dependencies' => $argv[1], 'name' => 'pkg']);
}
44 changes: 44 additions & 0 deletions core/tests/Unit/Security/LegacySessionIdValidationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
| The legacy session cookie is appended to "sess_" to find the old session file. PHP collapses
| "sess_/../" without the folder existing, so an unchecked cookie could read any file and import
| it into the visitor's session.
*/

function legacySessionRead(string $savePath, string $sessionId): ?string
{
// Hex keeps a null byte intact on the command line.
$out = evoRunPhp(dirname(__DIR__, 2) . '/Mocks/legacy_session_read_worker.php', [$savePath, bin2hex($sessionId)]);

return json_decode($out, true, flags: JSON_THROW_ON_ERROR);
}

beforeEach(function () {
$this->root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'evo-legacy-session-' . bin2hex(random_bytes(4));
$this->store = $this->root . DIRECTORY_SEPARATOR . 'store';
mkdir($this->store, 0777, true);
file_put_contents($this->root . DIRECTORY_SEPARATOR . 'planted.txt', 'mgrValidated|b:1;');
file_put_contents($this->store . DIRECTORY_SEPARATOR . 'sess_abc123,DEF-456', 'legacy|s:2:"ok";');
});

afterEach(function () {
@unlink($this->root . DIRECTORY_SEPARATOR . 'planted.txt');
@unlink($this->store . DIRECTORY_SEPARATOR . 'sess_abc123,DEF-456');
@rmdir($this->store);
@rmdir($this->root);
});

it('reads a legacy session file named by a well-formed id', function () {
expect(legacySessionRead($this->store, 'abc123,DEF-456'))->toBe('legacy|s:2:"ok";');
});

it('refuses ids that would leave the session directory', function (string $id) {
expect(legacySessionRead($this->store, $id))->toBeNull();
})->with([
'slash traversal' => ['/../../planted.txt'],
'backslash traversal' => ['\\..\\..\\planted.txt'],
'dot segment' => ['..'],
'null byte' => ["abc\0"],
'too long' => [str_repeat('a', 257)],
]);
12 changes: 12 additions & 0 deletions core/tests/Unit/Security/ManagerReflectedXssTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,15 @@ function paginateLinks(string $extargv): array
->and(html_entity_decode(strip_tags($html)))->toBe('Summer sale & more')
->not->toContain('onerror');
});

it('escapes the document fields restored from a posted form', function () {
// A template switch or a failed save re-renders the form from $_POST, not from the saved document.
$source = managerSource('manager/actions/mutate_content.dynamic.php');

expect($source)
->toContain("value=\"<?= e(stripslashes(get_by_key(\$content, 'alias', '', 'is_scalar'))) ?>\"")
->toContain("value=\"<?= (int) (\$content['menuindex'] ?? 0) ?>\"")
->toContain("name=\"content_dispo\" value=\"<?= (int) (\$content['content_dispo'] ?? 0) ?>\"")
->not->toContain(": \$content['parent']) ?>")
->and(substr_count($source, ": (int) (\$content['parent'] ?? 0)) ?>"))->toBe(2);
});
17 changes: 11 additions & 6 deletions core/tests/Unit/Security/SafeHtmlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,18 @@
expect($out)->toBe('<div class="x">text</div>');
});

test('executable and resource loading elements are removed with their content', function () {
foreach (['script', 'style', 'iframe', 'object', 'embed', 'form', 'svg'] as $tag) {
$out = (string) sanitize_rich_html('<p>before</p><' . $tag . '>payload</' . $tag . '><p>after</p>');
test('executable and resource loading elements are removed with their content', function (string $tag) {
$out = (string) sanitize_rich_html('<p>before</p><' . $tag . '>payload</' . $tag . '><p>after</p>');

expect($out)->toBe('<p>before</p><p>after</p>');
}
});
expect($out)->toBe('<p>before</p><p>after</p>');
})->with(['script', 'style', 'iframe', 'object', 'form', 'svg']);

test('void resource loading elements are removed while adjacent text survives', function (string $tag) {
// Void elements have no children; self-closing syntax also keeps older libxml parsers consistent.
$out = (string) sanitize_rich_html('<p>before</p><' . $tag . ' src="https://example.test/resource" />text<p>after</p>');

expect($out)->toBe('<p>before</p>text<p>after</p>');
})->with(['embed', 'img', 'input', 'source', 'track']);

test('an unknown element is unwrapped so its text stays visible', function () {
expect((string) sanitize_rich_html('<marquee>still readable</marquee>'))
Expand Down
10 changes: 10 additions & 0 deletions core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

// The missing dependency names come from the request (?dependencies=a,b) and are listed back on the page.
it('escapes the missing dependency names it lists', function () {
$html = evoRunPhp(dirname(__DIR__, 2) . '/Mocks/store_dependency_error_worker.php', ['<img src=x onerror=alert(1)>,DocLister']);

expect($html)
->toContain('&lt;img src=x onerror=alert(1)&gt;<br>DocLister')
->not->toContain('<img src=x');
});
10 changes: 5 additions & 5 deletions manager/actions/mutate_content.dynamic.php
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ function evoRenderTvImageCheck(a) {
<i class="<?= $_style["icon_question_circle"] ?>" data-tooltip="<?=ManagerTheme::getLexicon('resource_alias_help');?>"></i>
</td>
<td>
<input name="alias" type="text" maxlength="100" value="<?= stripslashes(get_by_key($content, 'alias', '', 'is_scalar')) ?>" class="inputBox" onchange="documentDirty=true;" />
<input name="alias" type="text" maxlength="100" value="<?= e(stripslashes(get_by_key($content, 'alias', '', 'is_scalar'))) ?>" class="inputBox" onchange="documentDirty=true;" />
</td>
</tr>
<tr>
Expand Down Expand Up @@ -875,7 +875,7 @@ function evoRenderTvImageCheck(a) {
<i class="<?= $_style["icon_question_circle"] ?>" data-tooltip="<?=ManagerTheme::getLexicon('resource_opt_menu_index_help');?>"></i>
</td>
<td>
<input name="menuindex" type="text" maxlength="6" value="<?= $content['menuindex'] ?>" class="inputBox" onchange="documentDirty=true;" />
<input name="menuindex" type="text" maxlength="6" value="<?= (int) ($content['menuindex'] ?? 0) ?>" class="inputBox" onchange="documentDirty=true;" />
<a href="javascript:;" class="btn btn-secondary" onclick="var elm = document.mutate.menuindex;var v=parseInt(elm.value+'')-1;elm.value=v>0? v:0;elm.focus();documentDirty=true;return false;"><i class="<?= $_style['icon_angle_left'] ?>"></i></a>
<a href="javascript:;" class="btn btn-secondary" onclick="var elm = document.mutate.menuindex;var v=parseInt(elm.value+'')+1;elm.value=v>0? v:0;elm.focus();documentDirty=true;return false;"><i class="<?= $_style['icon_angle_right'] ?>"></i></a>
</td>
Expand Down Expand Up @@ -928,8 +928,8 @@ function evoRenderTvImageCheck(a) {
}
?>
<i id="plock" class="<?= $_style["icon_folder"] ?>" onclick="enableParentSelection(!allowParentSelection);"></i>
<b><span id="parentName"><?= (isset($_REQUEST['pid']) ? entities($_REQUEST['pid']) : $content['parent']) ?> (<?= entities($parentname) ?>)</span></b>
<input type="hidden" name="parent" value="<?= (isset($_REQUEST['pid']) ? entities($_REQUEST['pid']) : $content['parent']) ?>" onchange="documentDirty=true;" />
<b><span id="parentName"><?= (isset($_REQUEST['pid']) ? entities($_REQUEST['pid']) : (int) ($content['parent'] ?? 0)) ?> (<?= entities($parentname) ?>)</span></b>
<input type="hidden" name="parent" value="<?= (isset($_REQUEST['pid']) ? entities($_REQUEST['pid']) : (int) ($content['parent'] ?? 0)) ?>" onchange="documentDirty=true;" />
</td>
</tr>
<tr></tr>
Expand Down Expand Up @@ -1411,7 +1411,7 @@ function evoRenderTvImageCheck(a) {
?>
<input type="hidden" name="contentType" value="<?= e(isset($content['contentType']) ? $content['contentType'] : "text/html") ?>" />
<input type="hidden" name="type" value="document" />
<input type="hidden" name="content_dispo" value="<?= (isset($content['content_dispo']) ? $content['content_dispo'] : '0') ?>" />
<input type="hidden" name="content_dispo" value="<?= (int) ($content['content_dispo'] ?? 0) ?>" />
<?php
} else {
// non-admin managers creating or editing a reference (weblink) resource
Expand Down
80 changes: 80 additions & 0 deletions manager/media/script/tests/tab-changed-mark.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const helper = require('../document-save-helper');

// "Save and continue" saves over XHR and keeps the editor open, so the tab frame is not reloaded
// and its onload never clears the "changed" mark; the editor announces the save instead.
const root = path.join(__dirname, '..', '..', '..', '..');
const evoSource = fs.readFileSync(path.join(root, 'manager', 'media', 'style', 'default', 'js', 'evo.js'), 'utf8');
const editorSource = fs.readFileSync(path.join(root, 'manager', 'actions', 'mutate_content.dynamic.php'), 'utf8');

function loadWatchChanges() {
const match = evoSource.match(/watchChanges: (function \(tabWindow\) \{[\s\S]*?\n {16}\}),\n/);
assert.ok(match, 'tab.watchChanges is defined');
return new Function('return (' + match[1] + ');')();
}

function tab() {
const classes = new Set();
return {
watchChanges: loadWatchChanges(),
tab: { classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) } },
classes,
};
}

function frame() {
const doc = new EventTarget();
const live = { click: new Set(), keyup: new Set() };
const add = doc.addEventListener.bind(doc);
const remove = doc.removeEventListener.bind(doc);
doc.addEventListener = (type, fn, o) => { live[type].add(fn); add(type, fn, o); };
doc.removeEventListener = (type, fn, o) => { live[type].delete(fn); remove(type, fn, o); };
const listeners = () => ({ click: live.click.size, keyup: live.keyup.size });
return { win: { document: doc, documentDirty: false }, listeners };
}

test('the tab is marked changed only once the editor is dirty', () => {
const t = tab();
const { win, listeners } = frame();
t.watchChanges(win);

win.document.dispatchEvent(new Event('click'));
assert.equal(t.classes.has('changed'), false);

win.documentDirty = true;
win.document.dispatchEvent(new Event('keyup'));
assert.equal(t.classes.has('changed'), true);
assert.deepEqual(listeners(), { click: 0, keyup: 0 });
});

test('watching again after a save does not stack listeners and marks the next edit', () => {
const t = tab();
const { win, listeners } = frame();
t.watchChanges(win);
t.watchChanges(win);
assert.deepEqual(listeners(), { click: 1, keyup: 1 });

win.documentDirty = true;
win.document.dispatchEvent(new Event('click'));
assert.equal(t.classes.has('changed'), true);

// what the evo:saved handler does after a successful save
win.documentDirty = false;
t.tab.classList.remove('changed');
t.watchChanges(win);
assert.deepEqual(listeners(), { click: 1, keyup: 1 });

win.documentDirty = true;
win.document.dispatchEvent(new Event('keyup'));
assert.equal(t.classes.has('changed'), true);
});

test('the editor announces a successful in-place save and the tab listens for it', () => {
assert.match(editorSource, /ajaxSaveHelper\.applyResult\([^\n]*\);\s*ajaxSaveHelper\.announceSaved\(document\);/);
const handler = evoSource.match(/addEventListener\('([^']+)', function \(\) \{\s*if \(!tabWindow\.documentDirty\) \{\s*s\.tab\.classList\.remove\('changed'\);\s*s\.watchChanges\(tabWindow\);/);
assert.ok(handler, 'evo.js clears the mark on a save event');
assert.equal(handler[1], helper.savedEvent);
});
Loading