From 281184c0406e926bed85f4ff0731ec55361a0170 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:31:06 +0200 Subject: [PATCH] fix(github): derive app API URLs from HTML hosts Normalize GitHub organization values and derive API URLs for GitHub.com, GHE.com, and enterprise hosts when creating or updating GitHub Apps. --- app/Http/Controllers/Api/GithubController.php | 29 +++- app/Livewire/Source/Github/Change.php | 23 ++- app/Livewire/Source/Github/Create.php | 10 +- bootstrap/helpers/github.php | 103 ++++++++++++- openapi.json | 1 - openapi.yaml | 1 - .../livewire/source/github/change.blade.php | 3 +- tests/Feature/GithubAppsListApiTest.php | 139 ++++++++++++++++++ tests/Feature/GithubSourceChangeTest.php | 93 ++++++++++++ .../Security/GithubAppSetupCallbackTest.php | 42 +++++- tests/Unit/GithubUrlHelpersTest.php | 78 ++++++++++ 11 files changed, 496 insertions(+), 26 deletions(-) create mode 100644 tests/Unit/GithubUrlHelpersTest.php diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 651969b97..fa166d4f7 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -129,7 +129,7 @@ public function list_github_apps(Request $request) 'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'], 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'], ], - required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], + required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], ), ), ], @@ -204,10 +204,14 @@ public function create_github_app(Request $request) 'is_system_wide', ]; + $request->merge([ + 'organization' => normalizeGithubOrganization($request->input('organization')), + ]); + $validator = customApiValidator($request->all(), [ 'name' => 'required|string|max:255', - 'organization' => 'nullable|string|max:255', - 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'], + 'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl], 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'custom_user' => 'nullable|string|max:255', 'custom_port' => 'nullable|integer|min:1|max:65535', @@ -250,8 +254,8 @@ public function create_github_app(Request $request) $payload = [ 'uuid' => Str::uuid(), 'name' => $request->input('name'), - 'organization' => $request->input('organization'), - 'api_url' => $request->input('api_url'), + 'organization' => normalizeGithubOrganization($request->input('organization')), + 'api_url' => githubApiUrlFromHtmlUrl($request->input('html_url')), 'html_url' => $request->input('html_url'), 'custom_user' => $request->input('custom_user', 'git'), 'custom_port' => $request->input('custom_port', 22), @@ -587,13 +591,17 @@ public function update_github_app(Request $request, $github_app_id) $payload = $request->only($allowedFields); + if (array_key_exists('organization', $payload)) { + $payload['organization'] = normalizeGithubOrganization($payload['organization']); + } + // Validate the request $rules = []; if (isset($payload['name'])) { $rules['name'] = 'string'; } if (isset($payload['organization'])) { - $rules['organization'] = 'nullable|string'; + $rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/']; } if (isset($payload['api_url'])) { $rules['api_url'] = ['url', new SafeExternalUrl]; @@ -637,6 +645,15 @@ public function update_github_app(Request $request, $github_app_id) ], 422); } + if (array_key_exists('organization', $payload)) { + $payload['organization'] = normalizeGithubOrganization($payload['organization']); + } + if (isset($payload['html_url'])) { + $payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']); + } elseif (isset($payload['api_url'])) { + $payload['api_url'] = githubApiUrlFromHtmlUrl($githubApp->html_url); + } + // Handle private_key_uuid -> private_key_id conversion if (isset($payload['private_key_uuid'])) { $privateKey = PrivateKey::where('team_id', $teamId) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 648bfe6ee..a5479e0fd 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -86,7 +86,7 @@ protected function rules(): array { return [ 'name' => 'required|string', - 'organization' => 'nullable|string', + 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'], 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], 'customUser' => 'required|string', @@ -107,6 +107,11 @@ protected function rules(): array ]; } + public function updatedHtmlUrl(): void + { + $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl); + } + public function boot() { if ($this->github_app) { @@ -123,6 +128,9 @@ private function syncData(bool $toModel = false): void { if ($toModel) { // Sync TO model (before save) + $this->organization = normalizeGithubOrganization($this->organization); + $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl); + $this->github_app->name = $this->name; $this->github_app->organization = $this->organization; $this->github_app->api_url = $this->apiUrl; @@ -296,11 +304,14 @@ public function mount() public function getGithubAppNameUpdatePath() { - if (str($this->github_app->organization)->isNotEmpty()) { - return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}"; + $name = encodeGithubPathSegment($this->github_app->name); + $organization = normalizeGithubOrganization($this->github_app->organization); + + if (filled($organization)) { + return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}"; } - return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}"; + return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}"; } private function generateGithubJwt($private_key, $app_id): string @@ -315,7 +326,7 @@ private function generateGithubJwt($private_key, $app_id): string return $configuration->builder() ->issuedBy((string) $app_id) - ->permittedFor('https://api.github.com') + ->permittedFor($this->github_app->api_url) ->identifiedBy((string) $now) ->issuedAt(new \DateTimeImmutable("@{$now}")) ->expiresAt(new \DateTimeImmutable('@'.($now + 600))) @@ -373,6 +384,8 @@ public function submit() $this->authorize('update', $this->github_app); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->organization = normalizeGithubOrganization($this->organization); + $this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl); $this->validate(); $this->syncData(true); diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index ec2ba3f08..9965fe4b7 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -30,14 +30,22 @@ public function mount() $this->name = substr(generate_random_name(), 0, 30); } + public function updatedHtmlUrl(): void + { + $this->api_url = githubApiUrlFromHtmlUrl($this->html_url); + } + public function createGitHubApp() { try { $this->authorize('createAnyResource'); + $this->organization = normalizeGithubOrganization($this->organization); + $this->api_url = githubApiUrlFromHtmlUrl($this->html_url); + $this->validate([ 'name' => 'required|string', - 'organization' => 'nullable|string', + 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'], 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'custom_user' => 'required|string', diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 0ec76f6fa..476707cec 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -13,6 +13,85 @@ use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token\Builder; +function githubUrlHost(?string $url): ?string +{ + if (blank($url)) { + return null; + } + + $host = parse_url($url, PHP_URL_HOST); + + if (! is_string($host) || blank($host)) { + return null; + } + + return strtolower($host); +} + +function githubUrlOrigin(string $url): string +{ + $scheme = parse_url($url, PHP_URL_SCHEME) ?: 'https'; + $host = githubUrlHost($url); + $port = parse_url($url, PHP_URL_PORT); + + if (! $host) { + return rtrim($url, '/'); + } + + return $scheme.'://'.$host.($port ? ":{$port}" : ''); +} + +function isGithubDotComHost(?string $htmlUrl): bool +{ + return githubUrlHost($htmlUrl) === 'github.com'; +} + +function isGheDotComHost(?string $htmlUrl): bool +{ + $host = githubUrlHost($htmlUrl); + + return is_string($host) + && Str::endsWith($host, '.ghe.com') + && ! Str::startsWith($host, 'api.'); +} + +function isGithubCloudFamilyHost(?string $htmlUrl): bool +{ + return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl); +} + +function isGithubEnterpriseServerHost(?string $htmlUrl): bool +{ + return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl); +} + +function githubApiUrlFromHtmlUrl(string $htmlUrl): string +{ + if (isGithubDotComHost($htmlUrl)) { + return 'https://api.github.com'; + } + + if (isGheDotComHost($htmlUrl)) { + return 'https://api.'.githubUrlHost($htmlUrl); + } + + return githubUrlOrigin($htmlUrl).'/api/v3'; +} + +function normalizeGithubOrganization(?string $organization): ?string +{ + if (blank($organization)) { + return null; + } + + return trim((string) $organization, "/ \t\n\r\0\x0B"); +} + +function encodeGithubPathSegment(string $segment): string +{ + return rawurlencode($segment); +} + function generateGithubToken(GithubApp $source, string $type) { $response = Http::get("{$source->api_url}/zen"); @@ -119,9 +198,17 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m function getInstallationPath(GithubApp $source): string { - $name = str(Str::kebab($source->name)); - $installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps'; + $name = encodeGithubPathSegment(Str::kebab($source->name)); $state = Str::random(64); + $organization = normalizeGithubOrganization($source->organization); + + if (isGithubEnterpriseServerHost($source->html_url)) { + $path = "github-apps/{$name}"; + } elseif (isGheDotComHost($source->html_url) && filled($organization)) { + $path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}"; + } else { + $path = "apps/{$name}"; + } Cache::put('github-app-setup-state:'.hash('sha256', $state), [ 'action' => 'install', @@ -129,15 +216,19 @@ function getInstallationPath(GithubApp $source): string 'team_id' => $source->team_id, ], now()->addMinutes(60)); - return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]); + return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]); } function getPermissionsPath(GithubApp $source) { - $github = GithubApp::where('uuid', $source->uuid)->first(); - $name = str(Str::kebab($github->name)); + $name = encodeGithubPathSegment(Str::kebab($source->name)); + $organization = normalizeGithubOrganization($source->organization); - return "$github->html_url/settings/apps/$name/permissions"; + if (filled($organization)) { + return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions"; + } + + return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions"; } function loadRepositoryByPage(GithubApp $source, string $token, int $page) diff --git a/openapi.json b/openapi.json index ca445ade0..f836baf5b 100644 --- a/openapi.json +++ b/openapi.json @@ -7439,7 +7439,6 @@ "schema": { "required": [ "name", - "api_url", "html_url", "app_id", "installation_id", diff --git a/openapi.yaml b/openapi.yaml index 6182cacd3..ca1bdcf44 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4806,7 +4806,6 @@ paths: schema: required: - name - - api_url - html_url - app_id - installation_id diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index dc9560a1f..051f9809f 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -373,7 +373,8 @@ function createGithubApp(webhook_endpoint, use_custom_webhook_endpoint, custom_w baseUrl = devWebhook; } const webhookBaseUrl = `${baseUrl}/webhooks`; - const path = organization ? `organizations/${organization}/settings/apps/new` : 'settings/apps/new'; + const organizationPath = organization ? encodeURIComponent(organization.replace(/^\/+|\/+$/g, '')) : ''; + const path = organizationPath ? `organizations/${organizationPath}/settings/apps/new` : 'settings/apps/new'; const default_permissions = { contents: 'read', metadata: 'read', diff --git a/tests/Feature/GithubAppsListApiTest.php b/tests/Feature/GithubAppsListApiTest.php index a6ce59dca..781c444d8 100644 --- a/tests/Feature/GithubAppsListApiTest.php +++ b/tests/Feature/GithubAppsListApiTest.php @@ -5,6 +5,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); @@ -26,6 +27,18 @@ ]); }); +function validGithubAppsApiPrivateKey(): string +{ + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($key, $privateKey); + + return $privateKey; +} + describe('GET /api/v1/github-apps', function () { test('returns 401 when not authenticated', function () { $response = $this->getJson('/api/v1/github-apps'); @@ -220,3 +233,129 @@ ]); }); }); + +describe('GitHub app API url normalization', function () { + test('normalizes ghe dot com api url when creating github apps', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson('/api/v1/github-apps', [ + 'name' => 'GHE App', + 'organization' => '/octocorp/', + 'html_url' => 'https://octocorp.ghe.com', + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'webhook_secret' => 'test-webhook-secret', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertCreated() + ->assertJsonFragment([ + 'organization' => 'octocorp', + 'api_url' => 'https://api.octocorp.ghe.com', + 'html_url' => 'https://octocorp.ghe.com', + ]); + }); + + test('normalizes ghe dot com api url when updating github apps', function () { + $githubApp = GithubApp::create([ + 'name' => 'GHE App', + 'api_url' => 'https://github.company.internal/api/v3', + 'html_url' => 'https://github.company.internal', + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'webhook_secret' => 'test-webhook-secret', + 'private_key_id' => $this->privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/github-apps/{$githubApp->id}", [ + 'html_url' => 'https://octocorp.ghe.com', + 'api_url' => 'https://octocorp.ghe.com/api/v3', + ]); + + $response->assertSuccessful() + ->assertJsonPath('data.api_url', 'https://api.octocorp.ghe.com'); + }); + + test('rejects invalid organization when creating github apps', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson('/api/v1/github-apps', [ + 'name' => 'GHE App', + 'organization' => 'octo/corp', + 'api_url' => 'https://api.octocorp.ghe.com', + 'html_url' => 'https://octocorp.ghe.com', + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'webhook_secret' => 'test-webhook-secret', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['organization']); + }); + + test('loads repositories and branches through normalized ghe dot com api url', function () { + $this->privateKey->update([ + 'private_key' => validGithubAppsApiPrivateKey(), + ]); + + $githubApp = GithubApp::create([ + 'name' => 'GHE App', + 'api_url' => 'https://api.octocorp.ghe.com', + 'html_url' => 'https://octocorp.ghe.com', + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'test-client-id', + 'client_secret' => 'test-client-secret', + 'webhook_secret' => 'test-webhook-secret', + 'private_key_id' => $this->privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + Http::preventStrayRequests(); + Http::fake([ + 'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [ + 'Date' => now()->toRfc7231String(), + ]), + 'https://api.octocorp.ghe.com/app/installations/67890/access_tokens' => Http::response([ + 'token' => 'installation-token', + ]), + 'https://api.octocorp.ghe.com/installation/repositories*' => Http::response([ + 'repositories' => [ + ['name' => 'repo', 'full_name' => 'octocorp/repo'], + ], + ]), + 'https://api.octocorp.ghe.com/repos/octocorp/repo/branches' => Http::response([ + ['name' => 'main'], + ]), + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories") + ->assertSuccessful() + ->assertJsonPath('repositories.0.name', 'repo'); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories/octocorp/repo/branches") + ->assertSuccessful() + ->assertJsonPath('branches.0.name', 'main'); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/installation/repositories?per_page=100&page=1'); + Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/repos/octocorp/repo/branches'); + }); +}); diff --git a/tests/Feature/GithubSourceChangeTest.php b/tests/Feature/GithubSourceChangeTest.php index 07bc2a2c3..a66aea8b1 100644 --- a/tests/Feature/GithubSourceChangeTest.php +++ b/tests/Feature/GithubSourceChangeTest.php @@ -147,6 +147,20 @@ function validPrivateKey(): string ]); }); + test('ghe dot com installation path includes encoded organization segment', function () { + $githubApp = new GithubApp; + $githubApp->forceFill([ + 'id' => 123, + 'name' => 'Provided GitHub App', + 'organization' => 'octo+corp', + 'html_url' => 'https://octocorp.ghe.com', + 'team_id' => 456, + ]); + + expect(getInstallationPath($githubApp)) + ->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?'); + }); + test('defaults webhook endpoint to app url when it is the first available endpoint', function () { config(['app.url' => 'http://localhost:8000']); @@ -277,6 +291,49 @@ function validPrivateKey(): string expect($githubApp->private_key_id)->toBe($privateKey->id); }); + test('normalizes ghe dot com api url when saving github app settings', function () { + $githubApp = GithubApp::create([ + 'name' => 'Test GitHub App', + 'api_url' => 'https://octocorp.ghe.com/api/v3', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->set('htmlUrl', 'https://octocorp.ghe.com') + ->set('apiUrl', 'https://octocorp.ghe.com/api/v3') + ->call('submit') + ->assertDispatched('success') + ->assertSet('apiUrl', 'https://api.octocorp.ghe.com'); + + $githubApp->refresh(); + expect($githubApp->api_url)->toBe('https://api.octocorp.ghe.com'); + }); + + test('rejects invalid github organization values', function () { + $githubApp = GithubApp::create([ + 'name' => 'Test GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->set('organization', 'octo/corp') + ->call('submit') + ->assertHasErrors(['organization']); + }); + test('validation allows nullable values for app configuration', function () { $githubApp = GithubApp::create([ 'name' => 'Test GitHub App', @@ -429,4 +486,40 @@ function validPrivateKey(): string ->and($githubApp->metadata)->toBe('read') ->and($githubApp->pull_requests)->toBe('write'); }); + + test('sync name uses normalized ghe dot com api url', function () { + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Test GitHub App', + 'api_url' => 'https://api.octocorp.ghe.com', + 'html_url' => 'https://octocorp.ghe.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Http::preventStrayRequests(); + Http::fake([ + 'https://api.octocorp.ghe.com/app' => Http::response([ + 'slug' => 'octocorp-app', + ]), + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->call('updateGithubAppName') + ->assertDispatched('success'); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app'); + }); }); diff --git a/tests/Feature/Security/GithubAppSetupCallbackTest.php b/tests/Feature/Security/GithubAppSetupCallbackTest.php index f56a77d5f..9e3f8ea81 100644 --- a/tests/Feature/Security/GithubAppSetupCallbackTest.php +++ b/tests/Feature/Security/GithubAppSetupCallbackTest.php @@ -44,7 +44,7 @@ function authenticateGithubSetupCallbackTest(object $test): void session(['currentTeam' => $test->team]); } -function fakeGithubManifestConversion(): void +function fakeGithubManifestConversion(string $apiUrl = 'https://api.github.com'): void { $key = openssl_pkey_new([ 'private_key_bits' => 2048, @@ -54,7 +54,7 @@ function fakeGithubManifestConversion(): void Http::preventStrayRequests(); Http::fake([ - 'https://api.github.com/app-manifests/*/conversions' => Http::response([ + "{$apiUrl}/app-manifests/*/conversions" => Http::response([ 'id' => 987654, 'slug' => 'attacker-controlled-app', 'client_id' => 'new-client-id', @@ -86,14 +86,14 @@ function configureGithubAppCredentials(GithubApp $githubApp): void ])->save(); } -function fakeGithubInstallationVerification(int $appId): void +function fakeGithubInstallationVerification(int $appId, string $apiUrl = 'https://api.github.com'): void { Http::preventStrayRequests(); Http::fake([ - 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [ + "{$apiUrl}/zen" => Http::response('Keep it logically awesome.', 200, [ 'Date' => now()->toRfc7231String(), ]), - 'https://api.github.com/app/installations/*' => Http::response([ + "{$apiUrl}/app/installations/*" => Http::response([ 'id' => 555, 'app_id' => $appId, ], 200), @@ -183,6 +183,21 @@ function fakeGithubInstallationVerificationFailure(): void ->and($this->githubApp->private_key_id)->not->toBeNull(); }); +it('converts ghe dot com app manifests through the data residency api host', function () { + authenticateGithubSetupCallbackTest($this); + $this->githubApp->forceFill([ + 'api_url' => 'https://api.octocorp.ghe.com', + 'html_url' => 'https://octocorp.ghe.com', + ])->save(); + fakeGithubManifestConversion('https://api.octocorp.ghe.com'); + cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp); + + $this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code') + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid])); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app-manifests/real-code/conversions'); +}); + it('rejects replayed github app manifest states', function () { authenticateGithubSetupCallbackTest($this); fakeGithubManifestConversion(); @@ -333,6 +348,23 @@ function fakeGithubInstallationVerificationFailure(): void expect($this->githubApp->installation_id)->toBe(123456); }); +it('verifies ghe dot com installations through the data residency api host', function () { + authenticateGithubSetupCallbackTest($this); + $this->githubApp->forceFill([ + 'api_url' => 'https://api.octocorp.ghe.com', + 'html_url' => 'https://octocorp.ghe.com', + ])->save(); + configureGithubAppCredentials($this->githubApp); + fakeGithubInstallationVerification($this->githubApp->app_id, 'https://api.octocorp.ghe.com'); + cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp); + + $this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456') + ->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid])); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/zen'); + Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app/installations/123456'); +}); + it('rejects replayed github app install states', function () { authenticateGithubSetupCallbackTest($this); configureGithubAppCredentials($this->githubApp); diff --git a/tests/Unit/GithubUrlHelpersTest.php b/tests/Unit/GithubUrlHelpersTest.php new file mode 100644 index 000000000..7c4ee6ff5 --- /dev/null +++ b/tests/Unit/GithubUrlHelpersTest.php @@ -0,0 +1,78 @@ +toBeTrue() + ->and(isGheDotComHost('https://octocorp.ghe.com'))->toBeTrue() + ->and(isGithubCloudFamilyHost('https://octocorp.ghe.com'))->toBeTrue() + ->and(isGithubCloudFamilyHost('https://github.com'))->toBeTrue() + ->and(isGithubEnterpriseServerHost('https://github.company.internal'))->toBeTrue() + ->and(isGithubEnterpriseServerHost('https://octocorp.ghe.com'))->toBeFalse(); +}); + +it('derives github api urls from html urls', function (string $htmlUrl, string $apiUrl) { + expect(githubApiUrlFromHtmlUrl($htmlUrl))->toBe($apiUrl); +})->with([ + 'github.com' => ['https://github.com', 'https://api.github.com'], + 'ghe.com data residency' => ['https://octocorp.ghe.com', 'https://api.octocorp.ghe.com'], + 'github enterprise server' => ['https://github.company.internal', 'https://github.company.internal/api/v3'], +]); + +it('generates correct install paths for github cloud ghe cloud and ghes', function (array $attributes, string $expectedPrefix) { + $githubApp = new GithubApp; + $githubApp->forceFill(array_merge([ + 'id' => 123, + 'name' => 'Coolify Test App', + 'team_id' => 456, + ], $attributes)); + + $installationUrl = getInstallationPath($githubApp); + parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query); + $state = $query['state'] ?? null; + + expect($installationUrl)->toStartWith($expectedPrefix) + ->and($state)->not->toBeEmpty() + ->and(Cache::get('github-app-setup-state:'.hash('sha256', $state))) + ->toMatchArray([ + 'action' => 'install', + 'github_app_id' => 123, + 'team_id' => 456, + ]); +})->with([ + 'github.com' => [ + ['html_url' => 'https://github.com'], + 'https://github.com/apps/coolify-test-app/installations/new?', + ], + 'ghe.com organization' => [ + ['html_url' => 'https://octocorp.ghe.com', 'organization' => 'octo-corp'], + 'https://octocorp.ghe.com/apps/octo-corp/coolify-test-app/installations/new?', + ], + 'ghe.com blank organization fallback' => [ + ['html_url' => 'https://octocorp.ghe.com', 'organization' => null], + 'https://octocorp.ghe.com/apps/coolify-test-app/installations/new?', + ], + 'github enterprise server' => [ + ['html_url' => 'https://github.company.internal', 'organization' => 'octo-corp'], + 'https://github.company.internal/github-apps/coolify-test-app/installations/new?', + ], +]); + +it('encodes organization path segments in settings links', function () { + $githubApp = new GithubApp; + $githubApp->forceFill([ + 'name' => 'coolify-app', + 'organization' => 'octo+corp', + 'api_url' => 'https://api.octocorp.ghe.com', + 'html_url' => 'https://octocorp.ghe.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => 123, + ]); + + expect(getPermissionsPath($githubApp))->toBe('https://octocorp.ghe.com/organizations/octo%2Bcorp/settings/apps/coolify-app/permissions'); +});