From 975048bad89bb1f149e47c9eea73b6ba6a3a88a9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:28:23 +0200 Subject: [PATCH] fix(security): encrypt plaintext secrets at rest --- app/Models/GithubApp.php | 2 + app/Models/GitlabApp.php | 1 + app/Models/ServerSetting.php | 1 + app/Models/StandaloneMariadb.php | 1 + app/Models/StandaloneMongodb.php | 17 +-- app/Models/TeamInvitation.php | 4 + ...10_19_083521_encrypt_plaintext_secrets.php | 88 +++++++++++++ .../Feature/PlaintextSecretEncryptionTest.php | 123 ++++++++++++++++++ 8 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 database/migrations/2026_10_19_083521_encrypt_plaintext_secrets.php create mode 100644 tests/Feature/PlaintextSecretEncryptionTest.php diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index c7e5385fff..493d51ce0a 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -50,6 +50,8 @@ class GithubApp extends BaseModel 'type' => 'string', 'webhook_events' => 'array', 'runner_group_id' => 'integer', + 'client_secret' => 'encrypted', + 'webhook_secret' => 'encrypted', ]; protected $hidden = [ diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 727ec77cd1..0f6de87930 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -51,6 +51,7 @@ class GitlabApp extends BaseModel 'access_token' => 'encrypted', 'refresh_token' => 'encrypted', 'client_secret' => 'encrypted', + 'app_secret' => 'encrypted', 'is_system_wide' => 'boolean', 'is_public' => 'boolean', ]; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 1314ad9bb2..d7eaffea62 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -182,6 +182,7 @@ class ServerSetting extends Model 'is_geoip_enabled' => 'boolean', 'geoip_refresh_days' => 'integer', 'geoip_maxmind_license_key' => 'encrypted', + 'logdrain_custom_config' => 'encrypted', 'traffic_ip_mode' => TrafficIpMode::class, 'docker_version_checked_at' => 'datetime', 'compose_version_checked_at' => 'datetime', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index efe112041b..dad5cbd630 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -79,6 +79,7 @@ class StandaloneMariadb extends BaseModel 'health_check_retries' => 'integer', 'health_check_start_period' => 'integer', 'mariadb_password' => 'encrypted', + 'mariadb_root_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index ff3528dffd..3771291cff 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -73,6 +73,7 @@ class StandaloneMongodb extends BaseModel protected $casts = [ 'include_coolify_default_variables' => 'boolean', 'health_check_enabled' => 'boolean', + 'mongo_initdb_root_password' => 'encrypted', 'health_check_interval' => 'integer', 'health_check_timeout' => 'integer', 'health_check_retries' => 'integer', @@ -282,22 +283,6 @@ class StandaloneMongodb extends BaseModel return null; } - public function mongoInitdbRootPassword(): Attribute - { - return Attribute::make( - get: function ($value) { - try { - return decrypt($value); - } catch (\Throwable $th) { - $this->mongo_initdb_root_password = encrypt($value); - $this->save(); - - return $value; - } - } - ); - } - public function portsMappings(): Attribute { return Attribute::make( diff --git a/app/Models/TeamInvitation.php b/app/Models/TeamInvitation.php index 4258a82b8a..f2a906e915 100644 --- a/app/Models/TeamInvitation.php +++ b/app/Models/TeamInvitation.php @@ -15,6 +15,10 @@ class TeamInvitation extends Model 'via', ]; + protected $casts = [ + 'link' => 'encrypted', + ]; + /** * Set the email attribute to lowercase. */ diff --git a/database/migrations/2026_10_19_083521_encrypt_plaintext_secrets.php b/database/migrations/2026_10_19_083521_encrypt_plaintext_secrets.php new file mode 100644 index 0000000000..5de956657e --- /dev/null +++ b/database/migrations/2026_10_19_083521_encrypt_plaintext_secrets.php @@ -0,0 +1,88 @@ +text('app_secret')->nullable()->change(); + }); + + $this->encryptColumns('github_apps', ['client_secret', 'webhook_secret']); + $this->encryptColumns('gitlab_apps', ['app_secret']); + $this->encryptColumns('standalone_mariadbs', ['mariadb_root_password']); + // The old accessor stored encrypt($value), which serializes the value before encryption. + $this->encryptColumns('standalone_mongodbs', ['mongo_initdb_root_password'], legacySerialized: true); + $this->encryptColumns('server_settings', ['logdrain_custom_config']); + $this->encryptColumns('team_invitations', ['link']); + } + + /** + * @param array $columns + */ + private function encryptColumns(string $table, array $columns, bool $legacySerialized = false): void + { + try { + DB::table($table)->select(['id', ...$columns])->chunkById(100, function ($rows) use ($table, $columns, $legacySerialized) { + foreach ($rows as $row) { + $updates = []; + + foreach ($columns as $column) { + $value = $row->{$column}; + + if (blank($value)) { + continue; + } + + $plaintext = $this->plaintextToEncrypt($value, $legacySerialized); + + if ($plaintext !== null) { + $updates[$column] = Crypt::encryptString($plaintext); + } + } + + if ($updates !== []) { + DB::table($table)->where('id', $row->id)->update($updates); + } + } + }); + } catch (Exception $e) { + echo "Encrypting secrets in {$table} failed."; + echo $e->getMessage(); + } + } + + /** + * Return the plaintext that still needs encryption, or null when the value is already stored as encryptString() output. + */ + private function plaintextToEncrypt(string $value, bool $legacySerialized): ?string + { + try { + $decrypted = Crypt::decryptString($value); + } catch (DecryptException) { + return $value; + } + + if ($legacySerialized) { + $unserialized = @unserialize($decrypted, ['allowed_classes' => false]); + + if (is_string($unserialized) && serialize($unserialized) === $decrypted) { + return $unserialized; + } + } + + return null; + } +}; diff --git a/tests/Feature/PlaintextSecretEncryptionTest.php b/tests/Feature/PlaintextSecretEncryptionTest.php new file mode 100644 index 0000000000..29edf2de47 --- /dev/null +++ b/tests/Feature/PlaintextSecretEncryptionTest.php @@ -0,0 +1,123 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $team->id])->id, + ]); + $databaseAttributes = [ + 'destination_type' => $destination->getMorphClass(), + 'destination_id' => $destination->id, + 'environment_id' => $environment->id, + ]; + $gitAppAttributes = [ + 'html_url' => 'https://example.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $team->id, + 'is_system_wide' => false, + ]; + + $githubApp = GithubApp::create([ + ...$gitAppAttributes, + 'name' => 'github', + 'api_url' => 'https://api.github.com', + 'client_secret' => 'github-client-secret', + 'webhook_secret' => 'github-webhook-secret', + ]); + $gitlabApp = GitlabApp::create([ + ...$gitAppAttributes, + 'name' => 'gitlab', + 'api_url' => 'https://gitlab.com/api/v4', + 'app_secret' => str_repeat('g', 64), + ]); + $mariadb = StandaloneMariadb::create([ + ...$databaseAttributes, + 'name' => 'mariadb', + 'mariadb_root_password' => 'mariadb-root-password', + 'mariadb_password' => 'mariadb-password', + ]); + $mongodb = StandaloneMongodb::create([ + ...$databaseAttributes, + 'name' => 'mongodb', + 'mongo_initdb_root_password' => 'mongo-root-password', + ]); + $server->settings->update(['logdrain_custom_config' => "[OUTPUT]\n Name http\n http_Passwd secret"]); + $invitation = TeamInvitation::create([ + 'team_id' => $team->id, + 'uuid' => 'invite-uuid', + 'email' => 'new@example.com', + 'role' => 'member', + 'link' => 'https://coolify.test/auth/link?token=secret-token', + 'via' => 'link', + ]); + + $this->secrets = [ + ['github_apps', $githubApp, 'client_secret', 'github-client-secret'], + ['github_apps', $githubApp, 'webhook_secret', 'github-webhook-secret'], + ['gitlab_apps', $gitlabApp, 'app_secret', str_repeat('g', 64)], + ['standalone_mariadbs', $mariadb, 'mariadb_root_password', 'mariadb-root-password'], + ['standalone_mongodbs', $mongodb, 'mongo_initdb_root_password', 'mongo-root-password'], + ['server_settings', $server->settings, 'logdrain_custom_config', "[OUTPUT]\n Name http\n http_Passwd secret"], + ['team_invitations', $invitation, 'link', 'https://coolify.test/auth/link?token=secret-token'], + ]; +}); + +function rawSecret(string $table, $model, string $column): ?string +{ + return DB::table($table)->where('id', $model->id)->value($column); +} + +it('encrypts secrets at rest when they are saved through the model', function () { + foreach ($this->secrets as [$table, $model, $column, $plaintext]) { + expect(Crypt::decryptString(rawSecret($table, $model, $column)))->toBe($plaintext, "{$table}.{$column}") + ->and($model->fresh()->{$column})->toBe($plaintext, "{$table}.{$column}"); + } +}); + +it('stores the GitLab app secret in a text column so the ciphertext fits', function () { + expect(Schema::getColumnType('gitlab_apps', 'app_secret'))->toBe('text'); +}); + +it('migrates plaintext and legacy serialized secrets without encrypting them twice', function () { + foreach ($this->secrets as [$table, $model, $column, $plaintext]) { + $legacyValue = $column === 'mongo_initdb_root_password' ? encrypt($plaintext) : $plaintext; + DB::table($table)->where('id', $model->id)->update([$column => $legacyValue]); + } + $emptyGithubApp = GithubApp::create([ + 'name' => 'empty', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'team_id' => $this->secrets[0][1]->team_id, + 'is_system_wide' => false, + ]); + + $migration = require database_path('migrations/2026_10_19_083521_encrypt_plaintext_secrets.php'); + $migration->up(); + $migration->up(); + + foreach ($this->secrets as [$table, $model, $column, $plaintext]) { + expect(Crypt::decryptString(rawSecret($table, $model, $column)))->toBe($plaintext, "{$table}.{$column}") + ->and($model->fresh()->{$column})->toBe($plaintext, "{$table}.{$column}"); + } + expect(rawSecret('github_apps', $emptyGithubApp, 'client_secret'))->toBeNull() + ->and(rawSecret('github_apps', $emptyGithubApp, 'webhook_secret'))->toBeNull(); +});