mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(security): encrypt plaintext secrets at rest
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,6 +15,10 @@ class TeamInvitation extends Model
|
||||
'via',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'link' => 'encrypted',
|
||||
];
|
||||
|
||||
/**
|
||||
* Set the email attribute to lowercase.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Encrypt secret columns that were stored as plaintext, to match their new `encrypted` casts.
|
||||
* Values that are already encrypted are skipped, so the migration is safe to run again.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Ciphertext does not fit in the original varchar(255) column.
|
||||
Schema::table('gitlab_apps', function (Blueprint $table) {
|
||||
$table->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<int, string> $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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\Team;
|
||||
use App\Models\TeamInvitation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$team = Team::factory()->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();
|
||||
});
|
||||
Reference in New Issue
Block a user