From 97a4c768f10b43a11e8b4b6b104b6b731cb435e0 Mon Sep 17 00:00:00 2001
From: memleakd <121398829+memleakd@users.noreply.github.com>
Date: Tue, 19 May 2026 22:34:21 +0200
Subject: [PATCH 1/2] feat(debug): add copyable error reports
Add a Copy Details action to detailed HTML exception pages.
- Render a copyable Markdown error report from the error view
- Include safe exception, environment, request, source, and stack trace context
- Omit sensitive request data, query strings, cookies, body data, and trace args
- Add tests for report rendering, escaping, previous exceptions, and privacy
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
---
app/Views/errors/html/debug.css | 77 +++++++++++
app/Views/errors/html/debug.js | 50 +++++++
app/Views/errors/html/error_exception.php | 39 +++++-
app/Views/errors/html/error_report.php | 128 ++++++++++++++++++
tests/system/Debug/ExceptionHandlerTest.php | 115 ++++++++++++++++
user_guide_src/source/changelogs/v4.8.0.rst | 5 +
.../source/installation/upgrade_480.rst | 12 ++
7 files changed, 423 insertions(+), 3 deletions(-)
create mode 100644 app/Views/errors/html/error_report.php
diff --git a/app/Views/errors/html/debug.css b/app/Views/errors/html/debug.css
index b8539a420221..2072418db1bd 100644
--- a/app/Views/errors/html/debug.css
+++ b/app/Views/errors/html/debug.css
@@ -46,9 +46,16 @@ p.lead {
.header .container {
padding: 1rem;
}
+.header-title {
+ align-items: flex-start;
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+}
.header h1 {
font-size: 2.5rem;
font-weight: 500;
+ min-width: 0;
}
.header p {
font-size: 1.2rem;
@@ -65,8 +72,58 @@ p.lead {
display: inline;
}
+.error-report {
+ flex: 0 0 auto;
+ margin-top: 0.35rem;
+}
+
+.error-report-button {
+ align-items: center;
+ background: rgba(255,255,255,0.35);
+ border: 1px solid rgba(0,0,0,0.14);
+ border-radius: 5px;
+ box-sizing: border-box;
+ color: var(--main-text-color);
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 0.82rem;
+ font-weight: 500;
+ gap: 0.35rem;
+ height: 1.875rem;
+ justify-content: center;
+ line-height: 1;
+ padding: 0 0.65rem;
+ transition: background-color 160ms ease-in-out, border-color 160ms ease-in-out, color 160ms ease-in-out, box-shadow 160ms ease-in-out;
+ white-space: nowrap;
+ width: 7.15rem;
+}
+
+.error-report-button:hover {
+ background: rgba(255,255,255,0.6);
+ border-color: rgba(0,0,0,0.22);
+ color: var(--dark-text-color);
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04);
+}
+
+.error-report-button:focus-visible {
+ border-color: rgba(0,0,0,0.35);
+ outline: 0;
+ box-shadow: 0 0 0 2px rgba(220,72,20,0.16);
+}
+
+.error-report-button:active {
+ background: rgba(255,255,255,0.75);
+}
+
+.error-report-icon {
+ flex: 0 0 auto;
+ height: 0.72rem;
+ width: 0.72rem;
+}
+
.environment {
background: var(--brand-primary-color);
+ box-sizing: border-box;
color: var(--main-bg-color);
text-align: center;
padding: calc(4px + 0.2083vw);
@@ -75,6 +132,26 @@ p.lead {
position: fixed;
}
+@media (max-width: 40rem) {
+ .header {
+ margin-top: 0;
+ }
+
+ .header p {
+ font-size: 1.1rem;
+ line-height: 1.45;
+ margin-top: 0.35rem;
+ overflow-wrap: anywhere;
+ }
+
+ .environment {
+ font-size: 0.9rem;
+ line-height: 1.25;
+ padding: 0.45rem 0.75rem;
+ position: static;
+ }
+}
+
.source {
background: #343434;
color: var(--light-text-color);
diff --git a/app/Views/errors/html/debug.js b/app/Views/errors/html/debug.js
index 99199cac872c..320f30cdb426 100644
--- a/app/Views/errors/html/debug.js
+++ b/app/Views/errors/html/debug.js
@@ -114,3 +114,53 @@ function toggle(elem)
return false;
}
+
+function copyErrorReport(reportId, button)
+{
+ var report = document.getElementById(reportId);
+
+ if (navigator.clipboard && window.isSecureContext)
+ {
+ navigator.clipboard
+ .writeText(report.value)
+ .then(() => showCopiedButton(button))
+ .catch(() => copyErrorReportWithFallback(report.value, button));
+
+ return false;
+ }
+
+ copyErrorReportWithFallback(report.value, button);
+
+ return false;
+}
+
+function copyErrorReportWithFallback(text, button)
+{
+ var textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.top = '-1000px';
+ textarea.style.left = '-1000px';
+
+ document.body.appendChild(textarea);
+ textarea.select();
+
+ if (document.execCommand('copy'))
+ {
+ showCopiedButton(button);
+ }
+
+ document.body.removeChild(textarea);
+}
+
+function showCopiedButton(button)
+{
+ button.defaultHtml = button.defaultHtml || button.innerHTML;
+ button.innerHTML = 'Copied!';
+
+ window.clearTimeout(button.copyResetTimer);
+ button.copyResetTimer = window.setTimeout(() => {
+ button.innerHTML = button.defaultHtml;
+ }, 1500);
+}
diff --git a/app/Views/errors/html/error_exception.php b/app/Views/errors/html/error_exception.php
index 2c4e00911365..eec30a3037dd 100644
--- a/app/Views/errors/html/error_exception.php
+++ b/app/Views/errors/html/error_exception.php
@@ -3,6 +3,7 @@
use CodeIgniter\CodeIgniter;
$errorId = uniqid('error', true);
+$copyableErrorReportId = $errorId . 'copyableErrorReport';
?>
@@ -30,7 +31,38 @@
Environment: = ENVIRONMENT ?>
-
= esc($title), esc($exception->getCode() ? ' #' . $exception->getCode() : '') ?>
+
= nl2br(esc($exception->getMessage())) ?>
getMessage())) ?>"
@@ -342,8 +374,9 @@
setStatusCode(http_response_code());
+ $response = service('response');
+ $responseStatusCode = http_response_code();
+ $response->setStatusCode($responseStatusCode === false || $responseStatusCode === 0 ? $code : $responseStatusCode);
?>
diff --git a/app/Views/errors/html/error_report.php b/app/Views/errors/html/error_report.php
new file mode 100644
index 000000000000..5429be6d7d37
--- /dev/null
+++ b/app/Views/errors/html/error_report.php
@@ -0,0 +1,128 @@
+setStatusCode($code);
+
+$report = [
+ '# ' . $reportTitle,
+ '',
+ '## Exception',
+ '',
+ '- Type: ' . $type,
+ '- Status Code: ' . $code,
+ '- Status: ' . $reportResponse->getReasonPhrase(),
+ $messageLines ? '- Message:' : '- Message: ' . $reportMessage,
+];
+
+if ($messageLines) {
+ $report[] = '';
+ $report[] = '```text';
+ $report[] = $reportMessage;
+ $report[] = '```';
+}
+
+$report[] = '';
+$report[] = '## Environment';
+$report[] = '';
+$report[] = '- PHP: ' . PHP_VERSION;
+$report[] = '- CodeIgniter: ' . CodeIgniter::CI_VERSION;
+$report[] = '- Environment: ' . ENVIRONMENT;
+$report[] = '- SAPI: ' . PHP_SAPI;
+$report[] = '- Time: ' . date('Y-m-d H:i:s e');
+$report[] = '- Memory Usage: ' . number_format(memory_get_usage(true) / 1024 / 1024, 2) . ' MB';
+
+$reportRequest = service('request');
+
+if ($reportRequest instanceof IncomingRequest) {
+ $reportPath = '/' . ltrim($reportRequest->getPath(), '/');
+ $reportUri = $reportRequest->getUri();
+ $reportUrl = $reportPath;
+
+ if ($reportUri->getHost() !== '') {
+ $reportUrl = URI::createURIString(
+ $reportUri->getScheme(),
+ $reportUri->getHost() . ($reportUri->getPort() === null ? '' : ':' . $reportUri->getPort()),
+ $reportPath,
+ );
+ }
+
+ $report[] = '';
+ $report[] = '## Request';
+ $report[] = '';
+ $report[] = '- Method: ' . $reportRequest->getMethod();
+ $report[] = '- Path: ' . $reportPath;
+ $report[] = '- URL: ' . $reportUrl;
+ $report[] = '- User Agent: ' . $reportRequest->getUserAgent()->getAgentString();
+}
+
+$report[] = '';
+$report[] = '## Source';
+$report[] = '';
+$report[] = '`' . clean_path($file) . ':' . $line . '`';
+
+if (is_file($file) && is_readable($file)) {
+ $sourceLines = file($file, FILE_IGNORE_NEW_LINES);
+
+ if ($sourceLines !== false) {
+ $startLine = max($line - 5, 1);
+ $endLine = min($line + 5, count($sourceLines));
+
+ $report[] = '';
+ $report[] = '```php';
+
+ for ($sourceLine = $startLine; $sourceLine <= $endLine; $sourceLine++) {
+ $report[] = sprintf(
+ '%s%4d %s',
+ $sourceLine === $line ? '>' : ' ',
+ $sourceLine,
+ $sourceLines[$sourceLine - 1],
+ );
+ }
+
+ $report[] = '```';
+ }
+}
+
+$previousException = $exception->getPrevious();
+
+if ($previousException instanceof Throwable) {
+ $report[] = '';
+ $report[] = '## Previous Exceptions';
+
+ while ($previousException instanceof Throwable) {
+ $report[] = '* ' . $previousException::class . ' - ' . $previousException->getMessage();
+ $report[] = ' ' . clean_path($previousException->getFile()) . ':' . $previousException->getLine();
+
+ $previousException = $previousException->getPrevious();
+ }
+}
+
+if ($trace !== []) {
+ $report[] = '';
+ $report[] = '## Stack Trace';
+ $report[] = '';
+ $report[] = '```text';
+
+ foreach (array_slice($trace, 0, 50) as $reportIndex => $reportRow) {
+ $reportLocation = isset($reportRow['file'], $reportRow['line'])
+ ? clean_path($reportRow['file']) . ':' . $reportRow['line']
+ : '{PHP internal code}';
+ $reportCall = ($reportRow['class'] ?? '') . ($reportRow['type'] ?? '') . ($reportRow['function'] ?? '');
+
+ $report[] = $reportIndex . ' ' . $reportLocation . ($reportCall === '' ? '' : ' ' . $reportCall . '()');
+ }
+
+ $report[] = '```';
+}
+
+echo esc(implode("\n", $report)) . "\n";
diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php
index 43086860b0d6..0b44a9d83b23 100644
--- a/tests/system/Debug/ExceptionHandlerTest.php
+++ b/tests/system/Debug/ExceptionHandlerTest.php
@@ -16,9 +16,13 @@
use App\Controllers\Home;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\Exceptions\RuntimeException;
+use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\SiteURI;
+use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\IniTestTrait;
use CodeIgniter\Test\StreamFilterTrait;
+use Config\App;
use Config\Exceptions as ExceptionsConfig;
use Config\Services;
use PHPUnit\Framework\Attributes\Group;
@@ -104,6 +108,65 @@ public function testCollectVars(): void
foreach (['title', 'type', 'code', 'message', 'file', 'line', 'trace'] as $key) {
$this->assertArrayHasKey($key, $vars);
}
+
+ $this->assertArrayNotHasKey('copyableErrorReport', $vars);
+ }
+
+ public function testCopyErrorReportIncludesPreviousExceptions(): void
+ {
+ $previous = new RuntimeException('Root cause.');
+ $exception = new RuntimeException('Top level.', 0, $previous);
+
+ $report = $this->extractCopyableErrorReport($this->renderHtmlException($exception));
+
+ $this->assertStringContainsString('## Previous Exceptions', $report);
+ $this->assertStringContainsString('* CodeIgniter\Exceptions\RuntimeException - Root cause.', $report);
+ }
+
+ public function testCopyErrorReportOmitsSensitiveRequestDataAndTraceArgs(): void
+ {
+ $exception = $this->createExceptionWithSensitiveTraceArgument();
+
+ $_COOKIE['debug_cookie'] = 'cookie-secret';
+ $_POST['debug_post'] = 'post-secret';
+
+ try {
+ $report = $this->extractCopyableErrorReport($this->renderHtmlException($exception));
+
+ $this->assertStringNotContainsString('secret-token', $report);
+ $this->assertStringNotContainsString('cookie-secret', $report);
+ $this->assertStringNotContainsString('post-secret', $report);
+ $this->assertStringNotContainsString('$_COOKIE', $report);
+ $this->assertStringNotContainsString('$_POST', $report);
+ } finally {
+ unset($_COOKIE['debug_cookie'], $_POST['debug_post']);
+ }
+ }
+
+ public function testCopyErrorReportOmitsQueryStringFromUrl(): void
+ {
+ $config = new App();
+ $secret = 'query-secret';
+ $token = '?token=';
+ $request = new IncomingRequest(
+ $config,
+ new SiteURI($config, '/orders?token=' . $secret, 'example.test', 'https'),
+ null,
+ new UserAgent(),
+ );
+
+ Services::injectMock('request', $request);
+
+ try {
+ $report = $this->extractCopyableErrorReport($this->renderHtmlException(new RuntimeException('Query test.')));
+
+ $this->assertStringContainsString('- Path: /orders', $report);
+ $this->assertStringContainsString('- URL: https://example.test/orders', $report);
+ $this->assertStringNotContainsString($secret, $report);
+ $this->assertStringNotContainsString($token, $report);
+ } finally {
+ $this->resetServices();
+ }
}
public function testHandleWebPageNotFoundExceptionDoNotAcceptHTML(): void
@@ -141,6 +204,27 @@ public function testHandleWebPageNotFoundExceptionAcceptHTML(): void
$this->assertStringContainsString('404 - Page Not Found', (string) $output);
}
+ public function testHandleWebRuntimeExceptionAcceptHTMLIncludesCopyErrorReport(): void
+ {
+ $output = $this->renderHtmlException(new RuntimeException('Something went wrong.'));
+ $report = $this->extractCopyableErrorReport($output);
+
+ $this->assertStringContainsString('Copy Details', $output);
+ $this->assertStringContainsString('# Something went wrong.', $report);
+
+ foreach (['## Exception', '## Environment', '## Request', '## Source', '## Stack Trace'] as $section) {
+ $this->assertStringContainsString($section, $report);
+ }
+ }
+
+ public function testHandleWebRuntimeExceptionEscapesCopyErrorReport(): void
+ {
+ $output = $this->renderHtmlException(new RuntimeException(''));
+
+ $this->assertStringNotContainsString('', $output);
+ $this->assertStringContainsString('</textarea><script>alert(1)</script>', $output);
+ }
+
public function testHandleCLIPageNotFoundException(): void
{
$exception = PageNotFoundException::forControllerNotFound('Foo', 'bar');
@@ -385,4 +469,35 @@ public function testSanitizeDataWithScalars(): void
$this->assertFalse($sanitizeData(false));
$this->assertNull($sanitizeData(null));
}
+
+ private function createExceptionWithSensitiveTraceArgument(): RuntimeException
+ {
+ return new RuntimeException('Trace argument test.');
+ }
+
+ private function extractCopyableErrorReport(string $output): string
+ {
+ $this->assertSame(1, preg_match('#)#s', $output, $matches));
+
+ return html_entity_decode($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ }
+
+ private function renderHtmlException(RuntimeException $exception): string
+ {
+ $this->backupIniValues([
+ 'highlight.comment', 'highlight.default', 'highlight.html', 'highlight.keyword', 'highlight.string',
+ ]);
+
+ $render = self::getPrivateMethodInvoker($this->handler, 'render');
+
+ ob_start();
+
+ try {
+ $render($exception, 500, APPPATH . 'Views/errors/html/error_exception.php');
+
+ return ob_get_clean();
+ } finally {
+ $this->restoreIniValues();
+ }
+ }
}
diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst
index 7670598cde1f..db0d44479842 100644
--- a/user_guide_src/source/changelogs/v4.8.0.rst
+++ b/user_guide_src/source/changelogs/v4.8.0.rst
@@ -234,6 +234,11 @@ Others
- Added ``$db->getLastException()`` which returns the typed exception even when ``DBDebug`` is ``false``. See :ref:`database-get-last-exception`.
- Added ``DatabaseException::getDatabaseCode()`` returning the native driver error code as ``int|string``; ``getCode()`` is constrained to ``int`` by PHP's ``Throwable`` interface and cannot carry string SQLSTATE codes.
+Debug
+=====
+
+- Added a **Copy Details** button to detailed HTML exception pages.
+
Model
=====
diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst
index d2738db27158..dbb95275ec0e 100644
--- a/user_guide_src/source/installation/upgrade_480.rst
+++ b/user_guide_src/source/installation/upgrade_480.rst
@@ -76,6 +76,18 @@ Config
- app/Config/Mimes.php
- ``Config\Mimes::$mimes`` added a new key ``md`` for Markdown files.
+Error Views
+-----------
+
+- app/Views/errors/html/debug.css
+ - Added styles for the **Copy Details** button.
+- app/Views/errors/html/debug.js
+ - Added clipboard handling for the **Copy Details** button.
+- app/Views/errors/html/error_exception.php
+ - Added a **Copy Details** button to detailed HTML exception pages.
+- app/Views/errors/html/error_report.php
+ - Added a Markdown error report partial used by the **Copy Details** button.
+
All Changes
===========
From b3327fd1226bf6db7d1f8b5f5b7c30b52ec12705 Mon Sep 17 00:00:00 2001
From: memleakd <121398829+memleakd@users.noreply.github.com>
Date: Wed, 20 May 2026 12:20:08 +0200
Subject: [PATCH 2/2] refactor(debug): use response service for error report
status
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
---
app/Views/errors/html/error_report.php | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/app/Views/errors/html/error_report.php b/app/Views/errors/html/error_report.php
index 5429be6d7d37..5927c4720d3a 100644
--- a/app/Views/errors/html/error_report.php
+++ b/app/Views/errors/html/error_report.php
@@ -2,7 +2,6 @@
use CodeIgniter\CodeIgniter;
use CodeIgniter\HTTP\IncomingRequest;
-use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\URI;
$reportMessage = str_replace(["\r\n", "\r"], "\n", $message);
@@ -10,7 +9,7 @@
$reportTitle = $reportTitle === '' ? $title : explode("\n", $reportTitle, 2)[0];
$messageLines = str_contains($reportMessage, "\n");
-$reportResponse = new Response();
+$reportResponse = service('response', null, false);
$reportResponse->setStatusCode($code);
$report = [