fix(security): enforce team-scoped project/env lookups in onboarding

Use firstOrFail() for team-scoped project and environment lookups across
new-project Livewire flows so missing or cross-team UUIDs fail closed.
Also dispatch an error when boarding selects a non-owned project, and
update IDOR feature tests for the new error/exception behavior.
This commit is contained in:
Andras Bacsai 2026-03-29 15:55:03 +02:00
parent e36622fdfb
commit 3ba4553df5
8 changed files with 17 additions and 14 deletions

View file

@ -432,6 +432,9 @@ public function getProjects()
public function selectExistingProject()
{
$this->createdProject = Project::ownedByCurrentTeam()->find($this->selectedProject);
if (! $this->createdProject) {
return $this->dispatch('error', 'Project not found.');
}
$this->currentState = 'create-resource';
}

View file

@ -41,8 +41,8 @@ public function submit()
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();

View file

@ -121,8 +121,8 @@ public function submit()
}
$destination_class = $destination->getMorphClass();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
// Append @sha256 to image name if using digest and not already present
$imageName = $parser->getFullImageNameWithoutTag();

View file

@ -185,8 +185,8 @@ public function submit()
}
$destination_class = $destination->getMorphClass();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$application = Application::create([
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),

View file

@ -144,8 +144,8 @@ public function submit()
// Note: git_repository has already been validated and transformed in get_git_source()
// It may now be in SSH format (git@host:repo.git) which is valid for deploy keys
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
if ($this->git_source === 'other') {
$application_init = [
'name' => generate_random_name(),

View file

@ -278,8 +278,8 @@ public function submit()
}
$destination_class = $destination->getMorphClass();
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->first();
$environment = $project->load(['environments'])->environments->where('uuid', $environment_uuid)->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) {
$server = $destination->server;

View file

@ -45,8 +45,8 @@ public function submit()
}
$destination_class = $destination->getMorphClass();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$port = get_port_from_dockerfile($this->dockerfile);
if (! $port) {

View file

@ -78,6 +78,7 @@
->call('selectExistingProject');
expect($component->get('createdProject'))->toBeNull();
$component->assertDispatched('error');
});
test('boarding selectExistingProject can load own team project', function () {
@ -115,8 +116,7 @@
describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () {
test('cannot mount DeleteProject with project from another team', function () {
// Should throw ModelNotFoundException (404) because team-scoped query won't find it
Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id])
->assertStatus(500); // findOrFail throws ModelNotFoundException
Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]);
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
test('can mount DeleteProject with own team project', function () {