Files
coolify/app/Http/Controllers/Webhook/Github.php
T
Andras Bacsai b7a2c75739 fix(github): check clock skew only from failed GitHub App responses
Stop calling /zen before each installation token request. A short
network problem to GitHub failed deployments with a cURL timeout on
/zen. assertGithubClockInSync now takes the failed response and reads
its Date header, so the "System time is out of sync" error still shows
when GitHub rejects the JWT.

Call it on failed JWT requests in the webhook installation check, the
permission job, the source connection test, and the server transfer
checklist. Remove the /zen fakes from the tests.
2026-11-03 19:29:45 +01:00

915 lines
44 KiB
PHP

<?php
namespace App\Http\Controllers\Webhook;
use App\Enums\GithubRunnerStatus;
use App\Exceptions\InvalidWebhookPayloadException;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\PrivateKey;
use App\Services\GithubRunner\GithubRunnerApi;
use App\Services\GithubRunner\GithubRunnerContainer;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class Github extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
use ReadsWebhookPushPayload;
public function manual(Request $request)
{
try {
$return_payloads = collect([]);
$x_github_delivery = request()->header('X-GitHub-Delivery');
$x_github_event = Str::lower((string) $request->header('X-GitHub-Event'));
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
$content_type = $request->header('Content-Type');
$payload = $request->collect();
if ($x_github_event === 'ping') {
// Just pong
return response('pong');
}
if ($content_type !== 'application/json') {
$form_payload = data_get($payload, 'payload');
$payload = is_string($form_payload) ? json_decode($form_payload, true) : null;
}
if ($x_github_event === 'push') {
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
$full_name = data_get($payload, 'repository.full_name');
$commit = $this->webhookCommitSha($payload, 'after');
$changed_files = $this->webhookPushChangedFiles($payload);
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
}
if ($x_github_event === 'pull_request') {
[
'action' => $action,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'pull_request_title' => $pull_request_title,
'branch' => $branch,
'base_branch' => $base_branch,
'before_sha' => $before_sha,
'after_sha' => $after_sha,
'commit_sha' => $commit_sha,
'is_fork_pull_request' => $is_fork_pull_request,
] = $this->readPullRequestPayload($payload);
$full_name = data_get($payload, 'repository.full_name');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) {
return response('Nothing to do. No branch found in the request.');
}
// A deleted branch has no commit to deploy. No secret is checked here.
if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) {
return response('Nothing to do. Branch deleted.');
}
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$matched_branch = match (true) {
$x_github_event === 'push' => $branch,
$action === 'closed' => null,
default => $base_branch,
};
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'github', $full_name, $matched_branch);
// A redelivery of the same signed payload is one guess.
$failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_hub_signature_256);
$applications = Application::query();
if ($x_github_event === 'push' || $action !== 'closed') {
$applications->where('git_branch', $matched_branch);
}
$applications = $this->manualWebhookApplications($applications, $full_name);
if ($applications->isEmpty()) {
return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt);
}
$applicationsByServer = $applications->groupBy(function ($app) {
return $app->destination->server_id;
});
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac)) {
auditLogWebhookFailure('github', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Server is not functional.',
]);
continue;
}
if ($x_github_event === 'push') {
if ($application->isDeployable()) {
if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: $commit ?? 'HEAD',
is_webhook: true,
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => $commit,
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Deployment queued.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
]);
}
} else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([
'status' => 'failed',
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]);
}
} else {
$return_payloads->push([
'status' => 'failed',
'message' => 'Deployments disabled.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
}
}
if ($x_github_event === 'pull_request') {
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
if (! $application->isPRDeployable() && $action !== 'closed') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
continue;
}
ProcessGithubPullRequestWebhook::dispatch(
applicationId: $application->id,
githubAppId: null,
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
pullRequestTitle: $pull_request_title,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: $commit_sha,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request,
);
$return_payloads->push([
'application' => $application->name,
'status' => 'queued',
'message' => 'PR webhook received, processing queued.',
]);
}
}
}
return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt);
} catch (InvalidWebhookPayloadException $e) {
return response($e->getMessage());
} catch (Exception $e) {
return handleError($e);
}
}
/**
* Cuts a webhook string to the varchar(255) column of the runner tables.
*/
private function limitRunnerColumn(mixed $value): ?string
{
return is_scalar($value) ? mb_substr((string) $value, 0, 255) : null;
}
/**
* Handles GitHub Actions runner demand. Every lookup is scoped to the App whose signature was verified.
* GitHub can give a job to any idle runner with matching labels, so progress is matched by runner name.
*/
private function handleWorkflowJob(GithubApp $githubApp, Collection $payload)
{
$action = data_get($payload, 'action');
$jobId = (int) data_get($payload, 'workflow_job.id', 0);
$runnerName = (string) data_get($payload, 'workflow_job.runner_name', '');
if ($jobId <= 0) {
return response('Nothing to do. No workflow job found.');
}
// A public App can be installed by other accounts; only its own installation may use the runners.
$installationId = data_get($payload, 'installation.id');
if (blank($githubApp->installation_id) || ! is_numeric($installationId) || (int) $installationId !== (int) $githubApp->installation_id) {
return response('Nothing to do. The job belongs to another installation of the GitHub App.');
}
// The columns are varchar(255). A cut URL would be a broken link, so a longer one is not stored.
$htmlUrl = data_get($payload, 'workflow_job.html_url');
$jobDetails = array_filter([
'workflow_job_id' => $jobId,
'workflow_job_html_url' => is_string($htmlUrl) && mb_strlen($htmlUrl) <= 255 ? $htmlUrl : null,
'workflow_name' => $this->limitRunnerColumn(data_get($payload, 'workflow_job.workflow_name')),
'job_name' => $this->limitRunnerColumn(data_get($payload, 'workflow_job.name')),
'repository_full_name' => $this->limitRunnerColumn(data_get($payload, 'repository.full_name')),
], fn ($value) => filled($value));
if ($action === 'queued') {
$labels = array_values(array_filter((array) data_get($payload, 'workflow_job.labels', []), 'is_string'));
$matchingConfigs = $githubApp->runnerConfigs()
->where('is_enabled', true)
->get()
->filter(fn (GithubRunnerConfig $config) => $config->matchesLabels($labels));
if ($matchingConfigs->isEmpty()) {
return response('Nothing to do. No runner configuration matches the job labels.');
}
$isPullRequest = $matchingConfigs->contains(fn (GithubRunnerConfig $config) => ! $config->allow_pull_requests)
&& $this->isPullRequestWorkflowJob($githubApp, $payload);
if ($isPullRequest && ! $matchingConfigs->contains('allow_pull_requests', true)) {
return response('Nothing to do. The runner configuration does not allow pull request jobs.');
}
$execution = GithubRunnerExecution::createOrFirst(
['github_app_id' => $githubApp->id, 'trigger_workflow_job_id' => $jobId],
[...$jobDetails, 'labels' => $labels, 'is_pull_request' => $isPullRequest, 'status' => GithubRunnerStatus::Queued, 'queued_at' => now()],
);
if ($execution->wasRecentlyCreated) {
ProvisionGithubRunnerJob::dispatch($execution->id);
}
return response('Runner queued.');
}
$execution = filled($runnerName)
? GithubRunnerExecution::query()->where('github_app_id', $githubApp->id)->where('runner_name', $runnerName)->first()
: null;
// The completed hook can arrive before the in_progress hook.
if (in_array($action, ['in_progress', 'completed'], true) && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
$replacement = $execution->assignJob($jobId, $jobDetails);
if ($replacement) {
ProvisionGithubRunnerJob::dispatch($replacement->id);
}
if ($action === 'in_progress') {
return response('Runner marked running.');
}
}
if ($action === 'completed') {
if ($execution) {
$execution->update([...$jobDetails, 'conclusion' => $this->limitRunnerColumn(data_get($payload, 'workflow_job.conclusion'))]);
if ($execution->isActive() || $execution->status === GithubRunnerStatus::Failed) {
$execution->finish(GithubRunnerStatus::Completed);
CleanupGithubRunnerJob::dispatch($execution->id);
}
return response('Runner cleanup queued.');
}
// The job ended without a Coolify runner, so its queued runner is no longer needed.
GithubRunnerExecution::query()
->where('github_app_id', $githubApp->id)
->where('trigger_workflow_job_id', $jobId)
->where('status', GithubRunnerStatus::Queued)
->first()
?->finish(GithubRunnerStatus::Cancelled, 'The job ended before a runner started.');
return response('Queued runner cancelled.');
}
return response('Nothing to do.');
}
/**
* Whether a queued job belongs to a workflow run started by a pull request, so no runner is started
* for a job that the job-started hook would refuse. The `workflow_job` payload has no event, so the
* run is read from GitHub. When that fails, the job counts as no pull request job and the hook still
* refuses it in the runner.
*/
private function isPullRequestWorkflowJob(GithubApp $githubApp, Collection $payload): bool
{
$runId = (int) data_get($payload, 'workflow_job.run_id', 0);
$repository = (string) data_get($payload, 'repository.full_name', '');
if ($runId <= 0 || ! str_contains($repository, '/')) {
return false;
}
try {
$event = (new GithubRunnerApi($githubApp))->workflowRunEvent($repository, $runId);
} catch (\Throwable) {
return false;
}
return in_array($event, GithubRunnerContainer::PULL_REQUEST_EVENTS, true);
}
public function normal(Request $request)
{
try {
$return_payloads = collect([]);
$id = null;
$x_github_delivery = $request->header('X-GitHub-Delivery');
$x_github_event = Str::lower((string) $request->header('X-GitHub-Event'));
$x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id');
$x_hub_signature_256 = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256=');
$payload = $request->collect();
if ($x_github_event === 'ping') {
// Just pong
return response('pong');
}
// One GitHub App can be installed on several accounts, each added as its own source.
$installation_id = data_get($payload, 'installation.id');
$github_app = is_numeric($installation_id)
? GithubApp::where('app_id', $x_github_hook_installation_target_id)->where('installation_id', (int) $installation_id)->first()
: null;
$github_app ??= GithubApp::where('app_id', $x_github_hook_installation_target_id)->first();
if (is_null($github_app)) {
return response('Nothing to do. No GitHub App found.');
}
$webhook_secret = data_get($github_app, 'webhook_secret');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac)) {
auditLogWebhookFailure('github', 'invalid_signature', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
TransferFinishChecklist::recordWebhook('github_app', $github_app->uuid);
if ($x_github_event === 'installation' || $x_github_event === 'installation_repositories') {
// Installation handled by setup redirect url. Repositories queried on-demand.
$action = data_get($payload, 'action');
if ($action === 'new_permissions_accepted') {
GithubAppPermissionJob::dispatch($github_app);
}
return response('cool');
}
if ($x_github_event === 'workflow_job') {
return $this->handleWorkflowJob($github_app, $payload);
}
if ($x_github_event === 'push') {
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
$commit = $this->webhookCommitSha($payload, 'after');
$changed_files = $this->webhookPushChangedFiles($payload);
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
}
if ($x_github_event === 'pull_request') {
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
[
'action' => $action,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'pull_request_title' => $pull_request_title,
'branch' => $branch,
'base_branch' => $base_branch,
'before_sha' => $before_sha,
'after_sha' => $after_sha,
'commit_sha' => $commit_sha,
'is_fork_pull_request' => $is_fork_pull_request,
] = $this->readPullRequestPayload($payload);
$full_name = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $id || ! $branch || ($x_github_event === 'pull_request' && $action !== 'closed' && ! $base_branch)) {
return response('Nothing to do. No id or branch found.');
}
if ($x_github_event === 'push' && $this->isWebhookBranchDeletionPush($payload)) {
return response('Nothing to do. Branch deleted.');
}
$applications = Application::where('repository_project_id', $id)
->where('source_id', $github_app->id)
->where('source_type', GithubApp::class)
->whereRelation('source', 'is_public', false);
if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$branch'.");
}
if ($applications->contains(fn (Application $application): bool => filled($application->watch_paths))) {
$changed_files = $this->githubPushComparedFiles($github_app, $payload) ?? $changed_files;
}
}
if ($x_github_event === 'pull_request') {
if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $applications->get();
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}
}
$applicationsByServer = $applications->groupBy(function ($app) {
return $app->destination->server_id;
});
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Server is not functional.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
if ($x_github_event === 'push') {
if ($application->isDeployable()) {
if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
commit: $commit ?? 'HEAD',
force_rebuild: false,
is_webhook: true,
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'app',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => $commit,
'github_app_id' => $github_app->id,
]);
}
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'] ?? null,
]);
} else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([
'status' => 'failed',
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]);
}
} else {
$return_payloads->push([
'status' => 'failed',
'message' => 'Deployments disabled.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
}
}
if ($x_github_event === 'pull_request') {
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
if (! $application->isPRDeployable() && $action !== 'closed') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
continue;
}
ProcessGithubPullRequestWebhook::dispatch(
applicationId: $application->id,
githubAppId: $github_app->id,
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
pullRequestTitle: $pull_request_title,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: $commit_sha,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request,
);
$return_payloads->push([
'application' => $application->name,
'status' => 'queued',
'message' => 'PR webhook received, processing queued.',
]);
}
}
}
return response($return_payloads);
} catch (InvalidWebhookPayloadException $e) {
return response($e->getMessage());
} catch (Exception $e) {
return handleError($e);
}
}
/**
* Files that the push changes between its before and after commits, from
* the GitHub compare API.
*
* The file lists of the pushed commits are not the net change: a merge
* commit names the files that it changes relative to its first parent,
* also when the push does not change them. Returns null for a new branch,
* a missing repository name or an API failure; the caller then uses the
* file lists of the pushed commits.
*
* @return Collection<int, string>|null
*
* @throws InvalidWebhookPayloadException When the before or after value is not a commit SHA.
*/
private function githubPushComparedFiles(GithubApp $githubApp, mixed $payload): ?Collection
{
$before = $this->webhookCommitSha($payload, 'before');
$after = $this->webhookCommitSha($payload, 'after');
$fullName = $this->manualWebhookRepositoryFullName(data_get($payload, 'repository.full_name'));
if ($before === null || $after === null || $fullName === null || preg_match('/\A0+\z/', $before) === 1) {
return null;
}
[$owner, $repo] = explode('/', $fullName, 2);
$files = getGithubCommitRangeFiles($githubApp, $owner, $repo, $before, $after);
return $files === null ? null : collect($files);
}
/**
* Read and validate the pull_request payload fields that the handlers use.
*
* @return array{action: string, pull_request_id: int, pull_request_html_url: string, pull_request_title: ?string, branch: ?string, base_branch: ?string, before_sha: ?string, after_sha: ?string, commit_sha: string, is_fork_pull_request: bool}
*
* @throws InvalidWebhookPayloadException When a field has a wrong type or format.
*/
private function readPullRequestPayload(mixed $payload): array
{
$head_sha = $this->webhookCommitSha($payload, 'pull_request.head.sha');
return [
'action' => $this->webhookPayloadString($payload, 'action', required: true),
'pull_request_id' => $this->webhookPullRequestId($payload, 'number'),
'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'pull_request.html_url', required: true),
'pull_request_title' => $this->webhookPayloadString($payload, 'pull_request.title'),
'branch' => $this->webhookString(data_get($payload, 'pull_request.head.ref')),
'base_branch' => $this->webhookString(data_get($payload, 'pull_request.base.ref')),
'before_sha' => $this->webhookCommitSha($payload, 'before'),
'after_sha' => $this->webhookCommitSha($payload, 'after') ?? $head_sha,
'commit_sha' => $head_sha ?? 'HEAD',
'is_fork_pull_request' => $this->isForkPullRequest($payload),
];
}
/**
* Determine whether a pull_request webhook payload originates from a fork.
*
* A same-repository PR branch can only be pushed by users with write access,
* so this is the trust signal for preview deployments. GitHub's
* `author_association` is not reliable for this: users with access through a
* team or a private organization membership arrive as CONTRIBUTOR or NONE.
*
* The repository id comparison is the canonical signal; the `head.repo.fork`
* flag and a case-insensitive full_name comparison are fallbacks for payloads
* where the ids are unavailable. A PR whose head repository is unknown (e.g.
* a deleted fork) is treated as a fork.
*
* @throws InvalidWebhookPayloadException When a repository id is not a positive integer.
*/
private function isForkPullRequest(mixed $payload): bool
{
$headRepoId = $this->webhookPayloadId($payload, 'pull_request.head.repo.id');
$baseRepoId = $this->webhookPayloadId($payload, 'pull_request.base.repo.id');
if ($headRepoId !== null && $baseRepoId !== null) {
return $headRepoId !== $baseRepoId;
}
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
return true;
}
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
}
return true;
}
public function redirect(Request $request)
{
$code = (string) $request->query('code', '');
abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'manifest',
);
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
$api_url = data_get($github_app, 'api_url');
$data = Http::GitSource($api_url)->withBody(null)
->accept('application/vnd.github+json')
->timeout(10)
->post("$api_url/app-manifests/$code/conversions")
->throw()
->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
public function install(Request $request)
{
$setup_action = (string) $request->query('setup_action', '');
abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
$installation_id = (string) $request->query('installation_id', '');
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
if ($setup_action === 'update') {
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'install',
);
$verification_error = $this->githubInstallationVerificationError($github_app, $installation_id);
if ($verification_error) {
Log::warning('GitHub App installation could not be verified.', [
'github_app_id' => $github_app->id,
'installation_id' => $installation_id,
'reason' => $verification_error,
]);
$message = "GitHub App installation could not be verified: {$verification_error}";
abort_if($request->expectsJson(), 403, $message);
return redirect()
->route('source.github.show', ['github_app_uuid' => $github_app->uuid])
->with('error', $message);
}
$github_app->installation_id = $installation_id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
{
$github_app = GithubApp::ownedByCurrentTeam()
->where('installation_id', $installation_id)
->first();
if ($github_app) {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
return redirect()->route('source.all');
}
/**
* Verify that the given installation id actually belongs to this GitHub App.
*
* The installation id arrives as an untrusted query parameter on an
* unauthenticated-reachable GET callback, so it must be confirmed against
* the GitHub API using the App's own credentials before it is persisted.
*
* @return string|null The reason the verification failed, or null when it passed.
*/
private function githubInstallationVerificationError(GithubApp $github_app, string $installation_id): ?string
{
if (blank($github_app->app_id) || blank(githubAppPrivateKey($github_app)?->private_key)) {
return 'The GitHub App ID or private key is missing.';
}
try {
$jwt = generateGithubJwt($github_app);
$response = Http::GitSource($github_app->api_url)->withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github+json',
])
->timeout(10)
->get("{$github_app->api_url}/app/installations/{$installation_id}");
if (! $response->successful()) {
assertGithubClockInSync($response);
}
} catch (\Throwable $e) {
return strip_tags(str_replace('<br>', ' ', $e->getMessage()));
}
if (! $response->successful()) {
return "GitHub API returned HTTP {$response->status()}: ".data_get($response->json(), 'message', 'no error message found');
}
if ((string) data_get($response->json(), 'app_id') !== (string) $github_app->app_id) {
return 'The installation belongs to a different GitHub App.';
}
return null;
}
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
{
if (blank($state)) {
$this->rejectInvalidGithubAppSetupState($request);
}
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
$this->rejectInvalidGithubAppSetupState($request);
}
// The state is bound to the team that started the setup. The user may have switched
// teams while on GitHub, so check membership of that team, not the session team.
$team_id = (int) data_get($payload, 'team_id');
abort_unless((bool) $request->user()?->teams->contains('id', $team_id), 403);
$github_app = GithubApp::whereKey(data_get($payload, 'github_app_id'))
->where('team_id', $team_id)
->firstOrFail();
if ($request->user()->currentTeam()?->id !== $team_id) {
refreshSession($github_app->team);
}
return $github_app;
}
private function rejectInvalidGithubAppSetupState(Request $request): never
{
if ($request->expectsJson()) {
abort(404);
}
throw new HttpResponseException(
redirect()
->route('source.all')
);
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
}
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
{
return filled($github_app->app_id)
|| filled($github_app->client_id)
|| filled($github_app->client_secret)
|| filled($github_app->webhook_secret)
|| filled($github_app->private_key_id);
}
}