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/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index e225f1e39..f86d88208 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -96,9 +96,18 @@ public function instantSave() public function toggleTimestamps() { + $previousValue = $this->showTimeStamps; $this->showTimeStamps = ! $this->showTimeStamps; - $this->instantSave(); - $this->getLogs(true); + + 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() 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/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index d054f083e..1d1ffca1e 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -11,6 +11,7 @@ intervalId: null, showTimestamps: true, searchQuery: '', + renderTrigger: 0, deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}', makeFullscreen() { this.fullscreen = !this.fullscreen; @@ -52,15 +53,53 @@ return text.toLowerCase().includes(this.searchQuery.toLowerCase()); }, decodeHtml(text) { - const doc = new DOMParser().parseFromString(text, 'text/html'); - return doc.documentElement.textContent; + // 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; }, - highlightMatch(text) { + renderHighlightedLog(el, text) { const decoded = this.decodeHtml(text); - if (!this.searchQuery.trim()) return decoded; - const escaped = this.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, String.fromCharCode(92) + '$&'); - const regex = new RegExp('(' + escaped + ')', 'gi'); - return decoded.replace(regex, '$1'); + 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; @@ -94,6 +133,17 @@ a.download = 'deployment-' + this.deploymentId + '-' + timestamp + '.txt'; a.click(); URL.revokeObjectURL(url); + }, + init() { + // Re-render logs after Livewire updates + document.addEventListener('livewire:navigated', () => { + this.$nextTick(() => { this.renderTrigger++; }); + }); + Livewire.hook('commit', ({ succeed }) => { + succeed(() => { + this.$nextTick(() => { this.renderTrigger++; }); + }); + }); } }">
- - +
-
No matches found.
@@ -219,7 +269,7 @@ class="shrink-0 text-gray-500">{{ $line['timestamp'] }} 'font-bold' => isset($line['command']) && $line['command'], 'whitespace-pre-wrap', ]) - x-html="highlightMatch($el.dataset.lineText)">{!! htmlspecialchars($lineContent) !!} + x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)">
@empty No logs yet. diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 89f6a1904..3a847bf43 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -6,6 +6,7 @@ alwaysScroll: false, intervalId: null, searchQuery: '', + renderTrigger: 0, containerName: '{{ $container ?? "logs" }}', makeFullscreen() { this.fullscreen = !this.fullscreen; @@ -47,19 +48,49 @@ return line.toLowerCase().includes(this.searchQuery.toLowerCase()); }, decodeHtml(text) { - const doc = new DOMParser().parseFromString(text, 'text/html'); - return doc.documentElement.textContent; + // Decode HTML entities, handling double-encoding + let decoded = text; + let prev = ''; + while (decoded !== prev) { + prev = decoded; + const doc = new DOMParser().parseFromString(decoded, 'text/html'); + decoded = doc.documentElement.textContent; + } + return decoded; }, - highlightMatch(text) { + renderHighlightedLog(el, text) { const decoded = this.decodeHtml(text); - if (!this.searchQuery.trim()) return this.styleTimestamp(decoded); - const escaped = this.searchQuery.replace(/[.*+?^${}()|[\]\\]/g, String.fromCharCode(92) + '$&'); - const regex = new RegExp('(' + escaped + ')', 'gi'); - const highlighted = decoded.replace(regex, '$1'); - return this.styleTimestamp(highlighted); - }, - styleTimestamp(text) { - return text.replace(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)/g, '$1'); + 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; @@ -93,8 +124,17 @@ 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++; }); + }); + }); } - }" x-init="if (expanded) { $wire.getLogs(); }"> + }">
- - +
@if ($outputs)
-
No matches found.
@foreach (explode("\n", $outputs) as $line) - @php - // Skip empty lines - if (trim($line) === '') { - continue; - } + @php + // Skip empty lines + if (trim($line) === '') { + continue; + } - // Escape HTML for safety - $escapedLine = htmlspecialchars($line); - @endphp -
- {!! preg_replace( - '/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)/', - '$1', - $escapedLine, - ) !!} -
+ // 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) + {{ $timestamp }} + @endif + +
@endforeach
@else 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('