mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-17 17:21:04 +00:00
refactor: improve docker compose validation and transaction handling in StackForm
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
|
|||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class StackForm extends Component
|
class StackForm extends Component
|
||||||
@@ -22,7 +23,7 @@ class StackForm extends Component
|
|||||||
|
|
||||||
public string $dockerComposeRaw;
|
public string $dockerComposeRaw;
|
||||||
|
|
||||||
public string $dockerCompose;
|
public ?string $dockerCompose = null;
|
||||||
|
|
||||||
public ?bool $connectToDockerNetwork = null;
|
public ?bool $connectToDockerNetwork = null;
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class StackForm extends Component
|
|||||||
{
|
{
|
||||||
$baseRules = [
|
$baseRules = [
|
||||||
'dockerComposeRaw' => 'required',
|
'dockerComposeRaw' => 'required',
|
||||||
'dockerCompose' => 'required',
|
'dockerCompose' => 'nullable',
|
||||||
'name' => ValidationPatterns::nameRules(),
|
'name' => ValidationPatterns::nameRules(),
|
||||||
'description' => ValidationPatterns::descriptionRules(),
|
'description' => ValidationPatterns::descriptionRules(),
|
||||||
'connectToDockerNetwork' => 'nullable',
|
'connectToDockerNetwork' => 'nullable',
|
||||||
@@ -140,18 +141,26 @@ class StackForm extends Component
|
|||||||
$this->validate();
|
$this->validate();
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
|
|
||||||
// Validate for command injection BEFORE saving to database
|
// Validate for command injection BEFORE any database operations
|
||||||
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
||||||
|
|
||||||
$this->service->save();
|
// Use transaction to ensure atomicity - if parse fails, save is rolled back
|
||||||
$this->service->saveExtraFields($this->fields);
|
DB::transaction(function () {
|
||||||
$this->service->parse();
|
$this->service->save();
|
||||||
$this->service->refresh();
|
$this->service->saveExtraFields($this->fields);
|
||||||
$this->service->saveComposeConfigs();
|
$this->service->parse();
|
||||||
|
$this->service->refresh();
|
||||||
|
$this->service->saveComposeConfigs();
|
||||||
|
});
|
||||||
|
|
||||||
$this->dispatch('refreshEnvs');
|
$this->dispatch('refreshEnvs');
|
||||||
$this->dispatch('refreshServices');
|
$this->dispatch('refreshServices');
|
||||||
$notify && $this->dispatch('success', 'Service saved.');
|
$notify && $this->dispatch('success', 'Service saved.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
// On error, refresh from database to restore clean state
|
||||||
|
$this->service->refresh();
|
||||||
|
$this->syncData(false);
|
||||||
|
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
} finally {
|
} finally {
|
||||||
if (is_null($this->service->config_hash)) {
|
if (is_null($this->service->config_hash)) {
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ function validateDockerComposeForInjection(string $composeYaml): void
|
|||||||
if (isset($volume['source'])) {
|
if (isset($volume['source'])) {
|
||||||
$source = $volume['source'];
|
$source = $volume['source'];
|
||||||
if (is_string($source)) {
|
if (is_string($source)) {
|
||||||
// Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
|
// Allow env vars and env vars with defaults (validated in parseDockerVolumeString)
|
||||||
|
// Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
|
||||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
|
||||||
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
|
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
|
||||||
|
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $source);
|
||||||
|
|
||||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
|
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||||
try {
|
try {
|
||||||
validateShellSafePath($source, 'volume source');
|
validateShellSafePath($source, 'volume source');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -310,15 +312,17 @@ function parseDockerVolumeString(string $volumeString): array
|
|||||||
// Validate source path for command injection attempts
|
// Validate source path for command injection attempts
|
||||||
// We validate the final source value after environment variable processing
|
// We validate the final source value after environment variable processing
|
||||||
if ($source !== null) {
|
if ($source !== null) {
|
||||||
// Allow simple environment variables like ${VAR_NAME} or ${VAR}
|
// Allow environment variables like ${VAR_NAME} or ${VAR}
|
||||||
// but validate everything else for shell metacharacters
|
// Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
|
||||||
$sourceStr = is_string($source) ? $source : $source;
|
$sourceStr = is_string($source) ? $source : $source;
|
||||||
|
|
||||||
// Skip validation for simple environment variable references
|
// Skip validation for simple environment variable references
|
||||||
// Pattern: ${WORD_CHARS} with no special characters inside
|
// Pattern 1: ${WORD_CHARS} with no special characters inside
|
||||||
|
// Pattern 2: ${WORD_CHARS}/path/to/file (env var with path concatenation)
|
||||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
|
||||||
|
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceStr);
|
||||||
|
|
||||||
if (! $isSimpleEnvVar) {
|
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
|
||||||
try {
|
try {
|
||||||
validateShellSafePath($sourceStr, 'volume source');
|
validateShellSafePath($sourceStr, 'volume source');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -711,9 +715,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||||||
// Validate source and target for command injection (array/long syntax)
|
// Validate source and target for command injection (array/long syntax)
|
||||||
if ($source !== null && ! empty($source->value())) {
|
if ($source !== null && ! empty($source->value())) {
|
||||||
$sourceValue = $source->value();
|
$sourceValue = $source->value();
|
||||||
// Allow simple environment variable references
|
// Allow environment variable references and env vars with path concatenation
|
||||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
||||||
if (! $isSimpleEnvVar) {
|
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
|
||||||
|
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
|
||||||
|
|
||||||
|
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||||
try {
|
try {
|
||||||
validateShellSafePath($sourceValue, 'volume source');
|
validateShellSafePath($sourceValue, 'volume source');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -1812,9 +1819,12 @@ function serviceParser(Service $resource): Collection
|
|||||||
// Validate source and target for command injection (array/long syntax)
|
// Validate source and target for command injection (array/long syntax)
|
||||||
if ($source !== null && ! empty($source->value())) {
|
if ($source !== null && ! empty($source->value())) {
|
||||||
$sourceValue = $source->value();
|
$sourceValue = $source->value();
|
||||||
// Allow simple environment variable references
|
// Allow environment variable references and env vars with path concatenation
|
||||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
||||||
if (! $isSimpleEnvVar) {
|
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
|
||||||
|
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
|
||||||
|
|
||||||
|
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||||
try {
|
try {
|
||||||
validateShellSafePath($sourceValue, 'volume source');
|
validateShellSafePath($sourceValue, 'volume source');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -194,6 +194,36 @@ YAML;
|
|||||||
->not->toThrow(Exception::class);
|
->not->toThrow(Exception::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('array-format with environment variable and path concatenation', function () {
|
||||||
|
// This is the reported issue #7127 - ${VAR}/path should be allowed
|
||||||
|
$dockerComposeYaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: '${VOLUMES_PATH}/mysql'
|
||||||
|
target: /var/lib/mysql
|
||||||
|
- type: bind
|
||||||
|
source: '${DATA_PATH}/config'
|
||||||
|
target: /etc/config
|
||||||
|
- type: bind
|
||||||
|
source: '${VOLUME_PATH}/app_data'
|
||||||
|
target: /app/data
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$parsed = Yaml::parse($dockerComposeYaml);
|
||||||
|
|
||||||
|
// Verify all three volumes have the correct source format
|
||||||
|
expect($parsed['services']['web']['volumes'][0]['source'])->toBe('${VOLUMES_PATH}/mysql');
|
||||||
|
expect($parsed['services']['web']['volumes'][1]['source'])->toBe('${DATA_PATH}/config');
|
||||||
|
expect($parsed['services']['web']['volumes'][2]['source'])->toBe('${VOLUME_PATH}/app_data');
|
||||||
|
|
||||||
|
// The validation should allow this - the reported bug was that it was blocked
|
||||||
|
expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))
|
||||||
|
->not->toThrow(Exception::class);
|
||||||
|
});
|
||||||
|
|
||||||
test('array-format with malicious environment variable default', function () {
|
test('array-format with malicious environment variable default', function () {
|
||||||
$dockerComposeYaml = <<<'YAML'
|
$dockerComposeYaml = <<<'YAML'
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -94,6 +94,27 @@ test('parseDockerVolumeString accepts simple environment variables', function ()
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseDockerVolumeString accepts environment variables with path concatenation', function () {
|
||||||
|
$volumes = [
|
||||||
|
'${VOLUMES_PATH}/mysql:/var/lib/mysql',
|
||||||
|
'${DATA_PATH}/config:/etc/config',
|
||||||
|
'${VOLUME_PATH}/app_data:/app',
|
||||||
|
'${MY_VAR_123}/deep/nested/path:/data',
|
||||||
|
'${VAR}/path:/app',
|
||||||
|
'${VAR}_suffix:/app',
|
||||||
|
'${VAR}-suffix:/app',
|
||||||
|
'${VAR}.ext:/app',
|
||||||
|
'${VOLUMES_PATH}/mysql:/var/lib/mysql:ro',
|
||||||
|
'${DATA_PATH}/config:/etc/config:rw',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($volumes as $volume) {
|
||||||
|
$result = parseDockerVolumeString($volume);
|
||||||
|
expect($result)->toBeArray();
|
||||||
|
expect($result['source'])->not->toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('parseDockerVolumeString rejects environment variables with command injection in default', function () {
|
test('parseDockerVolumeString rejects environment variables with command injection in default', function () {
|
||||||
$maliciousVolumes = [
|
$maliciousVolumes = [
|
||||||
'${VAR:-`whoami`}:/app',
|
'${VAR:-`whoami`}:/app',
|
||||||
|
|||||||
Reference in New Issue
Block a user