fix(security): prevent snapshot replay in API token permission checks
Never trust Livewire component properties for authorization decisions, as snapshots can be replayed from another user's session. Re-evaluate all permission checks fresh using auth()->user()->can() against current policies to ensure the authenticated user is being authorized, not a replayed copy. - Replace cached canUse* booleans with fresh policy evaluation - Add comprehensive security tests for token creation permissions - Update API authorization tests to verify middleware blocking behavior
This commit is contained in:
parent
c924655999
commit
66dc1515d4
4 changed files with 183 additions and 14 deletions
|
|
@ -50,31 +50,29 @@ private function getTokens()
|
|||
|
||||
public function updatedPermissions($permissionToUpdate)
|
||||
{
|
||||
// Check if user is trying to use restricted permissions
|
||||
if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) {
|
||||
// Re-evaluate policies fresh — never trust stored snapshot booleans
|
||||
if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
|
||||
$this->dispatch('error', 'You do not have permission to use root permissions.');
|
||||
// Remove root from permissions if it was somehow added
|
||||
$this->permissions = array_diff($this->permissions, ['root']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) {
|
||||
if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
|
||||
$this->dispatch('error', 'You do not have permission to use write permissions.');
|
||||
// Remove write permissions if they were somehow added
|
||||
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($permissionToUpdate == 'deploy' && ! $this->canUseDeployPermissions) {
|
||||
if ($permissionToUpdate == 'deploy' && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
|
||||
$this->dispatch('error', 'You do not have permission to use deploy permissions.');
|
||||
$this->permissions = array_diff($this->permissions, ['deploy']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($permissionToUpdate == 'read:sensitive' && ! $this->canUseSensitivePermissions) {
|
||||
if ($permissionToUpdate == 'read:sensitive' && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
|
||||
$this->dispatch('error', 'You do not have permission to use read:sensitive permissions.');
|
||||
$this->permissions = array_diff($this->permissions, ['read:sensitive']);
|
||||
|
||||
|
|
@ -100,20 +98,22 @@ public function addNewToken()
|
|||
try {
|
||||
$this->authorize('create', PersonalAccessToken::class);
|
||||
|
||||
// Validate permissions based on user role
|
||||
if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) {
|
||||
// Re-evaluate policies fresh against the current authenticated user.
|
||||
// Never trust $this->canUse* booleans — they come from the Livewire
|
||||
// snapshot which can be replayed from another user's session.
|
||||
if (in_array('root', $this->permissions) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
|
||||
throw new \Exception('You do not have permission to create tokens with root permissions.');
|
||||
}
|
||||
|
||||
if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) {
|
||||
if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
|
||||
throw new \Exception('You do not have permission to create tokens with write permissions.');
|
||||
}
|
||||
|
||||
if (in_array('deploy', $this->permissions) && ! $this->canUseDeployPermissions) {
|
||||
if (in_array('deploy', $this->permissions) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) {
|
||||
throw new \Exception('You do not have permission to create tokens with deploy permissions.');
|
||||
}
|
||||
|
||||
if (in_array('read:sensitive', $this->permissions) && ! $this->canUseSensitivePermissions) {
|
||||
if (in_array('read:sensitive', $this->permissions) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) {
|
||||
throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,10 +123,10 @@
|
|||
|
||||
// --- Member with root token (policy should deny mutations) ---
|
||||
|
||||
test('member with root token can view project', function () {
|
||||
test('member with root token is blocked by middleware', function () {
|
||||
$this->withToken($this->memberRootToken->plainTextToken)
|
||||
->getJson("/api/v1/projects/{$this->project->uuid}")
|
||||
->assertSuccessful();
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('member with root token cannot delete project', function () {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -7,6 +8,8 @@
|
|||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
|
|
|||
166
tests/Feature/Security/ApiTokenCreationSecurityTest.php
Normal file
166
tests/Feature/Security/ApiTokenCreationSecurityTest.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->member = User::factory()->create();
|
||||
$this->member->teams()->attach($this->team, ['role' => 'member']);
|
||||
});
|
||||
|
||||
describe('Livewire ApiTokens — member cannot create elevated tokens', function () {
|
||||
test('member cannot create token with root permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-root-token')
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member cannot create token with write permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-write-token')
|
||||
->set('permissions', ['write'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member cannot create token with deploy permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-deploy-token')
|
||||
->set('permissions', ['deploy'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member cannot create token with read:sensitive permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-sensitive-token')
|
||||
->set('permissions', ['read', 'read:sensitive'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member cannot bypass by setting canUseRootPermissions property', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
// Simulate snapshot replay: force the boolean to true
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('canUseRootPermissions', true)
|
||||
->set('description', 'sneaky-root-token')
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('member can create token with read permissions', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-read-token')
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertNotDispatched('error');
|
||||
|
||||
expect($this->member->tokens()->count())->toBe(1);
|
||||
expect($this->member->tokens()->first()->abilities)->toBe(['read']);
|
||||
});
|
||||
|
||||
test('owner can create token with root permissions', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'my-root-token')
|
||||
->set('permissions', ['root'])
|
||||
->call('addNewToken')
|
||||
->assertNotDispatched('error');
|
||||
|
||||
expect($this->owner->tokens()->count())->toBe(1);
|
||||
expect($this->owner->tokens()->first()->abilities)->toBe(['root']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiAbility middleware — member with elevated token blocked', function () {
|
||||
test('member root token is blocked on team_id=0 (root team)', function () {
|
||||
// Create root team with id=0
|
||||
$rootTeam = Team::factory()->create(['id' => 0]);
|
||||
$member = User::factory()->create();
|
||||
$rootTeam->members()->attach($member->id, ['role' => 'member']);
|
||||
|
||||
session(['currentTeam' => $rootTeam]);
|
||||
$token = $member->createToken('root-token', ['root']);
|
||||
|
||||
$this->withToken($token->plainTextToken)
|
||||
->getJson('/api/v1/projects')
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('admin root token passes on team_id=0 (root team)', function () {
|
||||
$rootTeam = Team::factory()->create(['id' => 0]);
|
||||
$admin = User::factory()->create();
|
||||
$rootTeam->members()->attach($admin->id, ['role' => 'admin']);
|
||||
|
||||
session(['currentTeam' => $rootTeam]);
|
||||
$token = $admin->createToken('root-token', ['root']);
|
||||
|
||||
$this->withToken($token->plainTextToken)
|
||||
->getJson('/api/v1/projects')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('member root token is blocked on non-zero team', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
$token = $this->member->createToken('root-token', ['root']);
|
||||
|
||||
$this->withToken($token->plainTextToken)
|
||||
->getJson('/api/v1/projects')
|
||||
->assertStatus(403);
|
||||
});
|
||||
|
||||
test('member read token passes on non-zero team', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
$token = $this->member->createToken('read-token', ['read']);
|
||||
|
||||
$this->withToken($token->plainTextToken)
|
||||
->getJson('/api/v1/projects')
|
||||
->assertSuccessful();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue