mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
8372f42525
Reported by @skeletonsec as GHSA-w29w-pj29-x7ww. Deleting an account that owns files makes the admin choose who inherits them. The picker narrows that list for a client-scoped staff member to their own roster, and says why two methods up: "a client-scoped staff member is not shown the name of somebody they can reach nothing of, and a picker is no more a reason to hand one over than a listing is." The write asked something else entirely — exists, active, and not the account being deleted. All three are true of every account on the installation. So a scoped staffer could name an id the picker had deliberately kept off the list, and a roster client's files and folders landed with a client on somebody else's roster: readable, editable and deletable there, because a client owns what they uploaded and visibleToClient() includes uploaded_by. The entry doors scope the source account and always did — guardTarget goes through canAssignClient. It is the destination nobody scoped. candidates() and validate() now run one predicate, reachableTargets(), rather than two that happened to agree. Two that agree by inspection is what this was: the narrowing existed, was correct, and was only ever applied to the list. The refusal deliberately reads as "no such account". An out-of-roster id and an id belonging to nobody now produce the same message, because a refusal that distinguishes them lets a scoped staffer walk the id space and learn which accounts exist outside their roster. That is why Rule::exists is gone rather than kept alongside: one code path, one answer. A test pins the two messages as identical instead of naming either. Both the web screen and the API twin come through this one validate(), so both are fixed by it — and the test file proves each separately rather than assuming the sharing holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
176 lines
7.2 KiB
PHP
176 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Identity;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Files\DeletedAccountContent;
|
|
use App\Modules\Identity\Models\Role;
|
|
use Closure;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
/**
|
|
* Deciding what happens to an account's files and folders when the account
|
|
* is deleted — cascade them away, or hand them to somebody still active.
|
|
*
|
|
* Staff and clients are deleted from two different screens, and both asked
|
|
* the same three questions in identical code: who could inherit this, what
|
|
* did the admin choose, and carry it out. Keeping one copy matters more than
|
|
* the line count here, because the rules are about destroying data: a
|
|
* "reassign to an active account other than this one" check that was
|
|
* tightened on one screen and not the other would be a real hole.
|
|
*
|
|
* The work itself belongs to DeletedAccountContent; this is the request-side
|
|
* half that wraps it.
|
|
*/
|
|
class AccountContentDeletion
|
|
{
|
|
public function __construct(
|
|
private readonly DeletedAccountContent $content,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly StaffLibraryScope $scope,
|
|
) {}
|
|
|
|
/**
|
|
* Every other active account this viewer may be shown, for the
|
|
* reassignment-target picker. $excludeId is omitted on index pages,
|
|
* where one candidate list is shared across every row and each row's
|
|
* own id is filtered out client-side instead.
|
|
*
|
|
* The client half is narrowed by StaffLibraryScope, the same rule that
|
|
* narrows the list this picker sits next to: a client-scoped staff
|
|
* member is not shown the name of somebody they can reach nothing of,
|
|
* and a picker is no more a reason to hand one over than a listing is.
|
|
* Staff accounts are not narrowed anywhere in the application and are
|
|
* not narrowed here.
|
|
*
|
|
* An unscoped viewer's list is unchanged — StaffLibraryScope::clients()
|
|
* returns every client for them.
|
|
*
|
|
* $viewer is null only where the picker is about the installation
|
|
* rather than about a screen: the erasure default in privacy settings
|
|
* is stored once for everybody, behind edit_settings, so narrowing it
|
|
* by whoever happens to be editing would store the wrong answer.
|
|
*
|
|
* @return array<int, array{id: int, name: string, role: string}>
|
|
*/
|
|
public function candidates(?User $viewer, ?int $excludeId = null): array
|
|
{
|
|
return $this->reachableTargets($viewer)
|
|
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
|
|
->with('role')
|
|
->orderBy('name')
|
|
->get()
|
|
->map(function (User $user): array {
|
|
$role = $user->role;
|
|
|
|
return [
|
|
'id' => $user->id,
|
|
'name' => $user->name,
|
|
'role' => $user->isClient() ? __('Client') : ($role instanceof Role ? $role->name : __('Staff')),
|
|
];
|
|
})
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* When the account being deleted owns any files/folders, require the
|
|
* admin to choose what happens to them. Returns an empty array when
|
|
* there is nothing to decide, so accounts with no content delete
|
|
* exactly as before.
|
|
*
|
|
* @return array{content_action?: string, reassign_to_id?: int}
|
|
*/
|
|
public function validate(Request $request, User $target): array
|
|
{
|
|
$summary = $this->content->summarize($target);
|
|
|
|
if ($summary['files'] === 0 && $summary['folders'] === 0) {
|
|
return [];
|
|
}
|
|
|
|
$viewer = $request->user();
|
|
|
|
return $request->validate([
|
|
'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])],
|
|
'reassign_to_id' => [
|
|
'required_if:content_action,reassign',
|
|
'integer',
|
|
Rule::notIn([$target->id]),
|
|
// The same question the picker asks, asked again of what
|
|
// came back from it. It used to be "exists, and is active",
|
|
// which is not the boundary the picker documents two
|
|
// methods up: a client-scoped staff member was shown their
|
|
// own roster and could name anybody, so deleting a roster
|
|
// client could hand that client's files and folders to a
|
|
// client on somebody else's roster — who then reads, edits
|
|
// and deletes them under the own-upload rules
|
|
// (GHSA-w29w-pj29-x7ww).
|
|
//
|
|
// One predicate for both, rather than a matching pair: a
|
|
// picker that promises a boundary the write does not keep
|
|
// is exactly what this was.
|
|
function (string $attribute, mixed $value, Closure $fail) use ($viewer): void {
|
|
if (! $this->reachableTargets($viewer)->whereKey($value)->exists()) {
|
|
// Deliberately the message an id that does not
|
|
// exist at all would get. "Not yours" and "not
|
|
// there" have to read the same, or refusing is how
|
|
// a scoped staff member enumerates the accounts
|
|
// outside their roster.
|
|
$fail('validation.exists')->translate();
|
|
}
|
|
},
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Every active account $viewer may hand content to: staff, who are
|
|
* narrowed nowhere in the application, plus the clients
|
|
* StaffLibraryScope shows them. An unscoped viewer gets everybody,
|
|
* because clients() returns everybody for them.
|
|
*
|
|
* $viewer is null only where the question is about the installation
|
|
* rather than about a screen — the erasure default in privacy
|
|
* settings, which is stored once for everybody.
|
|
*
|
|
* @return Builder<User>
|
|
*/
|
|
private function reachableTargets(?User $viewer): Builder
|
|
{
|
|
return User::query()
|
|
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
|
|
->where('type', UserType::Staff)
|
|
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
|
|
->where('active', true);
|
|
}
|
|
|
|
/**
|
|
* @param array{content_action?: string, reassign_to_id?: int} $validated
|
|
*/
|
|
public function apply(array $validated, User $target, string $name): void
|
|
{
|
|
$action = $validated['content_action'] ?? null;
|
|
|
|
if ($action === 'cascade_delete') {
|
|
$result = $this->content->cascadeDelete($target);
|
|
$this->activity->log(Action::AccountContentCascadeDeleted, context: ['name' => $name, ...$result]);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($action === 'reassign' && isset($validated['reassign_to_id'])) {
|
|
$to = User::findOrFail($validated['reassign_to_id']);
|
|
$result = $this->content->reassignTo($target, $to);
|
|
$this->activity->log(Action::AccountContentReassigned, context: ['name' => $name, 'target' => $to->name, ...$result]);
|
|
}
|
|
}
|
|
}
|