coolify/app/Models/StandaloneRedis.php
Andras Bacsai b3256d4df1 fix(security): harden model assignment and sensitive data handling
Restrict mass-assignable attributes across user/team/redis models and
switch privileged root/team creation paths to forceFill/forceCreate.

Encrypt legacy ClickHouse admin passwords via migration and cast the
correct ClickHouse password field as encrypted.

Tighten API and runtime exposure by removing sensitive team fields from
responses and sanitizing Git/compose error messages.

Expand security-focused feature coverage for command-injection and mass
assignment protections.
2026-03-29 20:56:04 +02:00

401 lines
12 KiB
PHP

<?php
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'name',
'description',
'redis_conf',
'status',
'image',
'is_public',
'public_port',
'ports_mappings',
'limits_memory',
'limits_memory_swap',
'limits_memory_swappiness',
'limits_memory_reservation',
'limits_cpus',
'limits_cpuset',
'limits_cpu_shares',
'started_at',
'restart_count',
'last_restart_at',
'last_restart_type',
'last_online_at',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
'last_restart_type' => 'string',
];
protected static function booted()
{
static::created(function ($database) {
LocalPersistentVolume::create([
'name' => 'redis-data-'.$database->uuid,
'mount_path' => '/data',
'host_path' => null,
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
]);
});
static::forceDeleting(function ($database) {
$database->persistentStorages()->delete();
$database->scheduledBackups()->delete();
$database->environment_variables()->delete();
$database->tags()->detach();
});
static::saving(function ($database) {
if ($database->isDirty('status')) {
$database->forceFill(['last_online_at' => now()]);
}
});
static::retrieved(function ($database) {
if (! $database->redis_username) {
$database->redis_username = 'default';
}
});
}
/**
* Get query builder for Redis databases owned by current team.
* If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
*/
public static function ownedByCurrentTeam()
{
return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
/**
* Get all Redis databases owned by current team (cached for request duration).
*/
public static function ownedByCurrentTeamCached()
{
return once(function () {
return StandaloneRedis::ownedByCurrentTeam()->get();
});
}
protected function serverStatus(): Attribute
{
return Attribute::make(
get: function () {
return $this->destination->server->isFunctional();
}
);
}
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf;
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
if ($oldConfigHash === $newConfigHash) {
return false;
} else {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
}
public function isRunning()
{
return (bool) str($this->status)->contains('running');
}
public function isExited()
{
return (bool) str($this->status)->startsWith('exited');
}
public function workdir()
{
return database_configuration_dir()."/{$this->uuid}";
}
public function deleteConfigurations()
{
$server = data_get($this, 'destination.server');
$workdir = $this->workdir();
if (str($workdir)->endsWith($this->uuid)) {
instant_remote_process(['rm -rf '.$this->workdir()], $server, false);
}
}
public function deleteVolumes()
{
$persistentStorages = $this->persistentStorages()->get() ?? collect();
if ($persistentStorages->count() === 0) {
return;
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
public function realStatus()
{
return $this->getRawOriginal('status');
}
public function status(): Attribute
{
return Attribute::make(
set: function ($value) {
if (str($value)->contains('(')) {
$status = str($value)->before('(')->trim()->value();
$health = str($value)->after('(')->before(')')->trim()->value() ?? 'unhealthy';
} elseif (str($value)->contains(':')) {
$status = str($value)->before(':')->trim()->value();
$health = str($value)->after(':')->trim()->value() ?? 'unhealthy';
} else {
$status = $value;
$health = 'unhealthy';
}
return "$status:$health";
},
get: function ($value) {
if (str($value)->contains('(')) {
$status = str($value)->before('(')->trim()->value();
$health = str($value)->after('(')->before(')')->trim()->value() ?? 'unhealthy';
} elseif (str($value)->contains(':')) {
$status = str($value)->before(':')->trim()->value();
$health = str($value)->after(':')->trim()->value() ?? 'unhealthy';
} else {
$status = $value;
$health = 'unhealthy';
}
return "$status:$health";
},
);
}
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function project()
{
return data_get($this, 'environment.project');
}
public function team()
{
return data_get($this, 'environment.project.team');
}
public function sslCertificates()
{
return $this->morphMany(SslCertificate::class, 'resource');
}
public function link()
{
if (data_get($this, 'environment.project.uuid')) {
return route('project.database.configuration', [
'project_uuid' => data_get($this, 'environment.project.uuid'),
'environment_uuid' => data_get($this, 'environment.uuid'),
'database_uuid' => data_get($this, 'uuid'),
]);
}
return null;
}
public function isLogDrainEnabled()
{
return data_get($this, 'is_log_drain_enabled', false);
}
public function portsMappings(): Attribute
{
return Attribute::make(
set: fn ($value) => $value === '' ? null : $value,
);
}
public function portsMappingsArray(): Attribute
{
return Attribute::make(
get: fn () => is_null($this->ports_mappings)
? []
: explode(',', $this->ports_mappings),
);
}
public function type(): string
{
return 'standalone-redis';
}
public function databaseType(): Attribute
{
return new Attribute(
get: fn () => $this->type(),
);
}
protected function internalDbUrl(): Attribute
{
return new Attribute(
get: function () {
$redis_version = $this->getRedisVersion();
$username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : '';
$encodedPass = rawurlencode($this->redis_password);
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$port = $this->enable_ssl ? 6380 : 6379;
$url = "{$scheme}://{$username_part}{$encodedPass}@{$this->uuid}:{$port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
}
return $url;
}
);
}
protected function externalDbUrl(): Attribute
{
return new Attribute(
get: function () {
if ($this->is_public && $this->public_port) {
$serverIp = $this->destination->server->getIp;
if (empty($serverIp)) {
return null;
}
$redis_version = $this->getRedisVersion();
$username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : '';
$encodedPass = rawurlencode($this->redis_password);
$scheme = $this->enable_ssl ? 'rediss' : 'redis';
$url = "{$scheme}://{$username_part}{$encodedPass}@{$serverIp}:{$this->public_port}/0";
if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') {
$url .= '?cacert=/etc/ssl/certs/coolify-ca.crt';
}
return $url;
}
return null;
}
);
}
public function getRedisVersion()
{
$image_parts = explode(':', $this->image);
return $image_parts[1] ?? '0.0';
}
public function environment()
{
return $this->belongsTo(Environment::class);
}
public function fileStorages()
{
return $this->morphMany(LocalFileVolume::class, 'resource');
}
public function destination()
{
return $this->morphTo();
}
public function runtime_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
public function persistentStorages()
{
return $this->morphMany(LocalPersistentVolume::class, 'resource');
}
public function scheduledBackups()
{
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
public function isBackupSolutionAvailable()
{
return false;
}
public function redisPassword(): Attribute
{
return new Attribute(
get: function () {
$password = $this->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->first();
if (! $password) {
return null;
}
return $password->value;
},
);
}
public function redisUsername(): Attribute
{
return new Attribute(
get: function () {
$username = $this->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->first();
if (! $username) {
$this->runtime_environment_variables()->create([
'key' => 'REDIS_USERNAME',
'value' => 'default',
]);
return 'default';
}
return $username->value;
}
);
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
}