Skip to content

fix(file-manager): shared storage and operator links in both file managers - #2548

Merged
Seiger merged 4 commits into
evolution-cms:3.6.xfrom
elcreator:fix-group-permissions-for-shared-storage
Nov 3, 2026
Merged

Seiger merged 4 commits into
evolution-cms:3.6.xfrom
elcreator:fix-group-permissions-for-shared-storage

Conversation

@elcreator

Copy link
Copy Markdown

Follow-up to #2546, which broke the media browser for restricted managers on shared storage, plus the file-manager link model that the shared-storage case needed.

Why images broke after #2546

#2546 mapped a shared type folder (rb_base_dir outside the release, assets/images a symlink to it) back to its old assets/images/... file-group keys, and refused everything when it could not prove the mapping. It took the site-side spelling from KCFinder's typeURL, which KCFinder builds by resolving a relative rb_base_url against the browser script's working directory and DOCUMENT_ROOT:

  • plain document root: assets/ becomes /manager/media/browser/mcpuk/assets/images;
  • symlinked document root (Deployer current, hosting public_html): path::rel2abs_url() returns false, so typeURL is /images.

The mapping therefore never matched, and every group check refused: managers who are not administrators, with "Use access permissions" on, got an empty, read-only browser. Administrators were not affected. The #2546 tests set typeURL to the correct value by hand, so they never saw it.

Changes

  1. Map shared roots from the configured URL. config.php passes the configured rb_base_url as _siteUploadURL (the leading _ keeps it out of the session override), and getFileGroupsRelPath() uses it instead of typeURL. A type folder that the site serves from outside a narrowed filemanager_path carries no file groups again (no row can name it), instead of refusing every action.
  2. Operator links in both file managers. The CMS never creates links (ZIP link entries are still not extracted), so a link below a root is the operator's: a shared upload folder, a plugin checked out from a separate repository, images from another disk. A link whose real target exists and is new is now a folder (or file) like any other, named by its own path, absolute or relative. Every rule applies by that name: protected folders, file groups, the executable-name rule, the media browser type folder.
    • Still inert: dangling links, loops, and links that would give a file a second name (into or around the ACL root, into a shared type folder, into anything already passed on the same path). File groups are keyed by name, so a second name would bypass them.
    • .. past a link is refused: it climbs in the link's target, not back where the server walks.
    • Removing a passable link removes the link only and keeps its groups (in both managers).
    • Code under a passable link counts as code for the "may not remove code" rule.
    • Protected folders are matched by their real path and by their name, so a protected folder may itself be a link.
    • Keys keep the stored letter case on Windows (fileManagerStoredName()).
  3. Both managers give the same answer for a shared type folder. The classic manager now enters assets/images when it links to shared storage, with the browser's keys, so groups there can be edited again. The link that stands for a type folder is listed as a folder and is never deleted, renamed or unlinked.
  4. Tests drive the real start-up path. SharedTypeRootRequestTest runs the real browser constructor and config.php with $_SERVER and the working directory of browse.php, through a public_html -> evolution -> evo-3.6.2 chain, for three layouts (canonical root, linked root, rb_base_dir in shared storage). The older link tests now use links that stay inert whatever the environment (loops, second names); the positive cases run in separate processes, because which links pass depends on the site settings.
  5. List/grid toggle colours (3.6 only). Since the icons became masks, background-color paints the glyph and the mask clips the border: the inactive view toggle was white on white, and a selected toolbar button (Settings) showed a blue icon on blue. The toggle button is now drawn on the label and the glyphs take currentColor.

The file-manager section of AGENTS.md describes the link model.

Known limits

  • Two separate links to one outside folder cannot be told apart cheaply, so the operator should not make them (documented).
  • A server without FollowSymLinks does not serve a linked folder that the CMS now opens to managers; that is the operator's choice.

