diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 0ce1bd1a2..c292e254c 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -9,6 +9,7 @@ use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; +use Livewire\Attributes\Locked; use Livewire\Component; class GithubPrivateRepository extends Component @@ -29,6 +30,7 @@ class GithubPrivateRepository extends Component public int $selected_repository_id; + #[Locked] public int $selected_github_app_id; public string $selected_repository_owner; @@ -37,8 +39,6 @@ class GithubPrivateRepository extends Component public string $selected_branch_name = 'main'; - public string $token; - public $repositories; public int $total_repositories_count = 0; @@ -71,7 +71,10 @@ public function mount() $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->repositories = $this->branches = collect(); - $this->github_apps = GithubApp::private(); + $this->github_apps = GithubApp::where('team_id', currentTeam()->id) + ->where('is_public', false) + ->whereNotNull('app_id') + ->get(); } public function updatedSelectedRepositoryId(): void @@ -96,22 +99,25 @@ public function updatedBuildPack() } } - public function loadRepositories($github_app_id) + public function loadRepositories(int $github_app_id): void { $this->repositories = collect(); $this->branches = collect(); $this->total_branches_count = 0; $this->page = 1; $this->selected_github_app_id = $github_app_id; - $this->github_app = GithubApp::where('id', $github_app_id)->first(); - $this->token = generateGithubInstallationToken($this->github_app); - $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page); + $this->github_app = GithubApp::where('team_id', currentTeam()->id) + ->where('is_public', false) + ->whereNotNull('app_id') + ->findOrFail($github_app_id); + $token = generateGithubInstallationToken($this->github_app); + $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); $this->total_repositories_count = $repositories['total_count']; $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); if ($this->repositories->count() < $this->total_repositories_count) { while ($this->repositories->count() < $this->total_repositories_count) { $this->page++; - $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page); + $repositories = loadRepositoryByPage($this->github_app, $token, $this->page); $this->total_repositories_count = $repositories['total_count']; $this->repositories = $this->repositories->concat(collect($repositories['repositories'])); } @@ -142,7 +148,9 @@ public function loadBranches() protected function loadBranchByPage() { - $response = Http::GitHub($this->github_app->api_url, $this->token) + $token = generateGithubInstallationToken($this->github_app); + + $response = Http::GitHub($this->github_app->api_url, $token) ->timeout(20) ->retry(3, 200, throw: false) ->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [ diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 54bbb3f7d..e5032d2d0 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -73,26 +73,6 @@ public static function ownedByCurrentTeam() }); } - public static function public() - { - return GithubApp::where(function ($query) { - $query->where(function ($q) { - $q->where('team_id', currentTeam()->id) - ->orWhere('is_system_wide', true); - })->where('is_public', true); - })->whereNotNull('app_id')->get(); - } - - public static function private() - { - return GithubApp::where(function ($query) { - $query->where(function ($q) { - $q->where('team_id', currentTeam()->id) - ->orWhere('is_system_wide', true); - })->where('is_public', false); - })->whereNotNull('app_id')->get(); - } - public function team() { return $this->belongsTo(Team::class); diff --git a/tests/Feature/GithubPrivateRepositoryTest.php b/tests/Feature/GithubPrivateRepositoryTest.php index ba66a10bb..5da17065d 100644 --- a/tests/Feature/GithubPrivateRepositoryTest.php +++ b/tests/Feature/GithubPrivateRepositoryTest.php @@ -5,8 +5,10 @@ use App\Models\PrivateKey; use App\Models\Team; use App\Models\User; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -64,6 +66,21 @@ function fakeGithubHttp(array $repositories): void ]); } +function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey +{ + $rsaKey = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + openssl_pkey_export($rsaKey, $pemKey); + + return PrivateKey::create([ + 'name' => 'Test Key '.$team->id, + 'private_key' => $pemKey, + 'team_id' => $team->id, + ]); +} + describe('GitHub Private Repository Component', function () { test('loadRepositories fetches and displays repositories', function () { $repos = [ @@ -81,6 +98,73 @@ function fakeGithubHttp(array $repositories): void ->assertSet('selected_repository_id', 1); }); + test('loadRepositories rejects a github app owned by another team', function () { + $victimTeam = Team::factory()->create(); + $victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam); + $victimGithubApp = GithubApp::create([ + 'name' => 'Victim GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 54321, + 'installation_id' => 98765, + 'client_id' => 'victim-client-id', + 'client_secret' => 'victim-client-secret', + 'webhook_secret' => 'victim-webhook-secret', + 'private_key_id' => $victimPrivateKey->id, + 'team_id' => $victimTeam->id, + 'is_public' => false, + 'is_system_wide' => false, + ]); + + Http::fake(); + + expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->call('loadRepositories', $victimGithubApp->id) + )->toThrow(ModelNotFoundException::class); + + Http::assertNothingSent(); + }); + + test('loadRepositories does not mint tokens for another teams system wide github app', function () { + $victimTeam = Team::factory()->create(); + $victimPrivateKey = githubPrivateRepositoryTestPrivateKeyForTeam($victimTeam); + $systemWideGithubApp = GithubApp::create([ + 'name' => 'System Wide GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 54321, + 'installation_id' => 98765, + 'client_id' => 'system-client-id', + 'client_secret' => 'system-client-secret', + 'webhook_secret' => 'system-webhook-secret', + 'private_key_id' => $victimPrivateKey->id, + 'team_id' => $victimTeam->id, + 'is_public' => false, + 'is_system_wide' => true, + ]); + + Http::fake(); + + expect(fn () => Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->call('loadRepositories', $systemWideGithubApp->id) + )->toThrow(ModelNotFoundException::class); + + Http::assertNothingSent(); + }); + + test('github installation token is not stored as public component state', function () { + expect((new ReflectionClass(GithubPrivateRepository::class))->hasProperty('token'))->toBeFalse(); + }); + + test('selected github app id cannot be tampered with from the client', function () { + Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->set('selected_github_app_id', $this->githubApp->id); + })->throws(CannotUpdateLockedPropertyException::class); + test('loadRepositories can be called again to refresh the repository list', function () { $initialRepos = [ ['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],