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
157 lines
6.5 KiB
PHP
157 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\Folder;
|
|
use App\Modules\Identity\AccountContentDeletion;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* GHSA-w29w-pj29-x7ww. Deleting an account that owns content makes the
|
|
* admin choose who inherits it, and the picker narrows that list to the
|
|
* viewer's own roster — its comment says so: "a client-scoped staff member
|
|
* is not shown the name of somebody they can reach nothing of."
|
|
*
|
|
* The write asked a different question. `exists, active, not the account
|
|
* being deleted` is true of every account on the installation, so a scoped
|
|
* staff member could name one the picker had deliberately kept off the
|
|
* list, and a roster client's files would land with a client on somebody
|
|
* else's roster — readable, editable and deletable there, because a client
|
|
* owns what they uploaded.
|
|
*
|
|
* The picker and the write now run one predicate, not two that agree by
|
|
* inspection.
|
|
*/
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
|
|
$role = Role::query()->create(['name' => 'Reps '.Str::random(6), 'client_scoped' => true]);
|
|
foreach ([Permission::DeleteClients, Permission::EditClients, Permission::CreateClients] as $permission) {
|
|
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
|
|
}
|
|
|
|
$this->rep = User::factory()->create(['role_id' => $role->id]);
|
|
|
|
// On this rep's roster, and owning something worth inheriting.
|
|
$this->mine = User::factory()->client()->create(['name' => 'Mine']);
|
|
$this->rep->assignedClients()->sync([$this->mine->id]);
|
|
|
|
$this->file = File::factory()->create(['uploaded_by' => $this->mine->id]);
|
|
$this->folder = Folder::query()->create([
|
|
'name' => 'Theirs', 'slug' => 'theirs-'.Str::random(6), 'path' => '/', 'created_by' => $this->mine->id,
|
|
]);
|
|
|
|
// On nobody's roster as far as this rep is concerned.
|
|
$this->stranger = User::factory()->client()->create(['name' => 'Not Mine']);
|
|
});
|
|
|
|
test('the picker does not offer a client outside the roster', function () {
|
|
// The promise the write has to keep. Asserted first so that a change
|
|
// loosening the picker cannot quietly make the rest of this file vacuous.
|
|
$names = collect(app(AccountContentDeletion::class)->candidates($this->rep))
|
|
->pluck('name');
|
|
|
|
expect($names)->toContain('Mine')
|
|
->and($names)->not->toContain('Not Mine');
|
|
});
|
|
|
|
test('a scoped staff member cannot hand content to a client off their roster', function () {
|
|
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $this->stranger->id,
|
|
])->assertSessionHasErrors('reassign_to_id');
|
|
|
|
expect($this->file->fresh()->uploaded_by)->toBe($this->mine->id)
|
|
->and($this->folder->fresh()->created_by)->toBe($this->mine->id)
|
|
->and(User::withTrashed()->find($this->mine->id)->trashed())->toBeFalse();
|
|
});
|
|
|
|
test('the API twin refuses it too', function () {
|
|
$token = $this->rep->createToken('t', [Permission::DeleteClients->value])->plainTextToken;
|
|
|
|
$this->withToken($token)
|
|
->deleteJson("/api/v1/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $this->stranger->id,
|
|
])->assertStatus(422);
|
|
|
|
expect($this->file->fresh()->uploaded_by)->toBe($this->mine->id);
|
|
});
|
|
|
|
test('refusing reads the same as an account that is not there at all', function () {
|
|
// Otherwise the refusal is an oracle: a scoped staff member could walk
|
|
// the id space and learn which accounts exist outside their roster by
|
|
// the difference between the two answers.
|
|
$outsider = $this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $this->stranger->id,
|
|
]);
|
|
|
|
$nobody = $this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => 99999999,
|
|
]);
|
|
|
|
expect($outsider->getSession()->get('errors')->get('reassign_to_id'))
|
|
->toBe($nobody->getSession()->get('errors')->get('reassign_to_id'));
|
|
});
|
|
|
|
test('a client on the roster is still a valid target', function () {
|
|
$alsoMine = User::factory()->client()->create(['name' => 'Also Mine']);
|
|
$this->rep->assignedClients()->sync([$this->mine->id, $alsoMine->id]);
|
|
|
|
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $alsoMine->id,
|
|
])->assertRedirect();
|
|
|
|
expect($this->file->fresh()->uploaded_by)->toBe($alsoMine->id)
|
|
->and($this->folder->fresh()->created_by)->toBe($alsoMine->id);
|
|
});
|
|
|
|
test('a staff account is still a valid target, because staff are narrowed nowhere', function () {
|
|
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $this->admin->id,
|
|
])->assertRedirect();
|
|
|
|
expect($this->file->fresh()->uploaded_by)->toBe($this->admin->id);
|
|
});
|
|
|
|
test('an unscoped administrator can still reassign to anybody', function () {
|
|
// The tightening is about the roster, and an unscoped staff member has
|
|
// no roster — StaffLibraryScope::clients() is every client for them.
|
|
$this->actingAs($this->admin)->delete("/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $this->stranger->id,
|
|
])->assertRedirect();
|
|
|
|
expect($this->file->fresh()->uploaded_by)->toBe($this->stranger->id);
|
|
});
|
|
|
|
test('the advisory controls still behave as they did', function () {
|
|
// Both were already enforced, and both must survive the new rule:
|
|
// an inactive account, and the account being deleted.
|
|
$inactive = User::factory()->client()->create(['active' => false]);
|
|
$this->rep->assignedClients()->sync([$this->mine->id, $inactive->id]);
|
|
|
|
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $inactive->id,
|
|
])->assertSessionHasErrors('reassign_to_id');
|
|
|
|
$this->actingAs($this->rep)->delete("/clients/{$this->mine->id}", [
|
|
'content_action' => 'reassign',
|
|
'reassign_to_id' => $this->mine->id,
|
|
])->assertSessionHasErrors('reassign_to_id');
|
|
|
|
expect($this->file->fresh()->uploaded_by)->toBe($this->mine->id);
|
|
});
|