mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
5fb17388cd
Reported by @Drescargot as GHSA-r3hg-3fxw-rcmr, in two halves. The groups listing never narrowed at all. Every other action in that controller is guarded with allowsGroupChange(), and index() — web and API alike — built a bare Group::query(), so a client-scoped staff member was shown every group on the installation with its name, description and member count. StaffLibraryScope::groups() is that narrowing, and assignableGroupIds() now reads from it rather than restating the same rule a second time, which is how the two drifted apart to begin with. The second half is the one that mattered. allowsGroupChange() asked only groupReachesNoFurther() — "is anything shared with this group outside my library" — which a group with nothing shared with it yet passes vacuously. So a scoped staff member could rename, delete or publish a group whose every member was somebody else's client. Publishing is the sharp end: whatever is shared with the group afterwards is reachable without signing in. The reporter suggested putting the membership check inside groupReachesNoFurther(). Tried, and it breaks two things. That predicate is shared with allowsGroupMembership(), where a group nobody has joined must stay usable so its creator can add the first member. And "every member must be mine" is the obvious reading of the rule and is wrong: it turns GHSA-whmp-p9hv-r7j7's narrowing — a mixed group's edit screen loads and simply does not name the stranger — back into a 404, undoing that fix. Four tests from it fail that way. So the check sits in allowsGroupChange() alone, and asks whether the group is wholly somebody else's rather than whether it is wholly theirs. A mixed group stays workable and is still covered by the reach check; an empty one stays nameable by whoever just made it; a group with members and none of them theirs is refused.
424 lines
16 KiB
PHP
424 lines
16 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Modules\Files\Access;
|
||
|
||
use App\Models\User;
|
||
use App\Modules\Files\Models\File;
|
||
use App\Modules\Files\Models\FileAssignment;
|
||
use App\Modules\Files\Models\Folder;
|
||
use App\Modules\Files\Models\FolderAssignment;
|
||
use App\Modules\Groups\Models\Group;
|
||
use App\Modules\Identity\UserType;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
|
||
/**
|
||
* The single point that decides which library content a staff member
|
||
* sees. An unscoped staff member sees the whole shared library; a
|
||
* client-scoped one (see User::isClientScoped) sees only the files &
|
||
* folders they created, plus everything belonging to the clients
|
||
* assigned to them.
|
||
*
|
||
* Every staff listing goes through here, and the policies consult
|
||
* allowsFile()/allowsFolder() so direct access (download, details,
|
||
* edit…) respects the same boundary.
|
||
*
|
||
* @method Builder<File> files(User $user)
|
||
* @method Builder<Folder> folders(User $user)
|
||
*/
|
||
class StaffLibraryScope
|
||
{
|
||
/**
|
||
* Built queries, by user id. Building one is not free: it walks the
|
||
* assigned clients and File::scopeVisibleToClient runs four immediate
|
||
* lookups for each of them, none of which depend on the query being
|
||
* built. Callers ask over and over — the policies ask once per row on
|
||
* a listing, and Gate resolves a fresh policy for every check — so the
|
||
* same handful of lookups were being repeated per row.
|
||
*
|
||
* A clone goes back rather than the query itself, since every caller
|
||
* adds to it. Registered with the container as `scoped`, so the memo
|
||
* lasts a request and is dropped between queue jobs.
|
||
*
|
||
* @var array<int, Builder<File>>
|
||
*/
|
||
private array $files = [];
|
||
|
||
/** @var array<int, Builder<Folder>> */
|
||
private array $folders = [];
|
||
|
||
/**
|
||
* @return Builder<File>
|
||
*/
|
||
public function files(User $user): Builder
|
||
{
|
||
return clone ($this->files[$user->id] ??= $this->buildFiles($user));
|
||
}
|
||
|
||
/**
|
||
* @return Builder<File>
|
||
*/
|
||
private function buildFiles(User $user): Builder
|
||
{
|
||
$query = File::query();
|
||
|
||
if (! $user->isClientScoped()) {
|
||
return $query;
|
||
}
|
||
|
||
// Own uploads ∪ files visible to each assigned client. The
|
||
// per-client visibility is File::scopeVisibleToClient — the single
|
||
// source of truth for client file access — so no rule is duplicated.
|
||
return $query->where(function (Builder $outer) use ($user): void {
|
||
$outer->where('uploaded_by', $user->id);
|
||
|
||
foreach ($user->assignedClients as $client) {
|
||
$outer->orWhere(fn (Builder $scoped) => $scoped->visibleToClient($client));
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @return Builder<Folder>
|
||
*/
|
||
public function folders(User $user): Builder
|
||
{
|
||
return clone ($this->folders[$user->id] ??= $this->buildFolders($user));
|
||
}
|
||
|
||
/**
|
||
* @return Builder<Folder>
|
||
*/
|
||
private function buildFolders(User $user): Builder
|
||
{
|
||
$query = Folder::query();
|
||
|
||
if (! $user->isClientScoped()) {
|
||
return $query;
|
||
}
|
||
|
||
return $query->where(function (Builder $outer) use ($user): void {
|
||
$outer->where('created_by', $user->id);
|
||
|
||
foreach ($user->assignedClients as $client) {
|
||
$outer->orWhere(fn (Builder $scoped) => $scoped->visibleToClient($client));
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Whether a scoped staff member may reach this specific file. Unscoped
|
||
* staff always may; the policies AND this into their permission checks
|
||
* so direct access respects the same boundary as the listings.
|
||
*/
|
||
public function allowsFile(User $user, File $file): bool
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return true;
|
||
}
|
||
|
||
return $this->files($user)->whereKey($file->getKey())->exists();
|
||
}
|
||
|
||
public function allowsFolder(User $user, Folder $folder): bool
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return true;
|
||
}
|
||
|
||
return $this->folders($user)->whereKey($folder->getKey())->exists();
|
||
}
|
||
|
||
/**
|
||
* Client ids a user may share with, or null when unrestricted (the
|
||
* whole roster). A scoped user may only share with their assigned
|
||
* clients.
|
||
*
|
||
* @return list<int>|null
|
||
*/
|
||
public function assignableClientIds(User $user): ?array
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return null;
|
||
}
|
||
|
||
return array_values($user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all());
|
||
}
|
||
|
||
/**
|
||
* Group ids a user may share with, or null when unrestricted. A scoped
|
||
* user may share with any group that contains at least one of their
|
||
* assigned clients.
|
||
*
|
||
* @return list<int>|null
|
||
*/
|
||
public function assignableGroupIds(User $user): ?array
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return null;
|
||
}
|
||
|
||
return array_values($this->groups($user)->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||
}
|
||
|
||
/**
|
||
* Every group this staff member may be told about, as a query.
|
||
*
|
||
* The listing half of assignableGroupIds(), and the same rule: a
|
||
* group counts as theirs because one of their clients is in it. The
|
||
* two were not the same code, and the listing simply had none — so
|
||
* `/groups` and `/api/v1/groups` showed a scoped staff member every
|
||
* group on the installation, name, description and member count,
|
||
* including groups whose every member was somebody else's client
|
||
* (GHSA-r3hg-3fxw-rcmr).
|
||
*
|
||
* Deliberately the *sharing* rule rather than the change rule below.
|
||
* A scoped staff member may already share a file with a mixed group,
|
||
* so its existence is not news to them; what they may not do is
|
||
* rename, publish or delete it.
|
||
*
|
||
* @return Builder<Group>
|
||
*/
|
||
public function groups(User $user): Builder
|
||
{
|
||
$query = Group::query();
|
||
$clientIds = $this->assignableClientIds($user);
|
||
|
||
if ($clientIds === null) {
|
||
return $query;
|
||
}
|
||
|
||
return $query->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds));
|
||
}
|
||
|
||
public function canAssignClient(User $user, User $client): bool
|
||
{
|
||
$ids = $this->assignableClientIds($user);
|
||
|
||
return $ids === null || in_array($client->id, $ids, true);
|
||
}
|
||
|
||
/**
|
||
* Every client this staff member may act on, as a query.
|
||
*
|
||
* The listing half of canAssignClient(), so a screen narrows by the
|
||
* same rule its buttons are guarded with rather than restating it —
|
||
* which is how ClientsController came to list every client on the
|
||
* installation, name and email, to a viewer who could reach nothing
|
||
* of theirs. An unscoped user gets the whole roster, unchanged.
|
||
*
|
||
* @return Builder<User>
|
||
*/
|
||
public function clients(User $user): Builder
|
||
{
|
||
$query = User::query()->where('type', UserType::Client);
|
||
$ids = $this->assignableClientIds($user);
|
||
|
||
return $ids === null ? $query : $query->whereIn('id', $ids);
|
||
}
|
||
|
||
public function canAssignGroup(User $user, Group $group): bool
|
||
{
|
||
$ids = $this->assignableGroupIds($user);
|
||
|
||
return $ids === null || in_array($group->id, $ids, true);
|
||
}
|
||
|
||
/**
|
||
* Whether a staff member may put a client into a group, or take one
|
||
* out again.
|
||
*
|
||
* Not canAssignGroup(): that answers "may I share with this group",
|
||
* and it answers it *from* the membership — a group counts as the
|
||
* user's because one of their clients is in it. Deciding membership
|
||
* with a predicate derived from membership means whoever may edit
|
||
* the list also decides what the list entitles them to, which is not
|
||
* a boundary at all. It is also the wrong answer here in the other
|
||
* direction: a group nobody has joined yet belongs to nobody, so a
|
||
* scoped staff member could never put the first member into a group
|
||
* they had just created.
|
||
*
|
||
* The question membership actually asks is about reach. Joining a
|
||
* group hands the new member everything shared with it, and — when
|
||
* that member is one of the actor's own clients — hands the actor
|
||
* the same content back through File::scopeVisibleToClient, which is
|
||
* what StaffLibraryScope::files() is built on. So both sides have to
|
||
* hold: the client must be one this staff member holds, and the
|
||
* group must not already reach beyond their library. A group with
|
||
* nothing shared with it passes trivially, which is what keeps a
|
||
* newly created one usable.
|
||
*
|
||
* Unscoped staff are unaffected — both halves are true for them by
|
||
* construction.
|
||
*/
|
||
public function allowsGroupMembership(User $user, Group $group, User $client): bool
|
||
{
|
||
return $this->canAssignClient($user, $client) && $this->groupReachesNoFurther($user, $group);
|
||
}
|
||
|
||
/**
|
||
* Whether this staff member may change a group itself — rename it,
|
||
* make it public, delete it.
|
||
*
|
||
* The reach half of allowsGroupMembership, on its own because there
|
||
* is no client in the question. Deleting a group is the destructive
|
||
* end of it: an assignment to a group is how its members reach a
|
||
* file, so removing the group takes that access away from every one
|
||
* of them. A staff member who may not add somebody to a group out of
|
||
* their reach should not be able to delete it out from under the
|
||
* people already in it.
|
||
*/
|
||
public function allowsGroupChange(User $user, Group $group): bool
|
||
{
|
||
return $this->groupIsNotWhollySomebodyElses($user, $group)
|
||
&& $this->groupReachesNoFurther($user, $group);
|
||
}
|
||
|
||
/**
|
||
* Whether this group is somebody else's entirely — every member
|
||
* outside the staff member's roster, and none of theirs in it.
|
||
*
|
||
* The half allowsGroupChange() was missing. Reach answers "what would
|
||
* this group hand somebody", which is the right question for putting a
|
||
* client *into* it; it says nothing about who is already there. So a
|
||
* group with nothing shared with it yet passed the reach check
|
||
* vacuously, and a scoped staff member could rename it, delete it, or
|
||
* publish it — a group made entirely of clients they had never been
|
||
* assigned (GHSA-r3hg-3fxw-rcmr).
|
||
*
|
||
* **Not "every member is mine", which is the obvious reading and is
|
||
* wrong.** A mixed group has to stay changeable: GHSA-whmp-p9hv-r7j7
|
||
* settled that a scoped staff member opens such a group's edit screen
|
||
* and is shown only their own clients in it, rather than being refused
|
||
* the screen. Requiring every member to be theirs turns that narrowing
|
||
* back into a 404 and undoes the earlier fix. What is left over — a
|
||
* mixed group whose shared content reaches past their library — is
|
||
* refused by groupReachesNoFurther() beside this, which is the check
|
||
* that has always covered it.
|
||
*
|
||
* **And deliberately not folded into groupReachesNoFurther() either.**
|
||
* That predicate is shared with allowsGroupMembership(), where a group
|
||
* nobody has joined must stay usable so its creator can put the first
|
||
* member in — the case that method's own docblock calls out.
|
||
*
|
||
* An empty group is nobody else's, so whoever just made it can still
|
||
* name it.
|
||
*/
|
||
private function groupIsNotWhollySomebodyElses(User $user, Group $group): bool
|
||
{
|
||
$clientIds = $this->assignableClientIds($user);
|
||
|
||
if ($clientIds === null) {
|
||
return true;
|
||
}
|
||
|
||
if (! $group->members()->exists()) {
|
||
return true;
|
||
}
|
||
|
||
return $group->members()->whereIn('users.id', $clientIds)->exists();
|
||
}
|
||
|
||
/**
|
||
* Whether everything shared with this group is already inside the
|
||
* user's library — files assigned to it, and the folders whose
|
||
* subtrees it can browse.
|
||
*
|
||
* Asked as "is anything shared with this group outside my library",
|
||
* rather than by counting assignment rows against library rows. An
|
||
* assignment outlives the thing it points at: nothing clears these
|
||
* rows when a file or folder is deleted, and a deleted one can never
|
||
* appear in files()/folders(), which exclude trashed rows. Counting
|
||
* therefore never balanced again, and the group became permanently
|
||
* unmanageable for a scoped staff member — including for their own
|
||
* clients, and including removing somebody. Starting from the live
|
||
* row rather than from the assignment ignores the dead ones by
|
||
* construction, which is also the right answer: a deleted file is
|
||
* not reach, because nobody can reach it.
|
||
*
|
||
* An expired file is the same answer for the same reason. Membership
|
||
* in this group grants nobody access to it — File::scopeVisibleToClient
|
||
* ends in notExpired(), so it is gone from every member's /my-files and
|
||
* the download is refused — while its absence from files() otherwise
|
||
* reads as "outside my library" and locks the group exactly as a
|
||
* deleted file used to. Expiry is reversible where deletion is not, so
|
||
* the file counts as reach again the moment it does: this asks what is
|
||
* reachable now, at the moment somebody is added or removed.
|
||
*/
|
||
private function groupReachesNoFurther(User $user, Group $group): bool
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return true;
|
||
}
|
||
|
||
$morph = $group->getMorphClass();
|
||
|
||
$assignedFiles = FileAssignment::query()->select('file_id')
|
||
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
||
|
||
$outside = File::query()
|
||
->whereIn('id', $assignedFiles)
|
||
->notExpired()
|
||
->whereNotIn('id', $this->files($user)->select('id'))
|
||
->exists();
|
||
|
||
if ($outside) {
|
||
return false;
|
||
}
|
||
|
||
$assignedFolders = FolderAssignment::query()->select('folder_id')
|
||
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
||
|
||
// The whole subtree, not the folder the assignment names. A folder
|
||
// shared with a group hands its members everything inside it —
|
||
// File::scopeVisibleToClient matches on folder placement, and a
|
||
// folder is visible to a client when it or an ancestor is shared
|
||
// with them — so "is anything shared with this group outside my
|
||
// library" has to ask about the contents, which is what the
|
||
// docblock above already claims ("the folders whose subtrees it
|
||
// can browse").
|
||
//
|
||
// Measured: a scoped staff member's own folder, with a subfolder
|
||
// somebody else created inside it and somebody else's file in
|
||
// that. The folder is theirs, its contents are not, and adding
|
||
// their own client to a group holding the parent handed that
|
||
// client the file — which then enters the staff member's own
|
||
// library too, because files() is "everything my clients can
|
||
// see". That is the widening this guard exists to refuse, and the
|
||
// test above it says so in as many words.
|
||
$reachable = Folder::query()->whereIn('id', $assignedFolders)->get()
|
||
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
|
||
if ($reachable === []) {
|
||
return true;
|
||
}
|
||
|
||
if (Folder::query()
|
||
->whereIn('id', $reachable)
|
||
->whereNotIn('id', $this->folders($user)->select('id'))
|
||
->exists()
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
// And the files sitting in them. A folder can be inside the
|
||
// library while a file in it is not: files() is own uploads plus
|
||
// what an assigned client may see, and neither covers somebody
|
||
// else's upload into a folder this staff member happens to own.
|
||
//
|
||
// notExpired() for the same reason the assignment half above skips
|
||
// deleted files: membership in this group grants nobody access to
|
||
// an expired file, because scopeVisibleToClient ends by excluding
|
||
// them, and something nobody can reach is not reach.
|
||
return ! File::query()
|
||
->whereIn('folder_id', $reachable)
|
||
->notExpired()
|
||
->whereNotIn('id', $this->files($user)->select('id'))
|
||
->exists();
|
||
}
|
||
}
|