Verification

  • tests/Unit tests/Feature on PHP 8.4 (Linux): all green except PhpThumbCacheImagesTest, which reads ng.inx and fails only in a git archive copy (the file is export-ignore).
  • Link tests also run in isolation, without a CMS in the process.
  • PHPStan: no errors. JS tests of the media browser: 86 passed.
  • Docker, Deployer-style layout (current -> releases/1, assets/images -> ../../../shared/assets/images, rb_base_dir=/data/www/shared/assets/): a restricted manager lists, uploads to and creates folders in news and does not see the HR-only hr folder; the dashboard shows no path warning.

🤖 Generated with Claude Code

elcreator and others added 3 commits November 3, 2026 03:04
The shared-root mapping read KCFinder's typeURL, which resolves a relative
rb_base_url against the browser script (or gives up when DOCUMENT_ROOT is a
symlink), so it never named the folder: restricted managers got an empty,
read-only browser on shared storage. Use the configured URL instead.

A type root the site serves from outside a narrowed filemanager_path carries
no file groups again instead of refusing every action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since the icons became masks, background-color paints the glyph and the mask
clips the border: the inactive toggle was white on white, and a selected
toolbar button (Settings) showed a blue icon on blue. Draw the toggle button
on the label and colour the glyphs through currentColor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…managers

Links below a file manager root are made by the operator, never by the CMS:
a shared upload folder, a plugin checked out elsewhere, images from another
disk. A link whose real target exists and is new (not inside or around the
ACL root, not a loop, not inside anything the walk already named or a shared
type folder) is now a folder or file like any other, named by its own path,
with every rule applied by that name: protected folders, file groups, the
executable-name rule and the media browser type folder. Links that dangle,
loop or would give a file a second name stay inert entries, and `..` past a
link is refused.

Both managers key a shared type folder the same way, so the classic manager
can now enter it and edit its groups; the link that stands for a type folder
is never deleted, renamed or unlinked. Removing any other passable link
removes the link only and keeps its groups.

A new fixture builds the media browser the way a web request does ($_SERVER,
the working directory of browse.php, config.php), through a public_html ->
evolution -> release chain, which is how the typeURL bug slipped through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MiddleSokilAI

Copy link
Copy Markdown
Collaborator

Thanks for preserving support for operator-managed shared folders. Two security boundaries still need attention before merging. Reviewed statically at 5a9b58d; I have not run this PR.

  1. The text editor still writes through a final symbolic link. In core/functions/actions/files.php:2420, textsave() uses fileManagerPathContainsLink() and fileManagerCanonicalPath(), which now accept passable links and preserve their names, then reaches file_put_contents() at :2445. It never calls fileManagerIsSafeWriteTarget(), whose final-component check at :912 explicitly rejects this case. With an existing passable file link, write permissions and the executable-name restriction are checked against the link's name while the write follows its target. A manager user allowed to edit files but not run code can consequently modify a target that those checks were intended to protect. This requires an existing operator/filesystem-provided link; the manager need not create one.

Please apply the safe-write-target guard to text saves before opening the file, reject final-component links consistently with other write operations, and retain the existing ACL and code-permission checks. Add a regression that invokes the actual text-save path as a user without code privileges against an existing final file link and verifies denial and unchanged target contents; also cover an ordinary allowed text file and a permitted parent-directory link.

  1. Protection of external view.paths is checked against the lexical name rather than the resolved target. fileManagerProtectedPaths() (:247) records the configured view directories and their real paths, but fileManagerPathIsProtected() (:494) and fileManagerPathTouchesProtected() (:514) only compare those against the incoming path spelling. fileManagerCanonicalPath() deliberately retains a passable link's name. When an accessible linked directory resolves into an external configured view directory, the lexical name can therefore miss the protected real directory. Such files are executable by their template engine regardless of extension, so users without code privileges must not be allowed to write there through an alias.

Please keep the lexical identity for file-group ACLs while also checking the resolved filesystem target against protected real paths for every relevant read/change guard. For new write destinations, resolve and validate the nearest existing parent and the appended destination; retain the intended allowance for unrelated shared media directories. Add regression coverage for an external configured view directory reached through a passable directory link: text-save, create/upload, rename/move and delete must respect the protection for a user without code privileges, including non-PHP template extensions. Verify that an unrelated shared media directory remains usable.

