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 matches found. +
+ @forelse ($this->logLines as $line) + @php + $lineContent = (isset($line['command']) && $line['command'] ? '[CMD]: ' : '') . trim($line['line']); + $searchableContent = $line['timestamp'] . ' ' . $lineContent; + @endphp +
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', + ]) + x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"> +
+ @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
-
-
- -
-
- Refresh - - -
-
-
-
-
-
- +
+ + + + + + -
- @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 !!} +
+ @if ($outputs) +
+
+ No matches found.
- @endforeach -
- @else -
Refresh to get the logs...
- @endif + @foreach (explode("\n", $outputs) as $line) + @php + // Skip empty lines + if (trim($line) === '') { + continue; + } + + // Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z) + $timestamp = ''; + $logContent = $line; + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z?\s*(.*)$/', $line, $matches)) { + $year = $matches[1]; + $month = $matches[2]; + $day = $matches[3]; + $time = $matches[4]; + $microseconds = isset($matches[5]) ? substr($matches[5], 0, 6) : '000000'; + $logContent = $matches[6]; + + // Convert month number to abbreviated name + $monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + $monthName = $monthNames[(int)$month - 1] ?? $month; + + // Format: 2025-Dec-04 09:44:58.198879 + $timestamp = "{$year}-{$monthName}-{$day} {$time}.{$microseconds}"; + } + + @endphp +
+ @if ($timestamp && $showTimeStamps) + {{ $timestamp }} + @endif + +
+ @endforeach +
+ @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('test
'; + $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('
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('toContain('