Evolution CMS is a community-maintained fork of MODX Evolution, rebuilt on Laravel 13 components. It is a PHP CMS and application framework with a tree-based document/resource model, a template variable (TV) system, plugins, snippets, chunks, and a modular extras ecosystem.
PHP requirement: >= 8.4.1 (8.5 supported; 8.6 RC tested) · License: GPL-3.0-or-later
/ ← web root (Apache/Nginx document root)
├── index.php ← front-end entry point
├── manager/ ← admin panel entry point (manager/index.php)
├── assets/ ← public uploads, cache, plugins, snippets, templates
│ ├── cache/ ← compiled template/TV cache (git-ignored)
│ ├── files/ ← user-uploaded files
│ ├── images/ ← user-uploaded images
│ ├── plugins/ ← installed plugin JS/CSS assets
│ └── templates/ ← front-end HTML templates
├── themes/ ← manager UI themes (default: demo)
├── views/ ← Blade layouts for the manager UI
├── core/ ← CMS core (NOT web-accessible; block in nginx/Apache)
│ ├── artisan ← CLI entry point: php core/artisan <command>
│ ├── bootstrap.php ← application bootstrap
│ ├── composer.json ← core dependencies (Laravel 13, Pest, etc.)
│ ├── config/ ← Laravel-style config files
│ ├── custom/ ← LOCAL OVERRIDES — copy examples here, never edit originals
│ │ ├── composer.json.example → custom/composer.json (add extra packages)
│ │ ├── define.php.example → custom/define.php (constants override)
│ │ ├── routes.php.example → custom/routes.php (add Laravel routes)
│ │ └── config/ → override any core config key per-subdirectory
│ ├── database/
│ │ ├── migrations/ ← Eloquent migrations (single consolidated migration)
│ │ └── seeders/
│ ├── functions/ ← autoloaded global helpers (helper.php, nodes.php, etc.)
│ ├── lang/ ← locale files (en, ru, de, fr, …)
│ ├── modifiers/ ← output modifier include files (mdf_*.inc.php)
│ ├── src/ ← PSR-4 namespace EvolutionCMS\
│ │ ├── Console/ ← Artisan commands
│ │ ├── Controllers/ ← manager action controllers
│ │ ├── Extensions/ ← Router, Collection extensions
│ │ ├── Facades/
│ │ ├── Legacy/ ← legacy shim classes (Cache, ManagerApi, Modifiers…)
│ │ ├── Middleware/
│ │ ├── Models/ ← Eloquent models (SiteContent, SiteTemplate, SitePlugin…)
│ │ ├── Providers/ ← ~30 Laravel service providers
│ │ ├── Services/ ← thin service layer (AuthServices, ConfigService…)
│ │ └── Support/
│ ├── storage/ ← Laravel storage (logs, cache, compiled views)
│ └── tests/ ← Pest test suite
│ ├── Feature/ ← feature/integration tests
│ └── Unit/ ← unit tests (Console/, CoreTest.php)
├── composer.json ← root composer (thin; delegates to core/)
├── phpstan.neon ← static analysis config (level 0, excludes Legacy/)
└── publiccode.yml ← structured information about version and project
All commits in this project use a bracketed prefix:
type(scope): short description (#issue)
Allowed lowercase types:
add— net-new file, feature, surface, contract, rule, or generated capabilityfeat— net-new user-facing feature or capability where feature wording is clearestfix— bug fix or defect repairupd— update to existing behavior, content, generated output, or workflowref— refactor or restructure without changing intended product meaningdel— explicit removal of obsolete code, docs, links, generated files, or flows
Examples:
add(ex42): add feed source worker (#69)ref(source): split API source transport (#69)fix(ex42): repair source run diagnostics
Use a short concrete scope, write the description in English, do not add a final period,
and append (#issue) only when the commit is tied to an issue.
3.5.x— main stable branch; open PRs against this branch- Feature/fix branches are named descriptively:
fix-rich-text-selection-when-editor-disabled - Do not push directly to
3.5.x; always open a PR
Tests use Pest and live in core/tests/.
# From the core/ directory:
composer test
# Or directly:
cd core && vendor/bin/pestPHPUnit sources cover: factory/, functions/, includes/, modifiers/, src/.
The SQLite test database is core/database/evo-test.sqlite.
# From the repository root:
composer analyze
# Equivalent: php -d xdebug.mode=off vendor/bin/phpstan --memory-limit=512M
# Scope: core/src/ (Legacy/ is excluded)
# Level: 0 (introductory — PRs should not increase error count)The CMS uses a subset of Laravel's Artisan. The entry point is core/artisan.
php core/artisan list # list all commands
php core/artisan cache:clear-full # clear all caches
php core/artisan make:site update # update site files and run pending migrations / updates
php core/artisan package:extras # manage extras/packages
php core/artisan deprecated:list # list deprecated code by semver
php core/artisan translations:sync # sync translation files
php core/artisan doc:list # list content documents
php core/artisan route:list # list registered routes
php core/artisan tpl:list # list registered templates
php core/artisan tv:list # list registered TVsWhen a site was installed from a branch/ref that ends with .x, web and CLI site updates
must target the same .x branch/ref unless the operator explicitly selects another target.
Update migrations, seeders, and data fixes must be trackable or idempotent so repeated
make:site update runs do not break already-updated installations.
Switching lines is one explicit ref away, in either direction (make:site update 3.5.x,
make:site update 3.5.8, or a fork as make:site update https://github.com/<owner>/<repo>/tree/<ref>).
After a non-release or fork update, make:site notes ref / repository / commit (the archive's
commit, from the zip comment GitHub writes) in core/factory/version.php; the manager update widget
(assets/plugins/updater) reads them to offer the branch again once its head moves;
an update without a version follows that note, and never moves a site back to an older release on
its own. Keep it there: every way of replacing the code also replaces that file, so an overlay of a
release clears the note. Do not rely on install/ for anything a switch needs: it is deleted after
installation, and a site moved by git, Composer or an older updater never runs it. Compiled Blade
views are keyed on the template file time (EvolutionCMS\Extensions\BladeCompiler) for that reason,
and packages of core/custom/composer.json are restored by make:site, the CLI updater and the web
installer (restoreCustomComposerPackages()) through EvolutionCMS\Services\CustomComposerPackages,
under one policy. Do not add a restore path that skips it:
- Versions: what the site ran (
previousVersions(): the lock while it still lists them, elsecore/custom/composer.installed.json, written by every restore and bypackage:installrequire), pinned bypins(); a package the release ships or the core requires keeps the release's version. A branch from git or hg is pinned at its commit (dev-main#<commit>). A branch without a source keeps the dist reference Composer prints for it (dev-main <reference>, a path repository's hash or HEAD), to compare only:composerVersion()drops it before anything reaches Composer. A branch whose reference is not known on both sides counts as a change, so it needs consent. - Arguments:
updateArguments(),--with-dependencies(--minimal-changesfrom Composer 2.7), never--with-all-dependencies, which moves the core's own packages too. plan()dry run first (planInstall(), acomposer install --dry-run, when nothing needs restoring: the merged requirements must still fit the lock). Any change beyond installing those versions needs consent (make:site --update-custom-packages,cli-install.php --updateCustomPackages=y).make:siteruns it against the unpacked archive beforemoveFiles()(preflightComposer()), so a refusal changes nothing. The stagedcustom/composer.jsonis rebased (rebasedCustomComposerJson(): the merge plugin prefixescustom/to path repositories and toextra.merge-plugin.include/require, a list or a single path) and removed again beforemoveFiles(), which would otherwise lay it over the site's own.- The merge plugin runs with
"replace": true: a custom constraint replaces the core's own, so a custom requirement on a core package is checked against the lock like any other (pins()). - A restore that fails leaves the installers' update incomplete: the CLI exits with 1 and keeps
install/, the web installer counts an error. Both restore after the core migrations.
Never let the Composer of core/vendor replace its own packages: it loads its classes from the directory
it rewrites, and an update that replaces one of them (symfony/finder, symfony/console...) stops it
halfway with those packages deleted. Prefer a standalone Composer. Whether a command runs the vendor copy
is asked of the command (isBundledComposer(): COMPOSER_BINARY, the PATH, links and wrapper scripts
are resolved), never of the way it was found; plan() and planInstall() refuse changes to
composerRuntimePackages() for it, and make:site asks them on every update that runs it, custom
packages or not (an update package's vendor/ may differ from its lock: 3.5.8's does).
Install Composer packages from the core/ directory through the Evolution CMS package installer:
cd core
php artisan package:installrequire vendor/package "*"Do not use composer require directly and do not manually add packages to the root
composer.json. The Artisan command registers the dependency in core/custom/composer.json and
runs Composer in the correct Evolution CMS context.
After installation, run vendor:publish only when the package documents a provider or publish tag,
then apply package migrations when required:
php artisan vendor:publish --provider="Vendor\\Package\\PackageServiceProvider"
php artisan migratecore/composer.json sets config.classmap-authoritative, so every composer install, update,
dump-autoload (and composer optimize, which installs without dev packages in one step) writes an
autoloader that never looks for a class outside vendor/composer/autoload_classmap.php. Whatever puts new
PHP classes on a site must rebuild it after the files are in place: Composer and the package commands do,
make:site does after moveFiles(), and so does the web installer after an update through the manager's
updater (installerInstallComposerDependencies(), after the custom packages), with the same
CustomComposerPackages::INSTALL_ARGUMENTS / DUMP_AUTOLOAD_ARGUMENTS; a failure leaves the update incomplete. A class copied in by hand, git or FTP (core/src,
core/custom/packages/*/src, a package under vendor/) does not exist until composer dump-autoload.
Do not register namespaces on the Composer loader at runtime: an authoritative loader ignores them.
tests/ stays out of the classmap; tests/Pest.php loads the Tests\ classes itself, and a script a test
runs in its own process requires the ones it uses.
A folder a package publishes into (assets/plugins/<name>, assets/modules/<name>, assets/snippets/<name>,
as its provider's publishes() declares) is rebuilt from core/vendor and stays out of the site's repository:
every artisan run gives it a .gitignore of * when it has none (EvolutionCMS\Support\PackageAssetFolders,
called from EvolutionCMS\Console once all providers have booted). Declare such assets through publishes(),
even when the package also copies them itself while it boots.
core/custom/ is the sanctioned location for all site-specific overrides. Core files must never be edited directly.
| File to create | Copy from | Purpose |
|---|---|---|
core/custom/define.php |
define.php.example |
Override CMS constants (paths, session config, etc.) |
core/custom/routes.php |
routes.php.example |
Register Laravel routes; use Route::fallbackToParser() to preserve CMS routing |
core/custom/composer.json |
composer.json.example |
Add extra Composer packages (namespace: EvolutionCMS\Custom\) |
core/custom/config/cms/settings.php |
config/cms/settings.php.example |
Override CMS settings |
core/custom/config/app/providers.php |
— | Register additional service providers |
core/custom/config/database/connections/ |
— | Add database connections |
The merge-plugin in core/composer.json automatically includes core/custom/composer.json.
| Namespace | Location | Purpose |
|---|---|---|
EvolutionCMS\ |
core/src/ |
Core CMS classes |
EvolutionCMS\Custom\ |
core/custom/src/ |
Project-specific extensions |
EvolutionCMS\Models\ |
core/src/Models/ |
Eloquent models |
Database\Seeders\ |
core/database/seeders/ |
Database seeders |
Tests\ |
core/tests/ |
Test classes |
SiteContent— resources/documents (the page tree)SiteTemplate— templatesSitePlugin— plugins (event-driven PHP)SiteSnippet— snippets (callable PHP fragments)SiteHtmlsnippet— chunks (reusable HTML)SiteTmplvar/SiteTmplvarContentvalues— template variables (TVs)User/UserAttribute— manager usersCategory— categorisation for all element types
There are ~30 service providers in core/src/Providers/. Notable ones:
RoutingServiceProvider— registers the CMS front-end parser as a route fallbackTemplateProcessorServiceProvider— registers the template tag parserManagerThemeServiceProvider— manager UITracyServiceProvider— Tracy debug bar integrationModifiersServiceProvider— output modifiers
A bundled package that serves the manager (EvoUI with Livewire, dTui editor, dDocs, sGallery, sTask) is
declared in app.on_demand_providers (core/config/app.php) next to its app.providers entry: a frontend
request registers it only on its services (facade accessors), a class of its namespace or its events
(EvolutionCMS\Support\OnDemandProviders); a request to any declared route paths registers all of them,
because their manager pages need EvoUI and Livewire booted before routing. Manager, console, installer and kept
worker applications register everything. Booting such packages on every page cost the 3.6 frontend about 17 ms, so
declare a new one there too. Test: core/tests/Unit/OnDemandProvidersTest.php.
- Resources/Documents — tree-based pages; each has a template, TVs, and content
- Chunks — reusable HTML fragments called with
{{ChunkName}} - Snippets — PHP code blocks called with
[[SnippetName? ¶m=value-one; ¶m2=value-two]] - Plugins — PHP code triggered by system events (OnPageNotFound, OnLoadWebDocument, etc.)
- Template Variables (TVs) — custom fields attached to templates
[*tvName*] - Output Modifiers — pipe-chained filters on tag output:
[*field*:modifier] - Templates — whole page HTML with chunks, snippets, TVs
Application developers can use these directives in Blade views instead of duplicating their underlying CMS logic:
| Directive | Purpose |
|---|---|
@evoConfig('key') |
Output an Evolution CMS configuration value |
@makeUrl($id) |
Build a CMS URL for a resource identifier |
@revision('path/to/file.css') |
Build a public static-file URL with an mtime-based cache revision |
@evoParser($value) |
Process Evolution CMS parser tags in a value |
@evoRole('role') / @evoElseRole('role') / @evoEndRole |
Conditionally render content for a manager role |
@auth / @guest |
Conditionally render content for authenticated or guest users |
@lang('key') |
Output a localized translation string |
Blade also provides its standard Laravel syntax. The most commonly used directives are:
| Group | Directives |
|---|---|
| Layouts | @extends, @section, @yield, @include, @component, @slot, @push, @stack |
| Conditions | @if, @elseif, @else, @unless, @isset, @empty, @switch, @case, @default |
| Loops | @for, @foreach, @forelse, @while, @break, @continue |
| Forms and security | @csrf, @method, @error |
| PHP and escaping | {{ $value }}, {!! $html !!}, @php, @verbatim |
{!! !!} disables escaping for the whole expression and is the usual cause of stored XSS in the
manager. Prefer these, in this order:
| Situation | Use |
|---|---|
| Plain text, attribute values, CSS class lists | {{ $value }} |
Anything inside a <script> element or an inline event handler |
@js($value) (or js_json() for a raw JSON island) |
| Stored short text rendered in a cell or label (element descriptions, captions) | {{ sanitize_inline_html($value) }} |
| Stored rich markup that must keep looking the way it does (Event Log reports) | {{ sanitize_rich_html($value) }} |
Stored text that the CMS parser runs over afterwards (ManagerTheme::makeTemplate() placeholders such as dashboard widgets, theme snippet output) |
escape_html_and_tags($value) - e() alone leaves [[snippet]], {{chunk}} and [(setting)] to run in the viewer's request |
| Theme icons | {{ icon_html($_style['icon_x']) }} / icon_markup() for string concatenation |
| Theme style blocks and lexicon entries that ship their own markup | {{ ManagerTheme::styleHtml('key') }} / {{ ManagerTheme::lexiconHtml('key', [...]) }} |
| Markup a PHP producer builds itself | return Illuminate\Support\HtmlString and print it with {{ }} |
{{ }} compiles to e(), which leaves Htmlable values untouched - so a producer that returns
HtmlString is printed verbatim and is never encoded twice. Reach for {!! !!} only for output
that is HTML by contract and outside the CMS's control (plugin event results, phpinfo(),
registered client scripts).
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 |
A page that renders on plain GET but changes state once a query parameter is present (?action=delete, ?op=add) |
Add action id => parameter to VerifyCsrfToken::MUTATING_GET_PARAMETERS instead, so navigation to the page still works, and append &_token= to the links that carry the parameter |
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::$actionswith no file undermanager/processors/. Auditing only the processor directory misses them — that is howa=90(DeleteUser, deletes a user straight from$_GET),a=92,a=52anda=26were initially left unguarded. $_REQUESTmeans GET works. A processor whose UI only ever posts is still reachable by query string if it reads$_REQUEST, so it belongs inMUTATING_GET_ACTIONStoo.
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.
The classic file manager (manager/actions/files.dynamic.php, helpers in
core/functions/actions/files.php) decides access in three layers. Every write path must go through
them; do not add a second scheme.
1. Folders, by permission - fileManagerProtectedPaths(). A listed folder is blocked for
reading, saving, deleting, uploading and ZIP extraction unless the user holds the permission. No
folder is protected unconditionally.
| Folder(s) | Open if the user has |
|---|---|
temp/backup, assets/backup |
bk_manager |
assets/plugins |
save_plugin |
assets/snippets |
save_snippet |
assets/templates |
save_template |
assets/modules |
save_module |
assets/cache |
empty_cache |
temp/import, assets/import |
import_static |
temp/export, assets/export |
export_static |
manager/, core/ |
any one of save_snippet, save_plugin, save_module (fileManagerMayRunCode()) |
every view path (config('view.paths'), by default views/; fileManagerTemplateViewPaths()) |
the same code permission: a template file there is executed by its engine, whatever the extension |
everything else under filemanager_path |
file_manager only |
save_snippet, save_plugin and save_module store PHP that the server runs, so whoever holds one
can already change anything under core/ and manager/. save_template does not: a database
template only calls existing snippets.
That set lives in one place, EvolutionCMS\Support\BackendCodePermissions (PERMISSIONS,
granted(), grantedBy()). Any action that lets a manager store backend code (PHP, or SQL, which
reaches PHP) must ask it rather than list permissions of its own: executable file names, template
files, @EVAL / @SELECT / @INCLUDE TV bindings, and database restore all do. It does not cover
client-side code: JS or HTML in a chunk or document is governed by that element's permission.
Test: core/tests/Unit/Security/BackendCodePermissionsTest.php.
A template file is a different thing from a database template, even though both are saved from the
same form. A file under a view path is rendered by an engine that executes it — the php engine
is a bare include, blade.php compiles to PHP, and a plugin can register latte, twig and
others through view.template_engines. So writing or scaffolding a template file is authoring
server-side code, whatever the extension, and there is no "safe" engine to allow-list. That write
needs a code permission (fileManagerMayRunCode(): one of save_snippet / save_plugin /
save_module), not save_template. Two paths reach it, and both enforce it:
- Both file managers, through the view-path row of layer 1 (
fileManagerTemplateViewPaths(); a view path is notassets/templates: that folder holds parsed front-end HTML and stays onsave_template). Test:TemplateFileWritePermissionTest.php. manager/processors/save_template.processor.php, when the form poststemplatesource=fileor asks for a file to be created. It callsEvolutionCMS\Support\TemplateFileGuardbefore touching the filesystem; asave_template-only user keeps database templates and is refused the file. Do not add another template-file write path that skips this guard. Test:core/tests/Unit/Security/TemplateFileWritePermissionTest.php.
2. File names - checkExtension(), used by upload, new file, rename and ZIP extraction.
| Name | Allowed when |
|---|---|
Executable or server-config name (fileManagerIsExecutableName(): a server-script extension anywhere in the name - php*, phps, phtml, pht, phar, inc, cgi, pl, py, sh, asp, aspx, jsp - or exactly .htaccess, .htpasswd, .user.ini, .env, web.config) |
the user may run code (fileManagerMayRunCode()) and the extension is in the allowed lists |
| Any other name | the extension is in upload_files / upload_images / upload_media |
A Windows device name (CON, NUL, COM1.jpg ...; fileManagerIsReservedDeviceName()) is refused on every platform, in both file managers: on PHP before 8.3.35 / 8.4.26 / 8.5.11 (CVE-2026-17545) a filesystem call on one can hang the worker. The check also covers folder names and every path segment a request sends (fileManagerRefuseReservedName()), and runs before the first filesystem probe: in fileManagerResolvePath(), fileManagerPathContainsLink(), fileManagerIsSafeWriteTarget(), folder rename, and the media browser (checkInputDir(), refuseReservedRequestNames()).
The executable-name ban does not depend on those lists. textsave() applies it too: an existing
executable file cannot be edited without the code permission. Nor can it be removed or displaced:
whoever may not write code may not delete, rename or move it either. Deleting or renaming a file or
link of an executable name, or a folder holding one at any depth (a link the file managers pass is
a folder whose code counts; any other link counts by its own name and is not followed), needs the code permission in both file managers (fileManagerCodeRemovalDenied()
in the classic one, refuseCodeRemoval() in the media browser, both on fileManagerCodeEntry()).
The name check also catches the spellings Windows turns into such a file: trailing dots and spaces
(.htaccess.) and NTFS streams (.htaccess:x, shell.php::$DATA).
3. File groups - only when the "Use access permissions" setting (use_udperms, user/document permissions: access control via user groups and resource groups) is on; administrators (role 1) are exempt.
| Operation | Requirement |
|---|---|
| Browse or enter a path | membership in a group of every restricted ancestor folder |
| Modify or delete an existing file | the same, and the file is not at the top level |
| Rename, move or delete an existing entry (media browser too) | the top-level rule of the classic manager, measured from the manager's own filemanager_path (canModifyExisting() in browser.php) |
| Delete a link | removing a symlink or junction (a passable one through the folder or file delete, any other through deletelink) removes the link only and never forgets the groups stored under its name, which apply again if the link returns |
| Upload over an existing file | the file's own restrictions, not just the folder's (fileManagerCanModifyExistingPath()); the file keeps them and is not given the folder's groups |
| Copy / duplicate (both file managers) | access is an AND over the restricted folders on the way and the file itself, each an OR over its groups, which one set of groups on one key cannot say. FileManagerAccess::carriedRestrictions() keeps the groups that satisfy every set the destination does not already enforce; the copy is refused when there are none, and when the destination is the ACL root itself or lies outside it (no per-entry row can be stored there). Never store the union of the groups. A copy takes replaceRestrictions() and a rename or move takes moveRestrictions(), which first drops rows left at the free destination by files removed outside the CMS. Rows are matched by the exact stored path (exactRows()), and the file_groups.file column is case-sensitive (utf8mb4_bin on MySQL), because on a case-sensitive file system report.txt and REPORT.txt are two files |
| Any name the media browser writes (copy, move, upload, thumbnail) | the destination is free, a dangling symlink counts as taken (isTaken()), and copies are made exclusively with copyExclusive() (fopen mode x), never copy() through a link |
| Move (media browser) | the same carriedRestrictions(), read before the move and applied with replaceRestrictions() after it (a moved file brings its own, possibly wider, groups); refused when no set expresses it |
| ZIP extraction | the archive and the target folder, and each entry via fileManagerZipWriteGuard() |
| ZIP extraction, source archive | the archive itself must pass the folder rules of layer 1 too (not only its group access) |
| ZIP extraction, inherited groups | only files the archive created receive the folder's groups ($created of fileManagerExtractZip()) |
| Saving groups of a file | the groups section needs manage_groups or manage_document_permissions; without manage_groups a user adds or removes only groups they belong to, and may not touch a file that carries a group they are not in. Leaving it with no direct group at all is "make public" and needs manage_groups |
Always enforced
- Paths outside
filemanager_pathare rejected, and..never climbs above the root. - Links below a root are the operator's (the CMS never creates one), so a link to somewhere new is a folder (or file) like any other: a shared upload folder, a plugin checked out elsewhere, images linked in from another disk. Absolute or relative, whatever its spelling, it is judged by its real target (
fileManagerPassableLinkTarget()), and passed when that target exists and is new: not inside or around the ACL root, not around the folder the link sits in (a loop), not inside or around anything the walk has named already (where it started, the links it passed), not inside shared storage that a type-folder link names (fileManagerSharedTypeRoots()). Any of those would give a file a second name, and file groups are keyed by name, so a group onteamwould not holdpublic/link-to-team. A passed link is named by its own path, never resolved (fileManagerCanonicalPath(), which also puts each part into its stored letter case and accepts any spelling of the root: a hostingpublic_htmlchain or a release link above it), and every rule applies by that name: the folders of layer 1 (fileManagerProtectedPaths()holds each by its real path and by its name, so a protected folder may itself be a link), file groups, the executable-name rule, the type folder of the media browser. A protected folder is also matched by where a path lands (fileManagerPathSpellings(): the real path of its nearest existing ancestor, the rest appended), so a link to a view path outside the site does not open it under another name; removing a link resolves only the folder it sits in. Past a link,..is refused: it climbs in the link's target, not back where the server walks. Two separate links to one outside folder cannot be told apart from here; the operator should not make them, and a server withoutFollowSymLinksdoes not serve what the CMS then opens. Tests:SharedTypeRootRequestTest.php(a web request through the real browser constructor),SharedBrowserAccessTest.php. - A path that passes through any other link below the root is rejected as a whole (
fileManagerPathContainsLink()): in the classic manager, uploads, direct edit and delete, and the media browser (isInsideTypeDir()); a write never goes to a path that is itself a link, dangling or not (fileManagerIsSafeWriteTarget(), text saves and uploads over an existing name included): the checks see the link's name, the write would land in its target. Both file managers list such a link as an inert entry: no web URL, drill-down, thumbnail or selection (a link may lead anywhere, and the web server would serve its target unchecked). The one action is removing the link itself (mode=deletelink,act_deleteLink), judged by the link's own path, never resolved: the top-level and group rules of an existing entry, and no link where a protected folder belongs; the target and its groups stay. Its target is shown only as a path relative to the link, and only when it lies inside the manager's root (the media browser: the type folder) and the manager may reach it (fileManagerRelativeLinkTarget()); a../../or absolute target outside, or a restricted one, is shown as "outside the file manager or not accessible", never as a path. Links are never created: a link entry in a ZIP is not extracted (neither as a link nor as a file holding its path) but reported (fileManagerExtractZip()$links), a directory archive stores a link as a link entry with that same relative target or leaves it out, and a folder copy leaves it out, passable or not. The media browser serves no thumbnail whose cached path or original runs through a link it does not pass. Nor does it write, rename, copy, move or empty the thumbnail cache through one (isSafeThumbPath(), measured from the upload root, so a link at the cache root itself is refused too, before anything is probed or created). - Path comparison (
FileManagerAccess::isWithin()) ignores case on Windows, and ZIP entries are resolved to the stored letter case (fileManagerCanonicalCase()) before the checks. - The media browser (KCFinder) follows the same rules: it stays out of the folders of layer 1 (
isInProtectedFolder()), applies the executable-name rule to every name it writes (validateFilename(), including the final name of an upload), its temporary download archives carry thekcf-download-prefix and are the only.zipfiles it ever cleans up, and a folder rename never replaces an existing destination. - The media browser keys file groups by the name of its type folder below the ACL root, then the path below it by name (
getFileGroupsRelPath(),FileBrowserAccess::resolveAclPath()), so a type folder moved to shared storage (rb_base_diroutside the release,assets/imagesa link to it) keeps itsassets/images/...rows, the same keys the classic manager uses for it. The type folder is named by its real path below the ACL root, else by a passable link to it below the root, else by the site-side spelling of the configuredrb_base_url(_siteUploadURL) once realpath proves it is the same folder; never by KCFinder'stypeURL, which it resolves against the browser script. An unproven name inside the ACL root refuses every group-checked action and logs it, while a type folder the site serves from outside the ACL root (a narrowedfilemanager_path) carries no groups, as anything else outside it. In the classic manager the link that stands for a type folder (fileManagerSharedTypeRootAt()) is listed as the folder and entered, but never deleted, renamed or unlinked. - ZIP entries with absolute paths,
:or..segments are skipped.
Refusals are logged. Every refusal above is written to the event log (a warning from source File manager, with the manager as its user), where administrators read it: fileManagerLogDenied() / fileManagerDenied() in the classic manager and its ZIP extraction, logDenied() in the media browser. Log the rule that refused, not routine misses (an extension that is merely not on the allowed list is not logged; an executable name is). Values from the request are escaped, one line per refusal, at most 20 per request. A new refusal that only returns a message is incomplete.
When you add a file manager write path, apply layer 1 (fileManagerPathIsProtected()), the name rule
for any new or changed name, and layer 3 for any existing file it touches. Tests:
core/tests/Unit/Security/FileManagerWritePathChecksTest.php and FileManagerPathContainmentTest.php.
The Backup manager (manager/actions/bkmanager.static.php, action 93) needs bk_manager.
| Operation | Requirement |
|---|---|
| Download a dump, take a snapshot | bk_manager |
Restore (restore1 pasted/uploaded SQL, restore2 a snapshot, plain or .sql.gz) |
bk_manager and BackendCodePermissions::granted(), checked before any mode runs; the snapshot name must match [A-Za-z0-9_.-]+\.sql(\.gz)? and resolve inside snapshot_path |
Queued snapshot through the optional sTask worker (EvolutionCMS\Workers\DatabaseBackup) |
the same bk_manager. sTask itself checks nothing: its run endpoint and workers table create a task of any active worker for any manager, and the task runs later without a session. So the worker checks twice: createTask() asks the session, taskMake() asks BackgroundDatabaseBackup::userMayBackup() about the task's started_by (exists, not blocked, role holds bk_manager) |
| Scheduled snapshot (sTask scheduler, CLI) | runs as the manager stored in the worker setting backup_authorized_by. Only BackgroundDatabaseBackup::authorizeSchedule() (listener on eloquent.saving of sWorker) writes it: the manager who saves the worker, when the schedule is on and they hold bk_manager, otherwise 0; a posted value never counts, a save without a manager session keeps the stored one. A queued scheduled task (meta backup_scheduled) is refused once that authorisation no longer names its started_by |
There is no queued restore. Do not add one, nor another way to start a snapshot, without these checks.
Tests: core/tests/Unit/Security/BackgroundBackupPermissionTest.php, BackendCodePermissionsTest.php, BackupManagerShellTest.php.
- Laravel 13 components (illuminate/*) — container, ORM, routing, events, cache, queue
- Pest 5 — test framework
- Tracy 2 — debug/profiling bar
- evolutioncms-services/document-manager and user-manager — official service packages
- phpmailer/phpmailer 7 — mail sending
- guzzlehttp/guzzle 8 — HTTP client
- Do not edit files in
core/src/Legacy/unless fixing a specific legacy bug. This code is excluded from static analysis and is being gradually deprecated. - Do not commit to
3.5.xdirectly. Open a PR. - Do not modify
core/config/for site-specific settings — usecore/custom/config/instead. - Do not add files to
assets/cache/— it is auto-generated and git-ignored. - Do not use
$modx— it is deprecated. Useevo()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 analyzebefore submitting a PR.