fix(github): derive API URLs from GitHub HTML hosts (#10610)

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-07-07 12:29:15 +02:00
commit 2caa5e67ff
12 files changed
+681 -46

No files matched your search

+206 -4
View File
@@ -1,31 +1,56 @@
<?php
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
$this->token = $this->user->createToken('test-token');
$this->bearerToken = $this->token->plainTextToken;
// Create a private key for the team
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $this->team->id,
]);
});
/**
* Generate a temporary 2048-bit RSA private key for GitHub Apps API tests.
*
* Generated in-process so tests do not depend on external files or secrets;
* the key is used only as a signing fixture and is never persisted.
*
* @return string PEM-encoded RSA private key
*/
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');
@@ -118,7 +143,8 @@ describe('GET /api/v1/github-apps', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherToken = $otherUser->createToken('other-token', ['*'], $otherTeam->id);
session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-token');
// System-wide apps should be visible to other teams
$response = $this->withHeaders([
@@ -152,7 +178,7 @@ describe('GET /api/v1/github-apps', function () {
$otherTeam = Team::factory()->create();
$otherPrivateKey = PrivateKey::create([
'name' => 'Other Key',
'private_key' => 'other-key',
'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $otherTeam->id,
]);
GithubApp::create([
@@ -220,3 +246,179 @@ describe('GET /api/v1/github-apps', function () {
]);
});
});
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://github.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.github.ghe.com',
'html_url' => 'https://github.ghe.com',
]);
});
test('preserves provided api url when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => '/octocorp/',
'api_url' => 'https://github.com/api/v3',
'html_url' => 'https://github.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://github.com/api/v3',
'html_url' => 'https://github.com',
]);
});
test('preserves provided 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://github.com',
'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
test('preserves provided api url when updating api url only', function () {
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.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,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
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');
});
});
@@ -251,6 +251,21 @@ describe('GitHub Source Change Component', function () {
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?');
});
test('ghe.com installation path encodes plus signs in the 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,
]);
$installationUrl = getInstallationPath($githubApp);
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
});
test('ghe.com installation path keeps app scoped fallback when organization is blank', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
@@ -404,6 +419,49 @@ describe('GitHub Source Change Component', function () {
expect($githubApp->private_key_id)->toBe($privateKey->id);
});
test('preserves custom ghe api url when saving github app settings', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://github.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://github.ghe.com')
->set('apiUrl', 'https://github.ghe.com/api/v3')
->call('submit')
->assertDispatched('success')
->assertSet('apiUrl', 'https://github.ghe.com/api/v3');
$githubApp->refresh();
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
});
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',
@@ -556,4 +614,43 @@ describe('GitHub Source Change Component', function () {
->and($githubApp->metadata)->toBe('read')
->and($githubApp->pull_requests)->toBe('write');
});
test('sync name uses normalized resolvable 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.github.ghe.com',
'html_url' => 'https://github.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.github.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.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.github.ghe.com/app');
});
});
@@ -72,8 +72,8 @@ describe('GitHub Source Create Component', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'enterprise-app')
->set('api_url', 'https://github.enterprise.com/api/v3')
->set('html_url', 'https://github.enterprise.com')
->set('api_url', 'https://github.ghe.com/api/v3')
->set('html_url', 'https://github.ghe.com')
->set('custom_user', 'git-custom')
->set('custom_port', 2222)
->call('createGitHubApp')
@@ -82,12 +82,28 @@ describe('GitHub Source Create Component', function () {
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.enterprise.com');
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.ghe.com');
expect($githubApp->custom_user)->toBe('git-custom');
expect($githubApp->custom_port)->toBe(2222);
});
test('preserves custom github enterprise api url when creating github app', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'ghe-custom-api-app')
->set('api_url', 'https://github.ghe.com/api/v3')
->set('html_url', 'https://github.ghe.com')
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'ghe-custom-api-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.ghe.com');
});
test('validates required fields', function () {
Livewire::test(Create::class)
->assertSuccessful()
@@ -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 @@ it('configures an unbound github app with a valid one-time manifest state', func
->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 @@ it('sets installation id when github confirms it belongs to the app', function (
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);
+86
View File
@@ -0,0 +1,86 @@
<?php
use App\Models\GithubApp;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class);
it('classifies github hosts', function () {
expect(isGithubDotComHost('https://github.com'))->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('rejects malformed urls when deriving an origin', function (string $url) {
expect(fn () => githubUrlOrigin($url))->toThrow(InvalidArgumentException::class);
})->with([
'blank' => [''],
'scheme-less' => ['github.company.internal'],
'malformed' => ['not-a-url'],
]);
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');
});