From 374a6f470bf101ec85bb1bc74423670232c9bc23 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 25 Oct 2026 19:45:19 +0100 Subject: [PATCH 1/2] fix(core): session proxy filename validation --- core/functions/session_proxy.php | 7 +- .../Store/PackageInstallFlowService.php | 2 +- .../Mocks/legacy_session_read_worker.php | 9 +++ .../Mocks/store_dependency_error_worker.php | 24 ++++++ .../LegacySessionIdValidationTest.php | 44 ++++++++++ .../Unit/Security/ManagerReflectedXssTest.php | 12 +++ .../StoreDependencyErrorEscapingTest.php | 10 +++ manager/actions/mutate_content.dynamic.php | 10 +-- .../script/tests/tab-changed-mark.test.js | 80 +++++++++++++++++++ 9 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 core/tests/Mocks/legacy_session_read_worker.php create mode 100644 core/tests/Mocks/store_dependency_error_worker.php create mode 100644 core/tests/Unit/Security/LegacySessionIdValidationTest.php create mode 100644 core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php create mode 100644 manager/media/script/tests/tab-changed-mark.test.js diff --git a/core/functions/session_proxy.php b/core/functions/session_proxy.php index 150cbe448e..0be92f67e3 100644 --- a/core/functions/session_proxy.php +++ b/core/functions/session_proxy.php @@ -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(); @@ -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; } diff --git a/core/src/Services/Store/PackageInstallFlowService.php b/core/src/Services/Store/PackageInstallFlowService.php index d10c67c79b..f0d23bf3cf 100644 --- a/core/src/Services/Store/PackageInstallFlowService.php +++ b/core/src/Services/Store/PackageInstallFlowService.php @@ -239,7 +239,7 @@ private function buildDependencyErrorHtml(array $get): string

'; - $html .= '

Error installation



Before install ' . htmlspecialchars((string) ($get['name'] ?? '')) . '
Please install this packages:
' . implode('
', $arrDependencies) . '

'; + $html .= '

Error installation



Before install ' . htmlspecialchars((string) ($get['name'] ?? '')) . '
Please install this packages:
' . implode('
', array_map('htmlspecialchars', $arrDependencies)) . '

'; $html .= "

"; return $html; diff --git a/core/tests/Mocks/legacy_session_read_worker.php b/core/tests/Mocks/legacy_session_read_worker.php new file mode 100644 index 0000000000..f46a1ce70f --- /dev/null +++ b/core/tests/Mocks/legacy_session_read_worker.php @@ -0,0 +1,9 @@ + +[, $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))); diff --git a/core/tests/Mocks/store_dependency_error_worker.php b/core/tests/Mocks/store_dependency_error_worker.php new file mode 100644 index 0000000000..0a0f97034b --- /dev/null +++ b/core/tests/Mocks/store_dependency_error_worker.php @@ -0,0 +1,24 @@ + ['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']); +} diff --git a/core/tests/Unit/Security/LegacySessionIdValidationTest.php b/core/tests/Unit/Security/LegacySessionIdValidationTest.php new file mode 100644 index 0000000000..af9ad1c8f6 --- /dev/null +++ b/core/tests/Unit/Security/LegacySessionIdValidationTest.php @@ -0,0 +1,44 @@ +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)], +]); diff --git a/core/tests/Unit/Security/ManagerReflectedXssTest.php b/core/tests/Unit/Security/ManagerReflectedXssTest.php index fdef55a81b..b9f26288c3 100644 --- a/core/tests/Unit/Security/ManagerReflectedXssTest.php +++ b/core/tests/Unit/Security/ManagerReflectedXssTest.php @@ -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=\"\"") + ->toContain("value=\"\"") + ->toContain("name=\"content_dispo\" value=\"\"") + ->not->toContain(": \$content['parent']) ?>") + ->and(substr_count($source, ": (int) (\$content['parent'] ?? 0)) ?>"))->toBe(2); +}); diff --git a/core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php b/core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php new file mode 100644 index 0000000000..0d8d80d254 --- /dev/null +++ b/core/tests/Unit/Security/StoreDependencyErrorEscapingTest.php @@ -0,0 +1,10 @@ +,DocLister']); + + expect($html) + ->toContain('<img src=x onerror=alert(1)>
DocLister') + ->not->toContain('" data-tooltip=""> - + @@ -875,7 +875,7 @@ function evoRenderTvImageCheck(a) { " data-tooltip=""> - + @@ -928,8 +928,8 @@ function evoRenderTvImageCheck(a) { } ?> " onclick="enableParentSelection(!allowParentSelection);"> - () - + () + @@ -1411,7 +1411,7 @@ function evoRenderTvImageCheck(a) { ?> " /> - + 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); +}); From 48fc8830d9d2b58dd9d701658c158b63ecf05e99 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Sun, 25 Oct 2026 20:32:57 +0100 Subject: [PATCH 2/2] fix(core): session proxy filename validation --- core/tests/Unit/Security/SafeHtmlTest.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/core/tests/Unit/Security/SafeHtmlTest.php b/core/tests/Unit/Security/SafeHtmlTest.php index 2d31666bb8..2fbe1d94e7 100644 --- a/core/tests/Unit/Security/SafeHtmlTest.php +++ b/core/tests/Unit/Security/SafeHtmlTest.php @@ -156,13 +156,18 @@ expect($out)->toBe('
text
'); }); -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('

before

<' . $tag . '>payload

after

'); +test('executable and resource loading elements are removed with their content', function (string $tag) { + $out = (string) sanitize_rich_html('

before

<' . $tag . '>payload

after

'); - expect($out)->toBe('

before

after

'); - } -}); + expect($out)->toBe('

before

after

'); +})->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('

before

<' . $tag . ' src="https://example.test/resource" />text

after

'); + + expect($out)->toBe('

before

text

after

'); +})->with(['embed', 'img', 'input', 'source', 'track']); test('an unknown element is unwrapped so its text stays visible', function () { expect((string) sanitize_rich_html('still readable'))