diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php
index bbafdd24b..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;
@@ -159,7 +155,7 @@
if (matches && query) count++;
if (textSpan) {
- const originalText = this.decodeHtml(textSpan.dataset.lineText || '');
+ const originalText = textSpan.dataset.lineText || '';
if (!query) {
textSpan.textContent = originalText;
} else if (matches) {
@@ -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 f74ab6dc5..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;
@@ -163,7 +159,7 @@
// Update highlighting
if (textSpan) {
- const originalText = this.decodeHtml(textSpan.dataset.lineText || '');
+ const originalText = textSpan.dataset.lineText || '';
if (!query) {
textSpan.textContent = originalText;
} else if (matches) {
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('
';
- $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('