diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index bcd7a729d..b6facba22 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -1813,7 +1813,7 @@ private function health_check()
$this->application->update(['status' => 'running']);
$this->application_deployment_queue->addLogEntry('New container is healthy.');
break;
- } elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
+ } elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
$this->newVersionIsHealthy = false;
$this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error');
$this->query_logs();
diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php
index cdac47d3d..87f7cff8a 100644
--- a/app/Livewire/Project/Application/Deployment/Show.php
+++ b/app/Livewire/Project/Application/Deployment/Show.php
@@ -18,6 +18,8 @@ class Show extends Component
public $isKeepAliveOn = true;
+ public bool $is_debug_enabled = false;
+
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -56,9 +58,23 @@ public function mount()
$this->application_deployment_queue = $application_deployment_queue;
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->deployment_uuid = $deploymentUuid;
+ $this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->isKeepAliveOn();
}
+ public function toggleDebug()
+ {
+ try {
+ $this->authorize('update', $this->application);
+ $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled;
+ $this->application->settings->save();
+ $this->is_debug_enabled = $this->application->settings->is_debug_enabled;
+ $this->application_deployment_queue->refresh();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function refreshQueue()
{
$this->application_deployment_queue->refresh();
diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php
index 304f7b411..f86d88208 100644
--- a/app/Livewire/Project/Shared/GetLogs.php
+++ b/app/Livewire/Project/Shared/GetLogs.php
@@ -43,6 +43,8 @@ class GetLogs extends Component
public ?int $numberOfLines = 100;
+ public bool $expandByDefault = false;
+
public function mount()
{
if (! is_null($this->resource)) {
@@ -92,6 +94,27 @@ public function instantSave()
}
}
+ public function toggleTimestamps()
+ {
+ $previousValue = $this->showTimeStamps;
+ $this->showTimeStamps = ! $this->showTimeStamps;
+
+ try {
+ $this->instantSave();
+ $this->getLogs(true);
+ } catch (\Throwable $e) {
+ // Revert the flag to its previous value on failure
+ $this->showTimeStamps = $previousValue;
+
+ return handleError($e, $this);
+ }
+ }
+
+ public function toggleStreamLogs()
+ {
+ $this->streamLogs = ! $this->streamLogs;
+ }
+
public function getLogs($refresh = false)
{
if (! $this->server->isFunctional()) {
diff --git a/resources/css/app.css b/resources/css/app.css
index 931e3fe19..30371d307 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -185,4 +185,15 @@ .input[type="password"] {
.lds-heart {
animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
+}
+
+.log-highlight {
+ background-color: rgba(234, 179, 8, 0.4);
+ border-radius: 2px;
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+}
+
+.dark .log-highlight {
+ background-color: rgba(234, 179, 8, 0.3);
}
\ No newline at end of file
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index 7bb366cd4..2b4ca6054 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -83,7 +83,7 @@
if (!html) return '';
const URL_RE = /^(https?:|mailto:)/i;
const config = {
- ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span', 'strong',
+ ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'mark', 'p', 'pre', 's', 'span', 'strong',
'u'
],
ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'],
diff --git a/resources/views/livewire/project/application/deployment-navbar.blade.php b/resources/views/livewire/project/application/deployment-navbar.blade.php
index 60c660bf7..8d0fc18fb 100644
--- a/resources/views/livewire/project/application/deployment-navbar.blade.php
+++ b/resources/views/livewire/project/application/deployment-navbar.blade.php
@@ -1,18 +1,12 @@
Deployment Log
- @if ($is_debug_enabled)
- Hide Debug Logs
- @else
- Show Debug Logs
- @endif
- @if (isDev())
- Copy Logs
- @endif
@if (data_get($application_deployment_queue, 'status') === 'queued')
Force Start
@endif
- @if (data_get($application_deployment_queue, 'status') === 'in_progress' ||
- data_get($application_deployment_queue, 'status') === 'queued')
+ @if (
+ data_get($application_deployment_queue, 'status') === 'in_progress' ||
+ data_get($application_deployment_queue, 'status') === 'queued'
+ )
Cancel
@endif
-
+
\ No newline at end of file
diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php
index b52a6eaf1..1d1ffca1e 100644
--- a/resources/views/livewire/project/application/deployment/show.blade.php
+++ b/resources/views/livewire/project/application/deployment/show.blade.php
@@ -1,15 +1,18 @@
{{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify
-
- Deployment
-
-
- Deployment
+
+
+
-
- @if (data_get($application_deployment_queue, 'status') === 'in_progress')
-
Deployment is
-
- {{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}.
-
-
-
- {{--
Logs will be updated automatically.
--}}
- @else
-
Deployment is {{ Str::headline(data_get($application_deployment_queue, 'status')) }} .
-
- @endif
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ @if (data_get($application_deployment_queue, 'status') === 'in_progress')
+
Deployment is
+
+ {{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}.
+
-
-
- @forelse ($this->logLines as $line)
-
isset($line['command']) && $line['command'],
- 'flex gap-2 dark:hover:bg-coolgray-500 hover:bg-gray-100',
- ])>
-
{{ $line['timestamp'] }}
-
$line['hidden'],
- 'text-red-500' => $line['stderr'],
- 'font-bold' => isset($line['command']) && $line['command'],
- 'whitespace-pre-wrap',
- ])>{!! (isset($line['command']) && $line['command'] ? '[CMD]: ' : '') . trim($line['line']) !!}
+ {{--
Logs will be updated automatically.
--}}
+ @else
+
Deployment is {{ Str::headline(data_get($application_deployment_queue, 'status')) }} .
+
+ @endif
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- @empty
-
No logs yet.
- @endforelse
+
+
-
-
+
\ No newline at end of file
diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php
index bc4eff557..bc95d5b97 100644
--- a/resources/views/livewire/project/shared/get-logs.blade.php
+++ b/resources/views/livewire/project/shared/get-logs.blade.php
@@ -1,8 +1,13 @@
-
-
+
{
- const screen = document.getElementById('screen');
- const logs = document.getElementById('logs');
- if (screen.scrollTop !== logs.scrollHeight) {
- screen.scrollTop = logs.scrollHeight;
+ const logsContainer = document.getElementById('logsContainer');
+ if (logsContainer) {
+ this.isScrolling = true;
+ logsContainer.scrollTop = logsContainer.scrollHeight;
+ setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
} else {
@@ -26,14 +32,119 @@
this.intervalId = null;
}
},
- goTop() {
- this.alwaysScroll = false;
- clearInterval(this.intervalId);
- const screen = document.getElementById('screen');
- screen.scrollTop = 0;
+ handleScroll(event) {
+ if (!this.alwaysScroll || this.isScrolling) return;
+ const el = event.target;
+ // Check if user scrolled away from the bottom
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ if (distanceFromBottom > 50) {
+ this.alwaysScroll = false;
+ clearInterval(this.intervalId);
+ this.intervalId = null;
+ }
+ },
+ matchesSearch(line) {
+ if (!this.searchQuery.trim()) return true;
+ return line.toLowerCase().includes(this.searchQuery.toLowerCase());
+ },
+ decodeHtml(text) {
+ // Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
+ let decoded = text;
+ let prev = '';
+ let iterations = 0;
+ const maxIterations = 3; // Prevent DoS from deeply nested HTML entities
+
+ while (decoded !== prev && iterations < maxIterations) {
+ prev = decoded;
+ const doc = new DOMParser().parseFromString(decoded, 'text/html');
+ decoded = doc.documentElement.textContent;
+ iterations++;
+ }
+ return decoded;
+ },
+ renderHighlightedLog(el, text) {
+ const decoded = this.decodeHtml(text);
+ el.textContent = '';
+
+ if (!this.searchQuery.trim()) {
+ el.textContent = decoded;
+ return;
+ }
+
+ const query = this.searchQuery.toLowerCase();
+ const lowerText = decoded.toLowerCase();
+ let lastIndex = 0;
+
+ let index = lowerText.indexOf(query, lastIndex);
+ while (index !== -1) {
+ // Add text before match
+ if (index > lastIndex) {
+ el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
+ }
+ // Add highlighted match
+ const mark = document.createElement('span');
+ mark.className = 'log-highlight';
+ mark.textContent = decoded.substring(index, index + this.searchQuery.length);
+ el.appendChild(mark);
+
+ lastIndex = index + this.searchQuery.length;
+ index = lowerText.indexOf(query, lastIndex);
+ }
+
+ // Add remaining text
+ if (lastIndex < decoded.length) {
+ el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
+ }
+ },
+ getMatchCount() {
+ if (!this.searchQuery.trim()) return 0;
+ const logs = document.getElementById('logs');
+ if (!logs) return 0;
+ const lines = logs.querySelectorAll('[data-log-line]');
+ let count = 0;
+ lines.forEach(line => {
+ if (line.textContent.toLowerCase().includes(this.searchQuery.toLowerCase())) {
+ count++;
+ }
+ });
+ return count;
+ },
+ downloadLogs() {
+ const logs = document.getElementById('logs');
+ if (!logs) return;
+ const visibleLines = logs.querySelectorAll('[data-log-line]:not(.hidden)');
+ let content = '';
+ visibleLines.forEach(line => {
+ const text = line.textContent.replace(/\s+/g, ' ').trim();
+ if (text) {
+ content += text + String.fromCharCode(10);
+ }
+ });
+ const blob = new Blob([content], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
+ a.download = this.containerName + '-logs-' + timestamp + '.txt';
+ a.click();
+ URL.revokeObjectURL(url);
+ },
+ init() {
+ if (this.expanded) { this.$wire.getLogs(); }
+ // Re-render logs after Livewire updates
+ Livewire.hook('commit', ({ succeed }) => {
+ succeed(() => {
+ this.$nextTick(() => { this.renderTrigger++; });
+ });
+ });
}
}">
-
+
+
+
+
@if ($displayName)
{{ $displayName }}
@elseif ($resource?->type() === 'application' || str($resource?->type())->startsWith('standalone'))
@@ -48,26 +159,90 @@
@endif
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if ($streamLogs)
+ {{-- Pause icon --}}
+
+
+
+ @else
+ {{-- Play icon --}}
+
+
+
+ @endif
+
+
+
+
+
+
+
+
@@ -76,42 +251,69 @@
-
-
-
+
+
+
- @if ($outputs)
-
- @foreach (explode("\n", $outputs) as $line)
- @php
- // Skip empty lines
- if (trim($line) === '') {
- continue;
- }
-
- // Style timestamps by replacing them inline
- $styledLine = preg_replace(
- '/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)/',
- '
$1 ',
- htmlspecialchars($line),
- );
- @endphp
-
- {!! $styledLine !!}
+
+ @else
+
Refresh to get the logs...
+ @endif
+
-
+
\ No newline at end of file
diff --git a/resources/views/livewire/project/shared/logs.blade.php b/resources/views/livewire/project/shared/logs.blade.php
index 87bb1a6b6..3a1afaa1c 100644
--- a/resources/views/livewire/project/shared/logs.blade.php
+++ b/resources/views/livewire/project/shared/logs.blade.php
@@ -17,13 +17,17 @@
@forelse ($servers as $server)
-
Server: {{ $server->name }}
+
Server: {{ $server->name }}
@if ($server->isFunctional())
@if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0)
+ @php
+ $totalContainers = collect($serverContainers)->flatten(1)->count();
+ @endphp
@foreach ($serverContainers[$server->id] as $container)
+ :resource="$resource" :container="data_get($container, 'Names')"
+ :expandByDefault="$totalContainers === 1" />
@endforeach
@else
No containers are running on server: {{ $server->name }}
@@ -53,7 +57,8 @@
@forelse ($containers as $container)
@if (data_get($servers, '0'))
+ :resource="$resource" :container="$container"
+ :expandByDefault="count($containers) === 1" />
@else
No functional server found for the database.
@endif
@@ -77,7 +82,8 @@
@forelse ($containers as $container)
@if (data_get($servers, '0'))
+ :resource="$resource" :container="$container"
+ :expandByDefault="count($containers) === 1" />
@else
No functional server found for the service.
@endif
diff --git a/tests/Unit/LogViewerXssSecurityTest.php b/tests/Unit/LogViewerXssSecurityTest.php
new file mode 100644
index 000000000..98c5df3f1
--- /dev/null
+++ b/tests/Unit/LogViewerXssSecurityTest.php
@@ -0,0 +1,427 @@
+alert("XSS")';
+ $escaped = htmlspecialchars($maliciousLog);
+
+ expect($escaped)->toContain('<script>');
+ expect($escaped)->not->toContain('">';
+ $escaped = htmlspecialchars($maliciousLog);
+
+ expect($escaped)->toContain('<iframe');
+ expect($escaped)->toContain('data:');
+ expect($escaped)->not->toContain('
';
+ $escaped = htmlspecialchars($maliciousLog);
+
+ expect($escaped)->toContain('<div');
+ expect($escaped)->toContain('style');
+ expect($escaped)->not->toContain('
test
';
+ $escaped = htmlspecialchars($maliciousLog);
+
+ expect($escaped)->toContain('<div');
+ expect($escaped)->toContain('x-html');
+ expect($escaped)->not->toContain('
&"\'';
+ $escaped = htmlspecialchars($maliciousLog);
+
+ expect($escaped)->toBe('<>&"'');
+ });
+
+ it('preserves legitimate text content', function () {
+ $legitimateLog = 'INFO: Application started successfully';
+ $escaped = htmlspecialchars($legitimateLog);
+
+ expect($escaped)->toBe($legitimateLog);
+ });
+
+ it('handles ANSI color codes after escaping', function () {
+ $logWithAnsi = "\e[31mERROR:\e[0m Something went wrong";
+ $escaped = htmlspecialchars($logWithAnsi);
+
+ // ANSI codes should be preserved in escaped form
+ expect($escaped)->toContain('ERROR');
+ expect($escaped)->toContain('Something went wrong');
+ });
+
+ it('escapes complex nested HTML structures', function () {
+ $maliciousLog = '
';
+ $escaped = htmlspecialchars($maliciousLog);
+
+ expect($escaped)->toContain('<div');
+ expect($escaped)->toContain('<img');
+ expect($escaped)->toContain('<script>');
+ expect($escaped)->not->toContain('
not->toContain('
not->toContain('';
+ $escaped = htmlspecialchars($contentWithHtml);
+
+ // When stored in data attribute and rendered with x-text:
+ // 1. Server escapes to: <script>alert("XSS")</script>
+ // 2. Browser decodes the attribute value to:
+ // 3. x-text renders it as textContent (plain text), NOT innerHTML
+ // 4. Result: User sees "" as text, script never executes
+
+ expect($escaped)->toContain('<script>');
+ expect($escaped)->not->toContain('';
+
+ // Step 1: Server-side escaping (PHP)
+ $escaped = htmlspecialchars($rawLog);
+ expect($escaped)->toBe('<script>alert("XSS")</script>');
+
+ // Step 2: Stored in data-log-content attribute
+ //
+
+ // Step 3: Client-side getDisplayText() decodes HTML entities
+ // const decoded = doc.documentElement.textContent;
+ // Result: '' (as text string)
+
+ // Step 4: x-text renders as textContent (NOT innerHTML)
+ // Alpine.js sets element.textContent = decoded
+ // Result: Browser displays '' as visible text
+ // The script tag is never parsed or executed - it's just text
+
+ // Step 5: Highlighting via CSS class
+ // If search query matches, 'log-highlight' class is added
+ // Visual feedback is provided through CSS, not HTML injection
+ });
+
+ it('documents search highlighting with CSS classes', function () {
+ $legitimateLog = '2024-01-01T12:00:00.000Z ERROR: Database connection failed';
+
+ // Server-side: Escape and store
+ $escaped = htmlspecialchars($legitimateLog);
+ expect($escaped)->toBe($legitimateLog); // No special chars
+
+ // Client-side: If user searches for "ERROR"
+ // 1. splitTextForHighlight() divides the text into parts:
+ // - Part 1: "2024-01-01T12:00:00.000Z " (highlight: false)
+ // - Part 2: "ERROR" (highlight: true) <- This part gets highlighted
+ // - Part 3: ": Database connection failed" (highlight: false)
+ // 2. Each part is rendered as a
with x-text (safe)
+ // 3. Only Part 2 gets the 'log-highlight' class via :class binding
+ // 4. CSS provides yellow/warning background color on "ERROR" only
+ // 5. No HTML injection occurs - just multiple safe text spans
+
+ expect($legitimateLog)->toContain('ERROR');
+ });
+
+ it('verifies no HTML injection occurs during search', function () {
+ $logWithHtml = 'User input: ';
+ $escaped = htmlspecialchars($logWithHtml);
+
+ // Even if log contains malicious HTML:
+ // 1. Server escapes it
+ // 2. x-text renders as plain text
+ // 3. Search highlighting uses CSS class, not HTML tags
+ // 4. User sees the literal text with highlight background
+ // 5. No script execution possible
+
+ expect($escaped)->toContain('<img');
+ expect($escaped)->toContain('onerror');
+ expect($escaped)->not->toContain(' alert("XSS")';
+
+ // The search query is used in matchesSearch() which does:
+ // line.toLowerCase().includes(this.searchQuery.toLowerCase())
+ // This is safe string comparison, no HTML parsing
+
+ expect($userSearchQuery)->toContain('