Files
projectsend/app/Modules/Groups/Http/Controllers/GroupsController.php
T
ignacionelson 5fb17388cd Stop scoped staff reaching groups that are not theirs
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.
2026-09-08 18:40:33 -03:00

226 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Groups\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Support\Pagination;
use App\Support\PublicUrl;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class GroupsController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly PublicUrl $publicUrl,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): Response
{
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:255'],
'visibility' => ['nullable', Rule::in(['public', 'private'])],
]);
$filters = [
'search' => $validated['search'] ?? null,
'visibility' => $validated['visibility'] ?? null,
];
$viewer = $request->user();
assert($viewer !== null);
// Scoped, not Group::query(): a client-scoped staff member is told
// about a group because one of their clients is in it. Without
// this the listing showed every group on the installation, to a
// viewer who could reach nothing of theirs (GHSA-r3hg-3fxw-rcmr).
$groups = $this->scope->groups($viewer)
->withCount('members')
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")))
->when($filters['visibility'], fn (Builder $query, string $visibility) => $query->where('public', $visibility === 'public'))
->orderBy('name')
->paginate(25)
->withQueryString()
->through(fn (Group $group): array => [
'id' => $group->id,
'name' => $group->name,
'description' => $group->description,
'public' => $group->public,
'members_count' => $group->members_count,
'public_url' => $group->public
? $this->publicUrl->for($group)
: null,
]);
return Inertia::render('groups/index', [
'groups' => $groups->items(),
'pagination' => Pagination::meta($groups),
'filters' => $filters,
]);
}
public function create(): Response
{
return Inertia::render('groups/create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
// The slug only matters (and is only shown) once a group is
// public — otherwise fall back to one derived from the name.
'slug' => Rules::slug('groups'),
'description' => ['nullable', 'string', 'max:2000'],
'public' => ['required', 'boolean'],
]);
$validated['slug'] = $validated['slug'] ?? '' ?: Group::uniqueSlugFrom($validated['name']);
$group = Group::query()->create($validated);
$this->activity->log(Action::GroupCreated, subject: $group);
if ($group->public) {
$this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]);
}
// Same create-without-edit rule as ClientsController::store().
$target = $request->user()?->can('edit_groups')
? redirect()->route('groups.edit', $group)
: redirect()->route('groups.create');
return $target->with('success', __('Group created.'));
}
public function edit(Request $request, Group $group): Response
{
$viewer = $request->user();
assert($viewer !== null);
// The same reach question update() and destroy() ask, asked one
// step earlier. Without it this was the one group route holding no
// library boundary at all: a scoped staff member could open a group
// whose contents they cannot see, read its membership off the
// screen, and only be refused on save.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
return Inertia::render('groups/edit', [
'group' => [
'id' => $group->id,
'name' => $group->name,
'slug' => $group->slug,
'description' => $group->description,
'public' => $group->public,
],
// Both lists narrow through StaffLibraryScope::clients(), which
// is the listing half of the rule this screen's buttons are
// already guarded with: a member outside the roster cannot be
// removed here (allowsGroupMembership refuses it), and a client
// outside it cannot be added. Naming them anyway, with their
// address, was the same mistake the client list made before
// that method existed. An unscoped viewer sees everything,
// unchanged.
'members' => $group->members()
->whereIn('users.id', $this->scope->clients($viewer)->select('id'))
->orderBy('name')
->get()
->map(fn (User $member): array => [
'id' => $member->id,
'name' => $member->name,
'email' => $member->email,
])->all(),
'available_clients' => $this->scope->clients($viewer)
->whereNotIn('id', $group->members()->pluck('users.id'))
->orderBy('name')
->get()
->map(fn (User $client): array => [
'id' => $client->id,
'name' => $client->name,
'email' => $client->email,
])->all(),
]);
}
public function update(Request $request, Group $group): RedirectResponse
{
$viewer = $request->user();
assert($viewer !== null);
// A group whose reach extends past this staff member's library is
// not theirs to change. #1701 drew this line for membership; the
// object itself needs it for the same reason and more sharply —
// an assignment to a group is how its members reach a file, so
// deleting one revokes that access for every member, including
// clients outside this person's roster. Measured before this
// guard: a scoped role deleted a stranger's group and the
// stranger's client stopped seeing the file it carried.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
// The slug only matters (and is only shown) once a group is
// public — otherwise fall back to one derived from the name.
'slug' => Rules::slug('groups', $group->id),
'description' => ['nullable', 'string', 'max:2000'],
'public' => ['required', 'boolean'],
]);
// Omitting the field on an update leaves the current slug alone —
// it must not silently change just because the name did.
$validated['slug'] = ($validated['slug'] ?? '') ?: ($group->slug ?: Group::uniqueSlugFrom($validated['name'], $group->id));
$wasPublic = $group->public;
$group->update($validated);
$this->activity->log(Action::GroupUpdated, subject: $group);
if (! $wasPublic && $group->public) {
$this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]);
} elseif ($wasPublic && ! $group->public) {
$this->activity->log(Action::GroupMadePrivate, subject: $group);
}
return back()->with('success', __('Group updated.'));
}
public function destroy(Request $request, Group $group): RedirectResponse
{
$viewer = $request->user();
assert($viewer !== null);
// A group whose reach extends past this staff member's library is
// not theirs to change. #1701 drew this line for membership; the
// object itself needs it for the same reason and more sharply —
// an assignment to a group is how its members reach a file, so
// deleting one revokes that access for every member, including
// clients outside this person's roster. Measured before this
// guard: a scoped role deleted a stranger's group and the
// stranger's client stopped seeing the file it carried.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$name = $group->name;
$group->delete();
$this->activity->log(Action::GroupDeleted, context: ['name' => $name]);
return redirect()->route('groups.index')->with('success', __('Group deleted.'));
}
}