SokilAI

…ough a linked file

A passed link keeps its own name, which file groups are keyed by, but the
protected-folder rule compared only that name. A link to a folder protected
outside the ACL root (an external view path; core/, manager/ or views/ under
a narrowed filemanager_path) opened it to managers without the permission.
fileManagerPathIsProtected() and fileManagerPathTouchesProtected() now also
match where a path lands: the real path of its nearest existing ancestor with
the rest appended, so new destinations are covered too. Removing a link
resolves only the folder it sits in.

textsave() checked a linked file by its name while file_put_contents()
followed the link, so a manager without code permissions could write PHP into
the target through a text name. It now refuses a final link through
fileManagerIsSafeWriteTarget(), as the classic upload over an existing name
does now too (a move across file systems copies, and the copy follows a link).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@elcreator

Copy link
Copy Markdown
Author

Thanks, both are confirmed and fixed in 022b864. Both are regressions of this PR: before it, no link was ever passed, so neither alias existed.

1. Text save through a final link. textsave() checked the link's name, and file_put_contents() followed the link. It now calls fileManagerIsSafeWriteTarget() before the other checks, so a final link is refused and logged. Folder links on the way still pass. The ACL, protected-folder and code-permission checks are unchanged.

The classic upload over an existing name had the same gap: move_uploaded_file() falls back to a copy across file systems, and the copy follows the link. It now uses the same guard. That one predates the PR, because fileupload() never called the guard.

2. Protected folders reached through a link. This applies to every protected folder whose real path is outside the ACL root: an external view.paths entry, and, with a narrowed filemanager_path, also core/, manager/ and views/.

  • fileManagerPathIsProtected() and fileManagerPathTouchesProtected() now match both the name and where the path lands (fileManagerPathSpellings(): the realpath of the nearest existing ancestor, with the rest of the path appended). New destinations are covered too.
  • Every guard in both managers goes through these two functions, so this closes all of them: text save, delete, new file or folder, upload, rename or move, ZIP extraction, folder copy, and the media browser's isInsideTypeDir().
  • File-group keys still use the link's own name.
  • Removing a link (deletelink, act_deleteLink) resolves only the folder the link sits in, so it is still judged by where the link is, not where it leads.

Tests. SharedTypeRootRequestTest runs the real textsave() and delete_file() as a manager without code permissions, in three layouts: canonical root; a public_html -> evolution -> release chain; rb_base_dir in shared storage.

  • assets/files/note.txt links to a PHP file outside the site: the save is refused and the target is unchanged. An ordinary text file saves, and so does a file under a passable folder link.
  • assets/files/tpl (classic manager) and views-alias (media browser) link to an external view path:
    • saving and deleting page.tpl are refused, and the file is unchanged;
    • creating new.tpl or sub/new.html, renaming, and moving into it are all refused (create, rename and move are checked at the guard level, not through a full request);
    • shared media stays writable.
  • Without the fix, the fixture deletes views/page.tpl through the alias.

Also checked over HTTP on a Deployer-style container (assets/files linked to shared storage, an extra view.paths entry via core/custom/config/view/paths.php), as a manager with file_manager and no code permission:

Request Before After
save assets/files/note.txt (link to /data/www/notes/run.php) written: <?php evil(); Access denied
save assets/files/tpl/page.tpl (external view path) written refused
delete assets/files/tpl/page.tpl deleted refused
save assets/files/plain.txt saved saved

Full tests/Unit tests/Feature suite on PHP 8.4: all green except PhpThumbCacheImagesTest, which reads ng.inx, a file that is missing from my export copy. PHPStan: no errors. AGENTS.md now covers both rules.

@Seiger
Seiger merged commit fb8d1df into evolution-cms:3.6.x Nov 3, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants