mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +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.
226 lines
9.1 KiB
PHP
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.'));
|
|
}
|
|
}
|