diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index a1cc4db19f..7a530fe297 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -4,6 +4,7 @@ namespace App\Livewire\Security; use App\Models\InstanceSettings; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\Rule; use Laravel\Sanctum\PersonalAccessToken; use Livewire\Attributes\Locked; use Livewire\Component; @@ -115,35 +116,39 @@ class ApiTokens extends Component try { $this->authorize('create', PersonalAccessToken::class); + $validated = $this->validate([ + 'description' => 'required|min:3|max:255', + 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365', + 'permissions' => 'required|array|min:1', + 'permissions.*' => ['required', 'string', Rule::in(['read', 'read:sensitive', 'write', 'write:sensitive', 'deploy', 'root'])], + ]); + $permissions = array_values($validated['permissions']); + // 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, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { + if (in_array('root', $permissions, true) && ! 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) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { + if (array_intersect(['write', 'write:sensitive'], $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, true) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { + if (in_array('deploy', $permissions, true) && ! 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, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { + if (in_array('read:sensitive', $permissions, true) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); } - $this->validate([ - 'description' => 'required|min:3|max:255', - 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365', - ]); $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; - $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); + $token = auth()->user()->createToken($this->description, $permissions, $expiresAt); auditLog('ui.api_token.created', [ 'team_id' => currentTeam()->id, 'api_token_name' => $this->description, - 'abilities' => array_values($this->permissions), + 'abilities' => $permissions, 'expires_at' => $expiresAt?->toIso8601String(), ]); $this->getTokens(); diff --git a/app/Models/User.php b/app/Models/User.php index 4177eff973..682b56dc5b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -235,7 +235,7 @@ class User extends Authenticatable implements SendsEmail return $new_team; } - public function createToken(string $name, array $abilities = ['*'], ?DateTimeInterface $expiresAt = null) + public function createToken(string $name, array $abilities = ['read'], ?DateTimeInterface $expiresAt = null) { $plainTextToken = sprintf( '%s%s%s', diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php index 0e5d363de9..60b23b0e56 100644 --- a/tests/Feature/Security/ApiTokenCreationSecurityTest.php +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -5,12 +5,13 @@ use App\Models\InstanceSettings; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0, 'is_api_enabled' => true])); $this->team = Team::factory()->create(); @@ -22,6 +23,34 @@ beforeEach(function () { }); describe('Livewire ApiTokens — member cannot create elevated tokens', function () { + test('tokens created without explicit abilities have read access only', function () { + session(['currentTeam' => $this->team]); + + $token = $this->member->createToken('default-token')->accessToken; + + expect($token->abilities)->toBe(['read']) + ->and($token->can('read'))->toBeTrue() + ->and($token->can('write'))->toBeFalse() + ->and($token->can('root'))->toBeFalse(); + }); + + test('member cannot create a token with an unknown or wildcard ability', function (array $permissions) { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'invalid-token') + ->set('permissions', $permissions) + ->call('addNewToken'); + + expect($this->member->tokens()->count())->toBe(0); + })->with([ + 'wildcard' => [['*']], + 'wildcard with read' => [['read', '*']], + 'unknown ability' => [['read', 'admin']], + 'non-string ability' => [['read', 1]], + ]); + test('member cannot create token with root permissions', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); @@ -78,13 +107,9 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', 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(fn () => Livewire::test(ApiTokens::class) + ->set('canUseRootPermissions', true)) + ->toThrow(CannotUpdateLockedPropertyException::class); expect($this->member->tokens()->count())->toBe(0); }); @@ -116,9 +141,30 @@ describe('Livewire ApiTokens — member cannot create elevated tokens', function expect($this->owner->tokens()->count())->toBe(1); expect($this->owner->tokens()->first()->abilities)->toBe(['root']); }); + + test('owner cannot create a wildcard token', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'owner-wildcard-token') + ->set('permissions', ['*']) + ->call('addNewToken'); + + expect($this->owner->tokens()->count())->toBe(0); + }); }); describe('ApiAbility middleware — member with elevated token blocked', function () { + test('existing member wildcard token is blocked', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('legacy-wildcard-token', ['*']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertForbidden(); + }); + 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]);