Evolution CMS is a community-maintained fork of MODX Evolution, rebuilt on Laravel 12 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 artisan boost:add-skill evolution-cms/evolution
Save this content to: AGENTS.md
# Evolution CMS — Agent Guide
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
---
## Repository Layout
```
/ ← 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
```
---
## Commit Message Convention
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 capability
- `feat` — net-new user-facing feature or capability where feature wording is clearest
- `fix` — bug fix or defect repair
- `upd` — update to existing behavior, content, generated output, or workflow
- `ref` — refactor or restructure without changing intended product meaning
- `del` — 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.
---
## Branching
- **`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
---
## Running the Test Suite
Tests use [Pest](https://pestphp.com/) and live in `core/tests/`.
```bash
# From the core/ directory:
composer test
# Or directly:
cd core && vendor/bin/pest
```
PHPUnit sources cover: `factory/`, `functions/`, `includes/`, `modifiers/`, `src/`.
The SQLite test database is `core/database/evo-test.sqlite`.
---
## Static Analysis
```bash
# 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)
```
---
## Artisan CLI
The CMS uses a subset of Laravel's Artisan. The entry point is `core/artisan`.
```bash
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 TVs
```
When 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, else
`core/custom/composer.installed.json`, written by every restore and by `package:installrequire`),
pinned by `pins()`; a package the release ships or the core requires keeps the release's version.
- Arguments: `updateArguments()`, `--with-dependencies` (`--minimal-changes` from Composer 2.7), never
`--with-all-dependencies`, which moves the core's own packages too.
- `plan()` dry run first (`planInstall()`, a `composer 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:site`
runs it against the unpacked archive before `moveFiles()` (`preflightComposer()`), so a refusal changes
nothing. The staged `custom/composer.json` is rebased (`rebasedCustomComposerJson()`: the merge plugin
prefixes `custom/` to path repositories and to `extra.merge-plugin.include` / `require`, a list or a
single path) and removed again before `moveFiles()`, 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).
### Installing Evolution CMS packages
Install Composer packages from the `core/` directory through the Evolution CMS package installer:
```bash
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:
```bash
php artisan vendor:publish --provider="Vendor\\Package\\PackageServiceProvider"
php artisan migrate
```
---
## Customisation Points (core/custom/)
`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`.
---
## Key Namespaces and Extension Points
| 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 |
### Important Models
- `SiteContent` — resources/documents (the page tree)
- `SiteTemplate` — templates
- `SitePlugin` — plugins (event-driven PHP)
- `SiteSnippet` — snippets (callable PHP fragments)
- `SiteHtmlsnippet` — chunks (reusable HTML)
- `SiteTmplvar` / `SiteTmplvarContentvalues` — template variables (TVs)
- `User` / `UserAttribute` — manager users
- `Category` — categorisation for all element types
### Service Providers
There are ~30 service providers in `core/src/Providers/`. Notable ones:
- `RoutingServiceProvider` — registers the CMS front-end parser as a route fallback
- `TemplateProcessorServiceProvider` — registers the template tag parser
- `ManagerThemeServiceProvider` — manager UI
- `TracyServiceProvider` — Tracy debug bar integration
- `ModifiersServiceProvider` — output modifiers
---
## CMS Concepts for Agents
- **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
---
### Blade Directives
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` |
### Escaping rules for manager views
`{!! !!}` 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).
### CSRF rules for manager actions
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::$actions` with no file under `manager/processors/`. Auditing only the processor
directory misses them — that is how `a=90` (`DeleteUser`, deletes a user straight from
`$_GET`), `a=92`, `a=52` and `a=26` were initially left unguarded.
- **`$_REQUEST` means GET works.** A processor whose UI only ever posts is still reachable by
query string if it reads `$_REQUEST`, so it belongs in `MUTATING_GET_ACTIONS` too.
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.
---
### File manager access rules
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 not `assets/templates`: that folder holds parsed front-end HTML and stays on `save_template`).
Test: `TemplateFileWritePermissionTest.php`.
- `manager/processors/save_template.processor.php`, when the form posts `templatesource=file` or asks
for a file to be created. It calls `EvolutionCMS\Support\TemplateFileGuard` before touching the
filesystem; a `save_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 (links count by their own name and
are 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 never forgets the groups stored under its target's key (the key of a link is its target's) |
| 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_path` are rejected; `..`, symlinks and Windows junctions are not followed.
- A path that passes through a symlink or junction below the root is rejected as a whole (`fileManagerPathContainsLink()`): in the classic manager, uploads, direct edit and delete, and the media browser (`isInsideTypeDir()`). Both file managers list 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. The media browser serves no thumbnail whose cached path or original runs through one. 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 the `kcf-download-` prefix and are the only `.zip` files it ever cleans up, and a folder rename never replaces an existing destination.
- 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`.
### Database backup and restore rules
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`.
---
## Dependencies of Note
- **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
---
## What to Avoid
- **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.x` directly.** Open a PR.
- **Do not modify `core/config/`** for site-specific settings — use `core/custom/config/` instead.
- **Do not add files to `assets/cache/`** — it is auto-generated and git-ignored.
- **Do not use `$modx`** — it is deprecated. Use `evo()` 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 analyze` before submitting a PR.
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 flowsExamples:
add(ex42): add feed source worker (#69)ref(source): split API source transport (#69)fix(ex42): repair source run diagnosticsUse 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 branchfix-rich-text-selection-when-editor-disabled3.5.x; always open a PRTests use Pest and live in core/tests/.
# From the core/ directory:
composer test
# Or directly:
cd core && vendor/bin/pest
PHPUnit 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 TVs
When 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:
previousVersions(): the lock while it still lists them, else
core/custom/composer.installed.json, written by every restore and by package:installrequire),
pinned by pins(); a package the release ships or the core requires keeps the release's version.updateArguments(), --with-dependencies (--minimal-changes from Composer 2.7), never
--with-all-dependencies, which moves the core's own packages too.plan() dry run first (planInstall(), a composer 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:site
runs it against the unpacked archive before moveFiles() (preflightComposer()), so a refusal changes
nothing. The staged custom/composer.json is rebased (rebasedCustomComposerJson(): the merge plugin
prefixes custom/ to path repositories and to extra.merge-plugin.include / require, a list or a
single path) and removed again before moveFiles(), which
would otherwise lay it over the site's own."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()).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 migrate
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 typesThere 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{{ChunkName}}[[SnippetName? ¶m=value-one; ¶m2=value-two]][*tvName*][*field*:modifier]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:
ManagerTheme::$actions with no file under manager/processors/. Auditing only the processor
directory misses them — that is how a=90 (DeleteUser, deletes a user straight from
$_GET), a=92, a=52 and a=26 were initially left unguarded.$_REQUEST means GET works. A processor whose UI only ever posts is still reachable by
query string if it reads $_REQUEST, so it belongs in MUTATING_GET_ACTIONS too.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:
fileManagerTemplateViewPaths(); a view
path is not assets/templates: that folder holds parsed front-end HTML and stays on save_template).
Test: TemplateFileWritePermissionTest.php.manager/processors/save_template.processor.php, when the form posts templatesource=file or asks
for a file to be created. It calls EvolutionCMS\Support\TemplateFileGuard before touching the
filesystem; a save_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 (links count by their own name and
are 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 never forgets the groups stored under its target's key (the key of a link is its target's) |
| 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
filemanager_path are rejected; .., symlinks and Windows junctions are not followed.fileManagerPathContainsLink()): in the classic manager, uploads, direct edit and delete, and the media browser (isInsideTypeDir()). Both file managers list 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. The media browser serves no thumbnail whose cached path or original runs through one. 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).FileManagerAccess::isWithin()) ignores case on Windows, and ZIP entries are
resolved to the stored letter case (fileManagerCanonicalCase()) before the checks.isInProtectedFolder()), applies the executable-name rule to every name it writes (validateFilename(), including the final name of an upload), its temporary download archives carry the kcf-download- prefix and are the only .zip files it ever cleans up, and a folder rename never replaces an existing destination.: 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.
core/src/Legacy/ unless fixing a specific legacy bug. This code is excluded from static analysis and is being gradually deprecated.3.5.x directly. Open a PR.core/config/ for site-specific settings — use core/custom/config/ instead.assets/cache/ — it is auto-generated and git-ignored.$modx — it is deprecated. Use evo() helper.VerifyCsrfToken::MUTATING_GET_ACTIONS.composer analyze before submitting a PR.How can I help you explore Laravel packages today?