mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-22 03:23:23 +00:00
Refuse self-deactivation over the API however the boolean is written
Api\UsersController::update() compares the validated value strictly:
if ($user->is($actor) && ($validated['active'] ?? true) === false) {
The `boolean` rule accepts 0 and "0" as well as false, and it does not
cast. `0 === false` is false, so the refusal never fires -- and the
model's own `boolean` cast then stores as false exactly the value the
guard had just decided was not a deactivation.
Measured against main, with a second administrator present so that
guardLastAdministrator is not what answers:
{"active": false} -> 422, still active
{"active": 0} -> 200, active is now false
{"active": "0"} -> 200, active is now false
The method's own docblock says it is "Refused with a 422 if the change
would leave the installation with no active administrator, or if you
would be deactivating yourself", and the web screen does refuse. This is
the API half of that sentence.
RolesController::guardScopeRemoval documents the rule this breaks, in the
same words: callers resolve the flag with Request::boolean() and hand the
same value to the guard and to the write, deliberately, because reading
the validated array and comparing it strictly "would let a request
through here that the model's `boolean` cast then stores as false anyway
-- the guard and the write disagreeing about one value is exactly the
shape this guard exists to prevent".
So read it once, with Request::boolean(), and give that one value to both.
Not changed: the validation rule. It stays `boolean`, so the accepted
inputs are the same as before -- what changes is that one of them stops
meaning two different things on its way through. Nor anything about
deactivating somebody else: all three forms still work, and there are
tests saying so.
Six cases from two datasets. Two measured red against the unfixed
controller (2 failed / 4 passed): 0 and "0" on yourself. `false` was
already refused, and the three "somebody else" cases are green either way
-- they guard against the fix over-refusing, not against the bug.
Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.
This commit is contained in:
@@ -176,15 +176,27 @@ class UsersController extends Controller
|
||||
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
|
||||
]);
|
||||
|
||||
// Read through Request::boolean() rather than off the validated
|
||||
// array, for the reason RolesController::guardScopeRemoval spells
|
||||
// out: the `boolean` rule accepts 0 and "0" as well as false but
|
||||
// does not cast, so a strict comparison lets through a value the
|
||||
// model's own `boolean` cast then stores as false anyway. The same
|
||||
// value goes to the guard and to the write.
|
||||
$deactivating = array_key_exists('active', $validated) && ! $request->boolean('active');
|
||||
|
||||
// The same refusal the web screen makes, and for the same reason:
|
||||
// locking yourself out is never what was meant.
|
||||
if ($user->is($actor) && ($validated['active'] ?? true) === false) {
|
||||
if ($user->is($actor) && $deactivating) {
|
||||
throw ValidationException::withMessages([
|
||||
'active' => __('You cannot deactivate your own account.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'active', 'password']));
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'password']));
|
||||
|
||||
if (array_key_exists('active', $validated)) {
|
||||
$attributes['active'] = $request->boolean('active');
|
||||
}
|
||||
|
||||
if (array_key_exists('role_id', $validated)) {
|
||||
$attributes['role_id'] = (int) $validated['role_id'];
|
||||
|
||||
@@ -351,6 +351,35 @@ test('a caller cannot deactivate or delete their own account', function () {
|
||||
->assertJsonPath('errors.user.0', 'You cannot delete your own account.');
|
||||
});
|
||||
|
||||
test('self-deactivation is refused however the boolean is written', function (mixed $active) {
|
||||
// A second administrator, so the last-administrator guard is not what
|
||||
// refuses this: with only one, that guard answers first and the refusal
|
||||
// under test here is never reached.
|
||||
User::factory()->create();
|
||||
|
||||
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['active' => $active])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.active.0', 'You cannot deactivate your own account.');
|
||||
|
||||
expect($this->admin->refresh()->active)->toBeTrue();
|
||||
})->with([
|
||||
'false' => [false],
|
||||
'zero' => [0],
|
||||
'the string zero' => ['0'],
|
||||
]);
|
||||
|
||||
test('deactivating somebody else still works in every one of those forms', function (mixed $active) {
|
||||
$user = User::factory()->role(SystemRole::Uploader)->create();
|
||||
|
||||
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", ['active' => $active])->assertOk();
|
||||
|
||||
expect($user->refresh()->active)->toBeFalse();
|
||||
})->with([
|
||||
'false' => [false],
|
||||
'zero' => [0],
|
||||
'the string zero' => ['0'],
|
||||
]);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deletion and its content
|
||||
|
||||
Reference in New Issue
Block a user