diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 84463c17a..bc1c3c634 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -115,10 +115,6 @@ const range = selection.getRangeAt(0); return logsContainer.contains(range.commonAncestorContainer); }, - decodeHtml(text) { - const doc = new DOMParser().parseFromString(text, 'text/html'); - return doc.documentElement.textContent; - }, highlightText(el, text, query) { if (this.hasActiveLogSelection()) return; @@ -436,14 +432,14 @@ $lineContent = (isset($line['command']) && $line['command'] ? '[CMD]: ' : '') . trim($line['line']); $searchableContent = $line['timestamp'] . ' ' . $lineContent; @endphp -
isset($line['command']) && $line['command'], 'flex gap-2 log-line', ])> {{ $line['timestamp'] }} - $line['hidden'], 'text-red-500' => $line['stderr'], diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index f5bc24fb8..39938c4df 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -139,10 +139,6 @@ const range = selection.getRangeAt(0); return logsContainer.contains(range.commonAncestorContainer); }, - decodeHtml(text) { - const doc = new DOMParser().parseFromString(text, 'text/html'); - return doc.documentElement.textContent; - }, applySearch() { const logs = document.getElementById('logs'); if (!logs) return; diff --git a/tests/Unit/LogViewerXssSecurityTest.php b/tests/Unit/LogViewerXssSecurityTest.php index 98c5df3f1..0c682c13c 100644 --- a/tests/Unit/LogViewerXssSecurityTest.php +++ b/tests/Unit/LogViewerXssSecurityTest.php @@ -1,427 +1,77 @@ '.$escapedContent.''; + + $document = new DOMDocument; + $document->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + return $document->documentElement->getAttribute('data-line-text'); +} + +describe('Log Viewer HTML Tag Preservation', function () { + it('lets Blade escape deployment log data attributes only once', function () { + $view = file_get_contents(__DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php'); + + expect($view) + ->toContain('data-log-content="{{ $searchableContent }}"') + ->toContain('data-line-text="{{ $lineContent }}"') + ->not->toContain('data-log-content="{{ htmlspecialchars($searchableContent) }}"') + ->not->toContain('data-line-text="{{ htmlspecialchars($lineContent) }}"'); + }); + + it('preserves literal html-like log text for client-side search reset and highlighting', function () { + $logContent = '
A
'; + + expect(renderedDataLineTextValue($logContent))->toBe($logContent); + }); + + it('does not strip tags with DOMParser based html decoding', function () { + $views = [ + __DIR__.'/../../resources/views/livewire/project/application/deployment/show.blade.php', + __DIR__.'/../../resources/views/livewire/project/shared/get-logs.blade.php', + ]; + + foreach ($views as $view) { + expect(file_get_contents($view)) + ->not->toContain('decodeHtml(text)') + ->not->toContain('DOMParser().parseFromString'); + } + }); +}); + describe('Log Viewer XSS Prevention', function () { - it('escapes script tags in log output', function () { + it('keeps script-like log output as text in Blade rendered markup', function () { $maliciousLog = ''; - $escaped = htmlspecialchars($maliciousLog); + $escapedLog = e($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('