From c2a3333df4472eee8ca95dc2a7294ba1695d8956 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:56:50 +0200 Subject: [PATCH] fix(runners): limit stored workflow and job names to the column length --- app/Http/Controllers/Webhook/Github.php | 20 +++- tests/Feature/GithubRunnerLongNamesTest.php | 118 ++++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/GithubRunnerLongNamesTest.php diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 8f44152306..009b11f299 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -260,6 +260,14 @@ class Github extends Controller } } + /** + * 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. @@ -278,12 +286,14 @@ class Github extends Controller 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' => data_get($payload, 'workflow_job.html_url'), - 'workflow_name' => data_get($payload, 'workflow_job.workflow_name'), - 'job_name' => data_get($payload, 'workflow_job.name'), - 'repository_full_name' => data_get($payload, 'repository.full_name'), + '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') { @@ -329,7 +339,7 @@ class Github extends Controller if ($action === 'completed') { if ($execution) { - $execution->update([...$jobDetails, 'conclusion' => data_get($payload, 'workflow_job.conclusion')]); + $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); diff --git a/tests/Feature/GithubRunnerLongNamesTest.php b/tests/Feature/GithubRunnerLongNamesTest.php new file mode 100644 index 0000000000..6bae1a2e8e --- /dev/null +++ b/tests/Feature/GithubRunnerLongNamesTest.php @@ -0,0 +1,118 @@ + 0]); + Server::flushIdentityMap(); + Queue::fake(); + + $team = Team::factory()->create(); + openssl_pkey_export(openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]), $pemKey); + $privateKey = PrivateKey::create(['name' => 'Runner key', 'private_key' => $pemKey, 'team_id' => $team->id]); + $this->longNamesApp = GithubApp::create([ + 'name' => 'runner-app', + 'organization' => 'acme', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 2222, + 'installation_id' => 2223, + 'webhook_secret' => 'runner-secret', + 'private_key_id' => $privateKey->id, + 'team_id' => $team->id, + 'is_system_wide' => false, + ]); + $server = Server::factory()->create(['team_id' => $team->id]); + GithubRunnerConfig::create([ + 'server_id' => $server->id, + 'github_app_id' => $this->longNamesApp->id, + 'labels' => ['coolify'], + 'max_runners' => 2, + 'docker_mode' => 'dind', + 'allow_pull_requests' => true, + ]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function sendLongNamesWorkflowJobWebhook($test, GithubApp $githubApp, string $action, array $job) +{ + $body = json_encode([ + 'action' => $action, + 'workflow_job' => [ + 'id' => 5001, + 'labels' => ['self-hosted', 'coolify'], + 'html_url' => 'https://github.com/acme/api/actions/runs/1/job/5001', + 'workflow_name' => str_repeat('w', 300), + 'name' => str_repeat('ö', 300), + 'runner_name' => null, + ...$job, + ], + 'repository' => ['id' => 99, 'full_name' => 'acme/'.str_repeat('r', 300)], + 'organization' => ['login' => 'acme'], + 'installation' => ['id' => $githubApp->installation_id], + ], JSON_THROW_ON_ERROR); + + return $test->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'workflow_job', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => (string) $githubApp->app_id, + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'runner-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $body); +} + +/** + * SQLite does not enforce varchar(255), so the tests assert the stored lengths that Postgres would reject. + */ +it('stores over-long workflow, job, and repository names of a queued job truncated to the column length', function () { + sendLongNamesWorkflowJobWebhook($this, $this->longNamesApp, 'queued', [ + 'html_url' => 'https://github.com/acme/api/actions/runs/1/job/'.str_repeat('1', 300), + ])->assertOk()->assertSee('Runner queued.'); + + $execution = GithubRunnerExecution::query()->sole(); + + expect(mb_strlen($execution->workflow_name))->toBe(255) + ->and($execution->workflow_name)->toBe(str_repeat('w', 255)) + ->and($execution->job_name)->toBe(str_repeat('ö', 255)) + ->and(mb_strlen($execution->repository_full_name))->toBe(255) + ->and($execution->workflow_job_html_url)->toBeNull(); + Queue::assertPushed(ProvisionGithubRunnerJob::class); +}); + +it('stores over-long names and conclusion of a completed job truncated to the column length', function () { + $execution = GithubRunnerExecution::create([ + 'github_app_id' => $this->longNamesApp->id, + 'trigger_workflow_job_id' => 5001, + 'labels' => ['self-hosted', 'coolify'], + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-runner-long', + 'queued_at' => now(), + ]); + + sendLongNamesWorkflowJobWebhook($this, $this->longNamesApp, 'completed', [ + 'runner_name' => 'coolify-runner-long', + 'conclusion' => str_repeat('c', 300), + ])->assertOk(); + + $execution->refresh(); + expect(mb_strlen($execution->workflow_name))->toBe(255) + ->and(mb_strlen($execution->job_name))->toBe(255) + ->and(mb_strlen($execution->repository_full_name))->toBe(255) + ->and(mb_strlen($execution->conclusion))->toBe(255); +});