diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index ca2bbd9b4..e95fd2f5a 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -10,6 +10,7 @@ class Executions extends Component { + #[Locked] public ScheduledTask $task; #[Locked] @@ -28,6 +29,7 @@ class Executions extends Component public $logsPerPage = 100; + #[Locked] public $selectedExecution = null; public $isPollingActive = false; @@ -45,7 +47,7 @@ public function mount($taskId) { try { $this->taskId = $taskId; - $this->task = ScheduledTask::findOrFail($taskId); + $this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId); $this->executions = $this->task->executions()->take(20)->get(); $this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone'); if (! $this->serverTimezone) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 882737f09..11df00153 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -15,8 +15,10 @@ class Show extends Component { use AuthorizesRequests; + #[Locked] public Application|Service $resource; + #[Locked] public ScheduledTask $task; #[Locked] @@ -115,6 +117,7 @@ public function toggleEnabled() { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->isEnabled = ! $this->isEnabled; $this->task->enabled = $this->isEnabled; $this->task->save(); @@ -128,6 +131,7 @@ public function instantSave() { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); $this->refreshTasks(); @@ -140,6 +144,7 @@ public function submit() { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); } catch (\Exception $e) { @@ -160,6 +165,7 @@ public function delete() { try { $this->authorize('update', $this->resource); + $this->authorize('delete', $this->task); $this->task->delete(); if ($this->type === 'application') { @@ -176,6 +182,7 @@ public function executeNow() { try { $this->authorize('update', $this->resource); + $this->authorize('update', $this->task); ScheduledTaskJob::dispatch($this->task); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { diff --git a/app/Policies/ScheduledTaskPolicy.php b/app/Policies/ScheduledTaskPolicy.php new file mode 100644 index 000000000..fac7e7b22 --- /dev/null +++ b/app/Policies/ScheduledTaskPolicy.php @@ -0,0 +1,70 @@ +teams->contains('id', $scheduledTask->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ScheduledTask $scheduledTask): Response + { + if (! $user->isAdminOfTeam($scheduledTask->team_id)) { + return Response::deny('You need at least admin or owner permissions to update this scheduled task.'); + } + + return Response::allow(); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ScheduledTask $scheduledTask): bool + { + return $user->isAdminOfTeam($scheduledTask->team_id); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ScheduledTask $scheduledTask): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ScheduledTask $scheduledTask): bool + { + return false; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 0adf9e232..09b2a3e08 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -19,6 +19,7 @@ use App\Models\Project; use App\Models\PushoverNotificationSettings; use App\Models\S3Storage; +use App\Models\ScheduledTask; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -56,6 +57,7 @@ use App\Policies\ProjectPolicy; use App\Policies\ResourceCreatePolicy; use App\Policies\S3StoragePolicy; +use App\Policies\ScheduledTaskPolicy; use App\Policies\ServerPolicy; use App\Policies\ServiceApplicationPolicy; use App\Policies\ServiceDatabasePolicy; @@ -118,6 +120,9 @@ class AuthServiceProvider extends ServiceProvider // S3 storage policy S3Storage::class => S3StoragePolicy::class, + // Scheduled task policy + ScheduledTask::class => ScheduledTaskPolicy::class, + // Team policy Team::class => TeamPolicy::class, diff --git a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php new file mode 100644 index 000000000..b250db119 --- /dev/null +++ b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php @@ -0,0 +1,125 @@ +withoutVite(); + InstanceSettings::forceCreate(['id' => 0]); + + $this->attacker = User::factory()->create(); + $this->attackerTeam = Team::factory()->create(); + $this->attacker->teams()->attach($this->attackerTeam, ['role' => 'owner']); + + $this->victim = User::factory()->create(); + $this->victimTeam = Team::factory()->create(); + $this->victim->teams()->attach($this->victimTeam, ['role' => 'owner']); + + // Attacker team gets a real server/project/env/app so Show can mount + authorize + $this->server = Server::factory()->create(['team_id' => $this->attackerTeam->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->attackerTeam->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->attacker); + session(['currentTeam' => $this->attackerTeam]); +}); + +describe('ScheduledTask locked properties', function () { + test('Show component task property has Locked attribute', function () { + $property = new ReflectionProperty(Show::class, 'task'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('Show component resource property has Locked attribute', function () { + $property = new ReflectionProperty(Show::class, 'resource'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('Executions component task property has Locked attribute', function () { + $property = new ReflectionProperty(Executions::class, 'task'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('Executions component selected execution property has Locked attribute', function () { + $property = new ReflectionProperty(Executions::class, 'selectedExecution'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); +}); + +describe('ScheduledTask cross-team access', function () { + test('Executions rejects mounting another team task id', function () { + $victimTask = ScheduledTask::factory()->create([ + 'team_id' => $this->victimTeam->id, + 'command' => 'echo top-secret-victim-command', + ]); + + Livewire::test(Executions::class, ['taskId' => $victimTask->id]) + ->assertStatus(404); + }); + + test('Show policy denies updating another team task', function () { + $victimTask = ScheduledTask::factory()->create([ + 'team_id' => $this->victimTeam->id, + 'name' => 'victim-task', + 'command' => 'echo original-victim-command', + ]); + + expect( + Gate::forUser($this->attacker)->allows('update', $victimTask) + )->toBeFalse(); + }); + + test('Show policy denies deleting another team task', function () { + $victimTask = ScheduledTask::factory()->create([ + 'team_id' => $this->victimTeam->id, + 'name' => 'victim-task', + 'command' => 'echo original-victim-command', + ]); + + expect( + Gate::forUser($this->attacker)->allows('delete', $victimTask) + )->toBeFalse(); + }); + + test('Show policy allows updating own team task', function () { + $ownTask = ScheduledTask::factory()->create([ + 'team_id' => $this->attackerTeam->id, + 'application_id' => $this->application->id, + 'name' => 'own-task', + 'command' => 'echo original-command', + ]); + + expect( + Gate::forUser($this->attacker)->allows('update', $ownTask) + )->toBeTrue(); + }); +});