Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,8 @@ A Windows device name (`CON`, `NUL`, `COM1.jpg` ...; `fileManagerIsReservedDevic
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()`
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`).
Expand All @@ -432,7 +432,7 @@ The name check also catches the spellings Windows turns into such a file: traili
| 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) |
| 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 |
Expand All @@ -444,11 +444,13 @@ The name check also catches the spellings Windows turns into such a file: traili

**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).
- Paths outside `filemanager_path` are 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 on `team` would not hold `public/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 hosting `public_html` chain 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 without `FollowSymLinks` does 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 the `kcf-download-` prefix and are the only `.zip` files 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_dir` outside the release, `assets/images` a link to it) keeps its `assets/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 configured `rb_base_url` (`_siteUploadURL`) once realpath proves it is the same folder; never by KCFinder's `typeURL`, 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 narrowed `filemanager_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.
Expand Down
Loading
Loading