mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 04:15:55 +00:00
fix(github): improve GitHub App setup and installation flow
- resolve the GitHub App by a stable identifier during installation callbacks so installing and re-installing keeps working over the full lifetime of the App - verify the installation id received from the callback against the GitHub API before persisting it - support re-installing an already configured GitHub App instead of blocking it - require an authenticated session and rate limit the setup callback routes - extend manifest setup state validity to match GitHub's manifest code lifetime Adds feature coverage for the GitHub App setup and installation callbacks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fcd63f40eb
commit
5a7408a919
5 files changed
+413
-45
No files matched your search
@@ -0,0 +1,257 @@
|
||||
<?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\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->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,
|
||||
]);
|
||||
});
|
||||
|
||||
function cacheGithubAppSetupState(string $state, string $action, GithubApp $githubApp): void
|
||||
{
|
||||
Cache::put('github-app-setup-state:'.hash('sha256', $state), [
|
||||
'action' => $action,
|
||||
'github_app_id' => $githubApp->id,
|
||||
'team_id' => $githubApp->team_id,
|
||||
], now()->addMinutes(15));
|
||||
}
|
||||
|
||||
function authenticateGithubSetupCallbackTest(object $test): void
|
||||
{
|
||||
$test->actingAs($test->user);
|
||||
session(['currentTeam' => $test->team]);
|
||||
}
|
||||
|
||||
function fakeGithubManifestConversion(): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/app-manifests/*/conversions' => Http::response([
|
||||
'id' => 987654,
|
||||
'slug' => 'attacker-controlled-app',
|
||||
'client_id' => 'new-client-id',
|
||||
'client_secret' => 'new-client-secret',
|
||||
'pem' => $privateKey,
|
||||
'webhook_secret' => 'new-webhook-secret',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
function configureGithubAppCredentials(GithubApp $githubApp): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
$privateKeyModel = PrivateKey::create([
|
||||
'name' => 'github-app-test-key',
|
||||
'private_key' => $privateKey,
|
||||
'team_id' => $githubApp->team_id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
|
||||
$githubApp->forceFill([
|
||||
'app_id' => 123456,
|
||||
'private_key_id' => $privateKeyModel->id,
|
||||
])->save();
|
||||
}
|
||||
|
||||
function fakeGithubInstallationVerification(int $appId): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/*' => Http::response([
|
||||
'id' => 555,
|
||||
'app_id' => $appId,
|
||||
], 200),
|
||||
]);
|
||||
}
|
||||
|
||||
function fakeGithubInstallationVerificationFailure(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/*' => Http::response(['message' => 'Not Found'], 404),
|
||||
]);
|
||||
}
|
||||
|
||||
it('requires authentication before processing github app manifest callbacks', function () {
|
||||
fakeGithubManifestConversion();
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
|
||||
->assertRedirect();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->app_id)->toBeNull()
|
||||
->and($this->githubApp->client_id)->toBeNull()
|
||||
->and($this->githubApp->webhook_secret)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects github app manifest callbacks with invalid state without calling github', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state='.$this->githubApp->uuid.'&code=attacker-code')
|
||||
->assertNotFound();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->app_id)->toBeNull()
|
||||
->and($this->githubApp->client_id)->toBeNull()
|
||||
->and($this->githubApp->webhook_secret)->toBeNull();
|
||||
});
|
||||
|
||||
it('blocks rebinding an already configured github app through manifest callback', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
|
||||
$this->githubApp->forceFill([
|
||||
'app_id' => 123456,
|
||||
'client_id' => 'existing-client-id',
|
||||
'client_secret' => 'existing-client-secret',
|
||||
'webhook_secret' => 'existing-webhook-secret',
|
||||
])->save();
|
||||
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=attacker-code')
|
||||
->assertForbidden();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->app_id)->toBe(123456)
|
||||
->and($this->githubApp->client_id)->toBe('existing-client-id')
|
||||
->and($this->githubApp->webhook_secret)->toBe('existing-webhook-secret');
|
||||
});
|
||||
|
||||
it('configures an unbound github app with a valid one-time manifest state', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
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::assertSentCount(1);
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->name)->toBe('attacker-controlled-app')
|
||||
->and($this->githubApp->app_id)->toBe(987654)
|
||||
->and($this->githubApp->client_id)->toBe('new-client-id')
|
||||
->and($this->githubApp->webhook_secret)->toBe('new-webhook-secret')
|
||||
->and($this->githubApp->private_key_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects replayed github app manifest states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
|
||||
->assertRedirect();
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
|
||||
->assertNotFound();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('requires authentication before processing github app install callbacks', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
|
||||
->assertRedirect();
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->installation_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects github app install callbacks for an unknown github app', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source=does-not-exist&setup_action=install&installation_id=123456')
|
||||
->assertNotFound();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('rejects an installation id that github does not confirm belongs to the app', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
fakeGithubInstallationVerificationFailure();
|
||||
|
||||
$this->withHeader('Accept', 'application/json')->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=999999')
|
||||
->assertForbidden();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->installation_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('sets installation id when github confirms it belongs to the app', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
fakeGithubInstallationVerification($this->githubApp->app_id);
|
||||
|
||||
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=123456')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->installation_id)->toBe(123456);
|
||||
});
|
||||
|
||||
it('allows reinstalling an already configured github app installation id', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
$this->githubApp->forceFill(['installation_id' => 111111])->save();
|
||||
fakeGithubInstallationVerification($this->githubApp->app_id);
|
||||
|
||||
$this->get('/webhooks/source/github/install?source='.$this->githubApp->uuid.'&setup_action=install&installation_id=222222')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->installation_id)->toBe(222222);
|
||||
});
|
||||
Reference in new issue
Block a user