Merge remote-tracking branch 'origin/next' into fix/harden-getlogs-livewire-properties
This commit is contained in:
commit
ad694275b0
24 changed files with 459 additions and 552 deletions
|
|
@ -30,7 +30,8 @@ public function handle(Server $server)
|
|||
]);
|
||||
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
|
||||
if (! $this->uptime) {
|
||||
$this->error = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="text-black underline dark:text-white" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br><div class="text-error">Error: '.$error.'</div>';
|
||||
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$this->error = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="text-black underline dark:text-white" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br><div class="text-error">Error: '.$sanitizedError.'</div>';
|
||||
$server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Jobs\ServerLimitCheckJob;
|
||||
use App\Models\Team;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class UpdateSubscriptionQuantity
|
||||
|
|
@ -42,6 +43,7 @@ public function fetchPricePreview(Team $team, int $quantity): array
|
|||
}
|
||||
|
||||
$currency = strtoupper($item->price->currency ?? 'usd');
|
||||
$billingInterval = $item->price->recurring->interval ?? 'month';
|
||||
|
||||
// Upcoming invoice gives us the prorated amount due now
|
||||
$upcomingInvoice = $this->stripe->invoices->upcoming([
|
||||
|
|
@ -99,6 +101,7 @@ public function fetchPricePreview(Team $team, int $quantity): array
|
|||
'tax_description' => $taxDescription,
|
||||
'quantity' => $quantity,
|
||||
'currency' => $currency,
|
||||
'billing_interval' => $billingInterval,
|
||||
],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -184,7 +187,7 @@ public function execute(Team $team, int $quantity): array
|
|||
\Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}");
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
} catch (InvalidRequestException $e) {
|
||||
\Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage());
|
||||
|
||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||
|
|
|
|||
|
|
@ -30,32 +30,32 @@ public function init()
|
|||
// Generate APP_KEY if not exists
|
||||
|
||||
if (empty(config('app.key'))) {
|
||||
echo "Generating APP_KEY.\n";
|
||||
echo " INFO Generating APP_KEY.\n";
|
||||
Artisan::call('key:generate');
|
||||
}
|
||||
|
||||
// Generate STORAGE link if not exists
|
||||
if (! file_exists(public_path('storage'))) {
|
||||
echo "Generating STORAGE link.\n";
|
||||
echo " INFO Generating storage link.\n";
|
||||
Artisan::call('storage:link');
|
||||
}
|
||||
|
||||
// Seed database if it's empty
|
||||
$settings = InstanceSettings::find(0);
|
||||
if (! $settings) {
|
||||
echo "Initializing instance, seeding database.\n";
|
||||
echo " INFO Initializing instance, seeding database.\n";
|
||||
Artisan::call('migrate --seed');
|
||||
} else {
|
||||
echo "Instance already initialized.\n";
|
||||
echo " INFO Instance already initialized.\n";
|
||||
}
|
||||
|
||||
// Clean up stuck jobs and stale locks on development startup
|
||||
try {
|
||||
echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
|
||||
echo " INFO Cleaning up Redis (stuck jobs and stale locks)...\n";
|
||||
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||
echo "Redis cleanup completed.\n";
|
||||
echo " INFO Redis cleanup completed.\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis: {$e->getMessage()}\n";
|
||||
echo " ERROR Redis cleanup failed: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -66,10 +66,10 @@ public function init()
|
|||
]);
|
||||
|
||||
if ($updatedTaskCount > 0) {
|
||||
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||
echo " INFO Marked {$updatedTaskCount} stuck scheduled task executions as failed.\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||
echo " ERROR Could not clean up stuck scheduled task executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -80,10 +80,10 @@ public function init()
|
|||
]);
|
||||
|
||||
if ($updatedBackupCount > 0) {
|
||||
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||
echo " INFO Marked {$updatedBackupCount} stuck database backup executions as failed.\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||
echo " ERROR Could not clean up stuck database backup executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
CheckHelperImageJob::dispatch();
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class Horizon extends Command
|
||||
{
|
||||
protected $signature = 'start:horizon';
|
||||
|
||||
protected $description = 'Start Horizon';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if (config('constants.horizon.is_horizon_enabled')) {
|
||||
$this->info('Horizon is enabled on this server.');
|
||||
$this->call('horizon');
|
||||
exit(0);
|
||||
} else {
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class Nightwatch extends Command
|
||||
{
|
||||
protected $signature = 'start:nightwatch';
|
||||
|
||||
protected $description = 'Start Nightwatch';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (config('constants.nightwatch.is_nightwatch_enabled')) {
|
||||
$this->info('Nightwatch is enabled on this server.');
|
||||
$this->call('nightwatch:agent');
|
||||
}
|
||||
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class Scheduler extends Command
|
||||
{
|
||||
protected $signature = 'start:scheduler';
|
||||
|
||||
protected $description = 'Start Scheduler';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
if (config('constants.horizon.is_scheduler_enabled')) {
|
||||
$this->info('Scheduler is enabled on this server.');
|
||||
$this->call('schedule:work');
|
||||
exit(0);
|
||||
} else {
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,8 @@ public function handle(): void
|
|||
// Validate connection
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if (! $uptime) {
|
||||
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error;
|
||||
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$sanitizedError;
|
||||
$this->server->update([
|
||||
'validation_logs' => $errorMessage,
|
||||
'is_validating' => false,
|
||||
|
|
@ -197,7 +198,7 @@ public function handle(): void
|
|||
]);
|
||||
|
||||
$this->server->update([
|
||||
'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
|
||||
'validation_logs' => 'An error occurred during validation: '.htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'),
|
||||
'is_validating' => false,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ public function checkConnection()
|
|||
$this->dispatch('success', 'Server is reachable.');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} else {
|
||||
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
|
||||
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$sanitizedError);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ public function validateConnection()
|
|||
$this->authorize('update', $this->server);
|
||||
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if (! $this->uptime) {
|
||||
$this->error = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="text-black underline dark:text-white" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br><div class="text-error">Error: '.$error.'</div>';
|
||||
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$this->error = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="text-black underline dark:text-white" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br><div class="text-error">Error: '.$sanitizedError.'</div>';
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -267,6 +267,13 @@ public static function flushIdentityMap(): void
|
|||
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
public function setValidationLogsAttribute($value): void
|
||||
{
|
||||
$this->attributes['validation_logs'] = $value !== null
|
||||
? \Stevebauman\Purify\Facades\Purify::config('validation_logs')->clean($value)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function type()
|
||||
{
|
||||
return 'server';
|
||||
|
|
|
|||
|
|
@ -67,9 +67,12 @@ protected function resetUrl($notifiable)
|
|||
return call_user_func(static::$createUrlCallback, $notifiable, $this->token);
|
||||
}
|
||||
|
||||
return url(route('password.reset', [
|
||||
$path = route('password.reset', [
|
||||
'token' => $this->token,
|
||||
'email' => $notifiable->getEmailForPasswordReset(),
|
||||
], false));
|
||||
], false);
|
||||
|
||||
// Use server-side config (FQDN / public IP) instead of request host
|
||||
return rtrim(base_url(), '/').$path;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
|
@ -28,8 +29,10 @@
|
|||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Process\Pool;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
|
@ -49,10 +52,14 @@
|
|||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Token\Builder;
|
||||
use Livewire\Component;
|
||||
use Nubs\RandomNameGenerator\All;
|
||||
use Nubs\RandomNameGenerator\Alliteration;
|
||||
use phpseclib3\Crypt\EC;
|
||||
use phpseclib3\Crypt\RSA;
|
||||
use Poliander\Cron\CronExpression;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
|
@ -116,7 +123,7 @@ function sanitize_string(?string $input = null): ?string
|
|||
* @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
|
||||
* @throws Exception If dangerous characters are detected
|
||||
*/
|
||||
function validateShellSafePath(string $input, string $context = 'path'): string
|
||||
{
|
||||
|
|
@ -138,7 +145,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
|||
// Check for dangerous characters
|
||||
foreach ($dangerousChars as $char => $description) {
|
||||
if (str_contains($input, $char)) {
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
"Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
|
||||
'Shell metacharacters are not allowed for security reasons.'
|
||||
);
|
||||
|
|
@ -160,7 +167,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string
|
|||
* @param string $input The databases_to_backup string
|
||||
* @return string The validated input
|
||||
*
|
||||
* @throws \Exception If any component contains dangerous characters
|
||||
* @throws Exception If any component contains dangerous characters
|
||||
*/
|
||||
function validateDatabasesBackupInput(string $input): string
|
||||
{
|
||||
|
|
@ -211,7 +218,7 @@ function validateDatabasesBackupInput(string $input): string
|
|||
* @param string $context Descriptive name for error messages
|
||||
* @return string The validated input (trimmed)
|
||||
*
|
||||
* @throws \Exception If the input contains disallowed characters
|
||||
* @throws Exception If the input contains disallowed characters
|
||||
*/
|
||||
function validateGitRef(string $input, string $context = 'git ref'): string
|
||||
{
|
||||
|
|
@ -223,12 +230,12 @@ function validateGitRef(string $input, string $context = 'git ref'): string
|
|||
|
||||
// Must not start with a hyphen (git flag injection)
|
||||
if (str_starts_with($input, '-')) {
|
||||
throw new \Exception("Invalid {$context}: must not start with a hyphen.");
|
||||
throw new Exception("Invalid {$context}: must not start with a hyphen.");
|
||||
}
|
||||
|
||||
// Allow only alphanumeric characters, dots, hyphens, underscores, and slashes
|
||||
if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) {
|
||||
throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
|
||||
throw new Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed.");
|
||||
}
|
||||
|
||||
return $input;
|
||||
|
|
@ -282,7 +289,7 @@ function refreshSession(?Team $team = null): void
|
|||
});
|
||||
session(['currentTeam' => $team]);
|
||||
}
|
||||
function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null)
|
||||
function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null)
|
||||
{
|
||||
if ($error instanceof TooManyRequestsException) {
|
||||
if (isset($livewire)) {
|
||||
|
|
@ -299,7 +306,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n
|
|||
return 'Duplicate entry found. Please use a different name.';
|
||||
}
|
||||
|
||||
if ($error instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) {
|
||||
if ($error instanceof ModelNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +336,7 @@ function get_latest_sentinel_version(): string
|
|||
$versions = $response->json();
|
||||
|
||||
return data_get($versions, 'coolify.sentinel.version');
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
|
@ -339,7 +346,7 @@ function get_latest_version_of_coolify(): string
|
|||
$versions = get_versions_data();
|
||||
|
||||
return data_get($versions, 'coolify.v4.version', '0.0.0');
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
|
||||
return '0.0.0';
|
||||
}
|
||||
|
|
@ -347,9 +354,9 @@ function get_latest_version_of_coolify(): string
|
|||
|
||||
function generate_random_name(?string $cuid = null): string
|
||||
{
|
||||
$generator = new \Nubs\RandomNameGenerator\All(
|
||||
$generator = new All(
|
||||
[
|
||||
new \Nubs\RandomNameGenerator\Alliteration,
|
||||
new Alliteration,
|
||||
]
|
||||
);
|
||||
if (is_null($cuid)) {
|
||||
|
|
@ -448,7 +455,7 @@ function getFqdnWithoutPort(string $fqdn)
|
|||
$path = $url->getPath();
|
||||
|
||||
return "$scheme://$host$path";
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return $fqdn;
|
||||
}
|
||||
}
|
||||
|
|
@ -478,13 +485,13 @@ function base_url(bool $withPort = true): string
|
|||
}
|
||||
if ($settings->public_ipv6) {
|
||||
if ($withPort) {
|
||||
return "http://$settings->public_ipv6:$port";
|
||||
return "http://[$settings->public_ipv6]:$port";
|
||||
}
|
||||
|
||||
return "http://$settings->public_ipv6";
|
||||
return "http://[$settings->public_ipv6]";
|
||||
}
|
||||
|
||||
return url('/');
|
||||
return config('app.url');
|
||||
}
|
||||
|
||||
function isSubscribed()
|
||||
|
|
@ -537,21 +544,21 @@ function validate_cron_expression($expression_to_validate): bool
|
|||
* Even if the job runs minutes late, it still catches the missed cron window.
|
||||
* Without a dedupKey, falls back to a simple isDue() check.
|
||||
*/
|
||||
function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool
|
||||
function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?Carbon $executionTime = null): bool
|
||||
{
|
||||
$cron = new \Cron\CronExpression($frequency);
|
||||
$executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone);
|
||||
$cron = new Cron\CronExpression($frequency);
|
||||
$executionTime = ($executionTime ?? Carbon::now())->copy()->setTimezone($timezone);
|
||||
|
||||
if ($dedupKey === null) {
|
||||
return $cron->isDue($executionTime);
|
||||
}
|
||||
|
||||
$previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
|
||||
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
|
||||
$lastDispatched = Cache::get($dedupKey);
|
||||
|
||||
$shouldFire = $lastDispatched === null
|
||||
? $cron->isDue($executionTime)
|
||||
: $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched));
|
||||
: $previousDue->gt(Carbon::parse($lastDispatched));
|
||||
|
||||
// Always write: seeds on first miss, refreshes on dispatch.
|
||||
// 30-day static TTL covers all intervals; orphan keys self-clean.
|
||||
|
|
@ -932,7 +939,7 @@ function get_service_templates(bool $force = false): Collection
|
|||
$services = $response->json();
|
||||
|
||||
return collect($services);
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
|
||||
|
||||
return collect(json_decode($services))->sortKeys();
|
||||
|
|
@ -955,7 +962,7 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
|
|||
}
|
||||
|
||||
// ServiceDatabase has a different relationship path: service->environment->project->team_id
|
||||
if ($resource instanceof \App\Models\ServiceDatabase) {
|
||||
if ($resource instanceof ServiceDatabase) {
|
||||
if ($resource->service?->environment?->project?->team_id === $teamId) {
|
||||
return $resource;
|
||||
}
|
||||
|
|
@ -1081,7 +1088,7 @@ function generateGitManualWebhook($resource, $type)
|
|||
if ($resource->source_id !== 0 && ! is_null($resource->source_id)) {
|
||||
return null;
|
||||
}
|
||||
if ($resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($resource->getMorphClass() === Application::class) {
|
||||
$baseUrl = base_url();
|
||||
|
||||
return Url::fromString($baseUrl)."/webhooks/source/$type/events/manual";
|
||||
|
|
@ -1102,11 +1109,11 @@ function sanitizeLogsForExport(string $text): string
|
|||
|
||||
function getTopLevelNetworks(Service|Application $resource)
|
||||
{
|
||||
if ($resource->getMorphClass() === \App\Models\Service::class) {
|
||||
if ($resource->getMorphClass() === Service::class) {
|
||||
if ($resource->docker_compose_raw) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// If the docker-compose.yml file is not valid, we will return the network name as the key
|
||||
$topLevelNetworks = collect([
|
||||
$resource->uuid => [
|
||||
|
|
@ -1169,10 +1176,10 @@ function getTopLevelNetworks(Service|Application $resource)
|
|||
|
||||
return $topLevelNetworks->keys();
|
||||
}
|
||||
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
||||
} elseif ($resource->getMorphClass() === Application::class) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
// If the docker-compose.yml file is not valid, we will return the network name as the key
|
||||
$topLevelNetworks = collect([
|
||||
$resource->uuid => [
|
||||
|
|
@ -1479,7 +1486,7 @@ function validateDNSEntry(string $fqdn, Server $server)
|
|||
$ip = $server->ip;
|
||||
}
|
||||
$found_matching_ip = false;
|
||||
$type = \PurplePixie\PhpDns\DNSTypes::NAME_A;
|
||||
$type = DNSTypes::NAME_A;
|
||||
foreach ($dns_servers as $dns_server) {
|
||||
try {
|
||||
$query = new DNSQuery($dns_server);
|
||||
|
|
@ -1500,7 +1507,7 @@ function validateDNSEntry(string $fqdn, Server $server)
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception) {
|
||||
} catch (Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1682,7 +1689,7 @@ function get_public_ips()
|
|||
}
|
||||
InstanceSettings::get()->update(['public_ipv4' => $ipv4]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
echo "Error: {$e->getMessage()}\n";
|
||||
}
|
||||
try {
|
||||
|
|
@ -1697,7 +1704,7 @@ function get_public_ips()
|
|||
}
|
||||
InstanceSettings::get()->update(['public_ipv6' => $ipv6]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
echo "Error: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
|
|
@ -1795,15 +1802,15 @@ function customApiValidator(Collection|array $item, array $rules)
|
|||
}
|
||||
function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null)
|
||||
{
|
||||
if ($resource->getMorphClass() === \App\Models\Service::class) {
|
||||
if ($resource->getMorphClass() === Service::class) {
|
||||
if ($resource->docker_compose_raw) {
|
||||
// Extract inline comments from raw YAML before Symfony parser discards them
|
||||
$envComments = extractYamlEnvironmentComments($resource->docker_compose_raw);
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException($e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
throw new RuntimeException($e->getMessage());
|
||||
}
|
||||
$allServices = get_service_templates();
|
||||
$topLevelVolumes = collect(data_get($yaml, 'volumes', []));
|
||||
|
|
@ -2567,10 +2574,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
} else {
|
||||
return collect([]);
|
||||
}
|
||||
} elseif ($resource->getMorphClass() === \App\Models\Application::class) {
|
||||
} elseif ($resource->getMorphClass() === Application::class) {
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception) {
|
||||
} catch (Exception) {
|
||||
return;
|
||||
}
|
||||
$server = $resource->destination->server;
|
||||
|
|
@ -3332,7 +3339,7 @@ function isAssociativeArray($array)
|
|||
}
|
||||
|
||||
if (! is_array($array)) {
|
||||
throw new \InvalidArgumentException('Input must be an array or a Collection.');
|
||||
throw new InvalidArgumentException('Input must be an array or a Collection.');
|
||||
}
|
||||
|
||||
if ($array === []) {
|
||||
|
|
@ -3448,7 +3455,7 @@ function wireNavigate(): string
|
|||
|
||||
// Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
|
||||
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
return 'wire:navigate.hover';
|
||||
}
|
||||
}
|
||||
|
|
@ -3457,13 +3464,13 @@ function wireNavigate(): string
|
|||
* Redirect to a named route with SPA navigation support.
|
||||
* Automatically uses wire:navigate when is_wire_navigate_enabled is true.
|
||||
*/
|
||||
function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed
|
||||
function redirectRoute(Component $component, string $name, array $parameters = []): mixed
|
||||
{
|
||||
$navigate = true;
|
||||
|
||||
try {
|
||||
$navigate = instanceSettings()->is_wire_navigate_enabled ?? true;
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
$navigate = true;
|
||||
}
|
||||
|
||||
|
|
@ -3505,7 +3512,7 @@ function loadConfigFromGit(string $repository, string $branch, string $base_dire
|
|||
]);
|
||||
try {
|
||||
return instant_remote_process($commands, $server);
|
||||
} catch (\Exception) {
|
||||
} catch (Exception) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
|
@ -3636,8 +3643,8 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
|
|||
// If this happens, the user may have provided an HTTP URL when they needed an SSH one
|
||||
// Let's try and fix that for known Git providers
|
||||
switch ($source->getMorphClass()) {
|
||||
case \App\Models\GithubApp::class:
|
||||
case \App\Models\GitlabApp::class:
|
||||
case GithubApp::class:
|
||||
case GitlabApp::class:
|
||||
$providerInfo['host'] = Url::fromString($source->html_url)->getHost();
|
||||
$providerInfo['port'] = $source->custom_port;
|
||||
$providerInfo['user'] = $source->custom_user;
|
||||
|
|
@ -3915,10 +3922,10 @@ function shouldSkipPasswordConfirmation(): bool
|
|||
* - User has no password (OAuth users)
|
||||
*
|
||||
* @param mixed $password The password to verify (may be array if skipped by frontend)
|
||||
* @param \Livewire\Component|null $component Optional Livewire component to add errors to
|
||||
* @param Component|null $component Optional Livewire component to add errors to
|
||||
* @return bool True if verification passed (or skipped), false if password is incorrect
|
||||
*/
|
||||
function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool
|
||||
function verifyPasswordConfirmation(mixed $password, ?Component $component = null): bool
|
||||
{
|
||||
// Skip if password confirmation should be skipped
|
||||
if (shouldSkipPasswordConfirmation()) {
|
||||
|
|
@ -3941,17 +3948,17 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
|
|||
* Extract hard-coded environment variables from docker-compose YAML.
|
||||
*
|
||||
* @param string $dockerComposeRaw Raw YAML content
|
||||
* @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name
|
||||
* @return Collection Collection of arrays with: key, value, comment, service_name
|
||||
*/
|
||||
function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection
|
||||
function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collection
|
||||
{
|
||||
if (blank($dockerComposeRaw)) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
try {
|
||||
$yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
} catch (\Exception $e) {
|
||||
$yaml = Yaml::parse($dockerComposeRaw);
|
||||
} catch (Exception $e) {
|
||||
// Malformed YAML - return empty collection
|
||||
return collect([]);
|
||||
}
|
||||
|
|
@ -4100,7 +4107,7 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
|
|||
if (is_null($id)) {
|
||||
continue;
|
||||
}
|
||||
$found = \App\Models\SharedEnvironmentVariable::where('type', $type)
|
||||
$found = SharedEnvironmentVariable::where('type', $type)
|
||||
->where('key', $variable)
|
||||
->where('team_id', $resource->team()->id)
|
||||
->where("{$type}_id", $id)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,17 @@
|
|||
'AutoFormat.RemoveEmpty' => false,
|
||||
],
|
||||
|
||||
'validation_logs' => [
|
||||
'Core.Encoding' => 'utf-8',
|
||||
'HTML.Doctype' => 'HTML 4.01 Transitional',
|
||||
'HTML.Allowed' => 'a[href|title|target|class],br,div[class],pre[class],span[class],p[class]',
|
||||
'HTML.ForbiddenElements' => '',
|
||||
'CSS.AllowedProperties' => '',
|
||||
'AutoFormat.AutoParagraph' => false,
|
||||
'AutoFormat.RemoveEmpty' => false,
|
||||
'Attr.AllowedFrameTargets' => ['_blank'],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
#!/command/execlineb -P
|
||||
#!/bin/sh
|
||||
|
||||
# Use with-contenv to ensure environment variables are available
|
||||
with-contenv
|
||||
cd /var/www/html
|
||||
|
||||
foreground {
|
||||
php
|
||||
artisan
|
||||
start:horizon
|
||||
}
|
||||
if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then
|
||||
echo " INFO Horizon is disabled, sleeping."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
echo " INFO Horizon is enabled, starting..."
|
||||
exec php artisan horizon
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
#!/command/execlineb -P
|
||||
#!/bin/sh
|
||||
|
||||
# Use with-contenv to ensure environment variables are available
|
||||
with-contenv
|
||||
cd /var/www/html
|
||||
|
||||
foreground {
|
||||
php
|
||||
artisan
|
||||
start:nightwatch
|
||||
}
|
||||
if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then
|
||||
echo " INFO Nightwatch is enabled, starting..."
|
||||
exec php artisan nightwatch:agent
|
||||
fi
|
||||
|
||||
echo " INFO Nightwatch is disabled, sleeping."
|
||||
exec sleep infinity
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
#!/command/execlineb -P
|
||||
#!/bin/sh
|
||||
|
||||
# Use with-contenv to ensure environment variables are available
|
||||
with-contenv
|
||||
cd /var/www/html
|
||||
|
||||
foreground {
|
||||
php
|
||||
artisan
|
||||
start:scheduler
|
||||
}
|
||||
|
||||
if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then
|
||||
echo " INFO Scheduler is disabled, sleeping."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
echo " INFO Scheduler is enabled, starting..."
|
||||
exec php artisan schedule:work
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#!/command/execlineb -P
|
||||
#!/bin/sh
|
||||
|
||||
# Use with-contenv to ensure environment variables are available
|
||||
with-contenv
|
||||
cd /var/www/html
|
||||
foreground {
|
||||
php
|
||||
artisan
|
||||
start:horizon
|
||||
}
|
||||
|
||||
if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then
|
||||
echo " INFO Horizon is disabled, sleeping."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
echo " INFO Horizon is enabled, starting..."
|
||||
exec php artisan horizon
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#!/command/execlineb -P
|
||||
#!/bin/sh
|
||||
|
||||
# Use with-contenv to ensure environment variables are available
|
||||
with-contenv
|
||||
cd /var/www/html
|
||||
foreground {
|
||||
php
|
||||
artisan
|
||||
start:nightwatch
|
||||
}
|
||||
|
||||
if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then
|
||||
echo " INFO Nightwatch is enabled, starting..."
|
||||
exec php artisan nightwatch:agent
|
||||
fi
|
||||
|
||||
echo " INFO Nightwatch is disabled, sleeping."
|
||||
exec sleep infinity
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
#!/command/execlineb -P
|
||||
#!/bin/sh
|
||||
|
||||
# Use with-contenv to ensure environment variables are available
|
||||
with-contenv
|
||||
cd /var/www/html
|
||||
foreground {
|
||||
php
|
||||
artisan
|
||||
start:scheduler
|
||||
}
|
||||
|
||||
if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then
|
||||
echo " INFO Scheduler is disabled, sleeping."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
echo " INFO Scheduler is enabled, starting..."
|
||||
exec php artisan schedule:work
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
|
|||
<span class="dark:text-white" x-text="fmt(preview?.recurring_tax)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-6 text-sm font-bold pt-1.5 border-t dark:border-coolgray-400 border-neutral-200">
|
||||
<span class="dark:text-white">Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }}</span>
|
||||
<span class="dark:text-white">Total / <span x-text="preview?.billing_interval === 'year' ? 'year' : 'month'">month</span></span>
|
||||
<span class="dark:text-white" x-text="fmt(preview?.recurring_total)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
187
tests/Feature/ResetPasswordUrlTest.php
Normal file
187
tests/Feature/ResetPasswordUrlTest.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TransactionalEmails\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
Once::flush();
|
||||
});
|
||||
|
||||
function callResetUrl(ResetPassword $notification, $notifiable): string
|
||||
{
|
||||
$method = new ReflectionMethod($notification, 'resetUrl');
|
||||
|
||||
return $method->invoke($notification, $notifiable);
|
||||
}
|
||||
|
||||
it('generates reset URL using configured FQDN, not request host', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
|
||||
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toStartWith('https://coolify.example.com/')
|
||||
->toContain('test-token-abc')
|
||||
->toContain(urlencode($user->email))
|
||||
->not->toContain('localhost');
|
||||
});
|
||||
|
||||
it('generates reset URL using public IP when no FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => '65.21.3.91']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$notification = new ResetPassword('test-token-abc', isTransactionalEmail: false);
|
||||
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toContain('65.21.3.91')
|
||||
->toContain('test-token-abc')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('is immune to X-Forwarded-Host header poisoning when FQDN is set', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
// Simulate a request with a spoofed X-Forwarded-Host header
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Forwarded-Host' => 'evil.com',
|
||||
])->get('/');
|
||||
|
||||
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toStartWith('https://coolify.example.com/')
|
||||
->toContain('poisoned-token')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('is immune to X-Forwarded-Host header poisoning when using IP only', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => '65.21.3.91']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Forwarded-Host' => 'evil.com',
|
||||
])->get('/');
|
||||
|
||||
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toContain('65.21.3.91')
|
||||
->toContain('poisoned-token')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('generates reset URL with bracketed IPv6 when no FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$notification = new ResetPassword('ipv6-token', isTransactionalEmail: false);
|
||||
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toContain('[2001:db8::1]')
|
||||
->toContain('ipv6-token')
|
||||
->toContain(urlencode($user->email));
|
||||
});
|
||||
|
||||
it('is immune to X-Forwarded-Host header poisoning when using IPv6 only', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Forwarded-Host' => 'evil.com',
|
||||
])->get('/');
|
||||
|
||||
$notification = new ResetPassword('poisoned-token', isTransactionalEmail: false);
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toContain('[2001:db8::1]')
|
||||
->toContain('poisoned-token')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('uses APP_URL fallback when no FQDN or public IPs are configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null]
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
config(['app.url' => 'http://my-coolify.local']);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Forwarded-Host' => 'evil.com',
|
||||
])->get('/');
|
||||
|
||||
$notification = new ResetPassword('fallback-token', isTransactionalEmail: false);
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
expect($url)
|
||||
->toStartWith('http://my-coolify.local/')
|
||||
->toContain('fallback-token')
|
||||
->not->toContain('evil.com');
|
||||
});
|
||||
|
||||
it('generates a valid route path in the reset URL', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$notification = new ResetPassword('my-token', isTransactionalEmail: false);
|
||||
|
||||
$url = callResetUrl($notification, $user);
|
||||
|
||||
// Should contain the password reset route path with token and email
|
||||
expect($url)
|
||||
->toContain('/reset-password/')
|
||||
->toContain('my-token')
|
||||
->toContain(urlencode($user->email));
|
||||
});
|
||||
75
tests/Feature/ServerValidationXssTest.php
Normal file
75
tests/Feature/ServerValidationXssTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$user->teams()->attach($this->team);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips dangerous HTML from validation_logs via mutator', function () {
|
||||
$xssPayload = '<img src=x onerror=alert(document.domain)>';
|
||||
$this->server->update(['validation_logs' => $xssPayload]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->not->toContain('<img')
|
||||
->and($this->server->validation_logs)->not->toContain('onerror');
|
||||
});
|
||||
|
||||
it('strips script tags from validation_logs', function () {
|
||||
$xssPayload = '<script>alert("xss")</script>';
|
||||
$this->server->update(['validation_logs' => $xssPayload]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->not->toContain('<script');
|
||||
});
|
||||
|
||||
it('preserves allowed HTML in validation_logs', function () {
|
||||
$allowedHtml = 'Server is not reachable.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs">documentation</a> for further help.<br><br><div class="text-error">Error: Connection refused</div>';
|
||||
$this->server->update(['validation_logs' => $allowedHtml]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->toContain('<a')
|
||||
->and($this->server->validation_logs)->toContain('<br')
|
||||
->and($this->server->validation_logs)->toContain('<div')
|
||||
->and($this->server->validation_logs)->toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('allows null validation_logs', function () {
|
||||
$this->server->update(['validation_logs' => null]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->toBeNull();
|
||||
});
|
||||
|
||||
it('sanitizes XSS embedded within valid error HTML', function () {
|
||||
$maliciousError = 'Server is not reachable.<br><div class="text-error">Error: <img src=x onerror=alert(document.cookie)></div>';
|
||||
$this->server->update(['validation_logs' => $maliciousError]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->toContain('<div')
|
||||
->and($this->server->validation_logs)->toContain('Error:')
|
||||
->and($this->server->validation_logs)->not->toContain('onerror')
|
||||
->and($this->server->validation_logs)->not->toContain('<img');
|
||||
});
|
||||
|
||||
it('sanitizes event handler attributes in validation_logs', function () {
|
||||
$payload = '<div onmouseover="alert(1)" class="text-error">Error</div>';
|
||||
$this->server->update(['validation_logs' => $payload]);
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->validation_logs)->toContain('<div')
|
||||
->and($this->server->validation_logs)->not->toContain('onmouseover');
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Stripe\Exception\InvalidRequestException;
|
||||
use Stripe\Service\InvoiceService;
|
||||
use Stripe\Service\SubscriptionService;
|
||||
use Stripe\Service\TaxRateService;
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
'data' => [(object) [
|
||||
'id' => 'si_item_123',
|
||||
'quantity' => 2,
|
||||
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
|
||||
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'month']],
|
||||
]],
|
||||
],
|
||||
];
|
||||
|
|
@ -187,7 +188,7 @@
|
|||
test('handles stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
|
||||
->andThrow(new InvalidRequestException('Subscription not found'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
|
|
@ -199,7 +200,7 @@
|
|||
test('handles generic exception gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \RuntimeException('Network error'));
|
||||
->andThrow(new RuntimeException('Network error'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->execute($this->team, 5);
|
||||
|
|
@ -270,6 +271,46 @@
|
|||
expect($result['preview']['tax_description'])->toContain('27%');
|
||||
expect($result['preview']['quantity'])->toBe(3);
|
||||
expect($result['preview']['currency'])->toBe('USD');
|
||||
expect($result['preview']['billing_interval'])->toBe('month');
|
||||
});
|
||||
|
||||
test('returns yearly billing interval for annual subscriptions', function () {
|
||||
$yearlySubscriptionResponse = (object) [
|
||||
'items' => (object) [
|
||||
'data' => [(object) [
|
||||
'id' => 'si_item_123',
|
||||
'quantity' => 2,
|
||||
'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'year']],
|
||||
]],
|
||||
],
|
||||
];
|
||||
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->with('sub_test_qty')
|
||||
->andReturn($yearlySubscriptionResponse);
|
||||
|
||||
$this->mockInvoices
|
||||
->shouldReceive('upcoming')
|
||||
->andReturn((object) [
|
||||
'amount_due' => 1000,
|
||||
'total' => 1000,
|
||||
'subtotal' => 1000,
|
||||
'tax' => 0,
|
||||
'currency' => 'usd',
|
||||
'lines' => (object) [
|
||||
'data' => [
|
||||
(object) ['amount' => 1000, 'proration' => false],
|
||||
],
|
||||
],
|
||||
'total_tax_amounts' => [],
|
||||
]);
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($this->team, 2);
|
||||
|
||||
expect($result['success'])->toBeTrue();
|
||||
expect($result['preview']['billing_interval'])->toBe('year');
|
||||
});
|
||||
|
||||
test('returns preview without tax when no tax applies', function () {
|
||||
|
|
@ -336,7 +377,7 @@
|
|||
test('handles Stripe API error gracefully', function () {
|
||||
$this->mockSubscriptions
|
||||
->shouldReceive('retrieve')
|
||||
->andThrow(new \RuntimeException('API error'));
|
||||
->andThrow(new RuntimeException('API error'));
|
||||
|
||||
$action = new UpdateSubscriptionQuantity($this->mockStripe);
|
||||
$result = $action->fetchPricePreview($this->team, 5);
|
||||
|
|
|
|||
|
|
@ -1,360 +0,0 @@
|
|||
<?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();
|
||||
});
|
||||
|
||||
it('skips host validation for terminal auth routes', function () {
|
||||
// These routes should be accessible with any Host header (for internal container communication)
|
||||
$response = $this->postJson('/terminal/auth', [], [
|
||||
'Host' => 'coolify:8080', // Internal Docker host
|
||||
]);
|
||||
|
||||
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for terminal auth ips route', function () {
|
||||
// These routes should be accessible with any Host header (for internal container communication)
|
||||
$response = $this->postJson('/terminal/auth/ips', [], [
|
||||
'Host' => 'soketi:6002', // Another internal Docker host
|
||||
]);
|
||||
|
||||
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('still enforces host validation for non-terminal routes', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
// Regular routes should still validate Host header
|
||||
$response = $this->get('/', [
|
||||
'Host' => 'evil.com',
|
||||
]);
|
||||
|
||||
// Should get 400 Bad Host for untrusted host
|
||||
expect($response->status())->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for API routes', function () {
|
||||
// All API routes use token-based auth (Sanctum), not host validation
|
||||
// They should be accessible from any host (mobile apps, CLI tools, scripts)
|
||||
|
||||
// Test health check endpoint
|
||||
$response = $this->get('/api/health', [
|
||||
'Host' => 'internal-lb.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test v1 health check
|
||||
$response = $this->get('/api/v1/health', [
|
||||
'Host' => '10.0.0.5',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test feedback endpoint
|
||||
$response = $this->post('/api/feedback', [], [
|
||||
'Host' => 'mobile-app.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('trusts localhost when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('localhost');
|
||||
});
|
||||
|
||||
it('trusts 127.0.0.1 when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('127.0.0.1');
|
||||
});
|
||||
|
||||
it('trusts IPv6 loopback when FQDN is configured', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$middleware = new TrustHosts($this->app);
|
||||
$hosts = $middleware->hosts();
|
||||
|
||||
expect($hosts)->toContain('[::1]');
|
||||
});
|
||||
|
||||
it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
|
||||
InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
);
|
||||
|
||||
$response = $this->get('/', [
|
||||
'Host' => 'localhost',
|
||||
]);
|
||||
|
||||
// Should NOT be rejected as untrusted host (would be 400)
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for webhook endpoints', function () {
|
||||
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
|
||||
// and use cryptographic signature validation instead of host validation
|
||||
|
||||
// Test GitHub webhook
|
||||
$response = $this->post('/webhooks/source/github/events', [], [
|
||||
'Host' => 'github-webhook-proxy.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test GitLab webhook
|
||||
$response = $this->post('/webhooks/source/gitlab/events/manual', [], [
|
||||
'Host' => 'gitlab.example.com',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test Stripe webhook
|
||||
$response = $this->post('/webhooks/payments/stripe/events', [], [
|
||||
'Host' => 'stripe-webhook-forwarder.local',
|
||||
]);
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
Loading…
Reference in a new issue