Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
use Yiisoft\FriendlyException\FriendlyExceptionInterface;
use Yiisoft\Http\Header;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionMethod;
Expand Down Expand Up @@ -45,11 +46,17 @@
use function ob_implicit_flush;
use function ob_start;
use function realpath;
use function preg_match;
use function preg_replace;
use function preg_replace_callback;
use function preg_split;
use function str_replace;
use function str_starts_with;
use function stripos;
use function strlen;
use function count;
use function function_exists;
use function trim;

use const DIRECTORY_SEPARATOR;
use const ENT_QUOTES;
Expand Down Expand Up @@ -204,10 +211,16 @@ public function render(Throwable $t, ?ServerRequestInterface $request = null): E

public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData
{
$displayThrowable = $t instanceof CompositeException ? $t->getFirstException() : $t;
$exceptionDescription = $displayThrowable instanceof FriendlyExceptionInterface
? null
: $this->getThrowableDescription($displayThrowable);

return new ErrorData(
$this->renderTemplate($this->verboseTemplate, [
'request' => $request,
'throwable' => $t,
'exceptionDescription' => $exceptionDescription,
]),
[Header::CONTENT_TYPE => self::CONTENT_TYPE],
);
Expand Down Expand Up @@ -541,6 +554,60 @@ public function removeAnonymous(string $value): string
return $anonymousPosition !== false ? substr($value, 0, $anonymousPosition) : $value;
}

/**
* Extracts a user-facing description from throwable class PHPDoc.
*
* Takes only descriptive text before block tags and normalizes inline
* {@see ...}/{@link ...} annotations into markdown-friendly form.
*/
private function getThrowableDescription(Throwable $throwable): ?string
{
$docComment = (new ReflectionClass($throwable))->getDocComment();
if ($docComment === false) {
return null;
}

$descriptionLines = [];
foreach (preg_split('/\R/', $docComment) ?: [] as $line) {
$line = trim($line);
$line = preg_replace('/^\/\*\*?/', '', $line) ?? $line;
$line = preg_replace('/\*\/$/', '', $line) ?? $line;
$line = preg_replace('/^\*/', '', $line) ?? $line;
$line = trim($line);

if ($line !== '' && str_starts_with($line, '@')) {
break;
}

$descriptionLines[] = $line;
}

$description = trim(implode("\n", $descriptionLines));
if ($description === '') {
return null;
}

return preg_replace_callback(
'/\{@(see|link)\s+([^\s}]+)(?:\s+([^}]+))?\}/i',
static function (array $matches): string {
$target = $matches[2];
$label = trim($matches[3] ?? '');

if (preg_match('/^https?:\/\//i', $target) === 1) {
$text = $label !== '' ? $label : $target;
return '[' . $text . '](' . $target . ')';
}

if ($label !== '') {
return $label . ' (`' . $target . '`)';
}

return '`' . $target . '`';
},
$description,
) ?? $description;
}
Comment on lines +563 to +609
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getThrowableDescription() returns raw PHPDoc text (with inline tag substitutions) that can contain arbitrary HTML/Markdown. Because parseMarkdown() does not sanitize attributes/URL schemes, a PHPDoc containing HTML like <img onerror=...> or markdown links to javascript: could result in XSS when rendered. Consider escaping/removing raw HTML in the extracted description and validating link/image URL schemes before handing it to the markdown renderer.

Copilot uses AI. Check for mistakes.

/**
* Renders a template.
*
Expand Down
5 changes: 5 additions & 0 deletions templates/development.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/**
* @var ServerRequestInterface|null $request
* @var Throwable $throwable
* @var string|null $exceptionDescription
*/

$theme = $_COOKIE['yii-exception-theme'] ?? '';
Expand Down Expand Up @@ -93,6 +94,10 @@
<?= nl2br($this->htmlEncode($exceptionMessage)) ?>
</div>

<?php if ($exceptionDescription !== null): ?>
<div class="exception-description solution"><?= $this->parseMarkdown($exceptionDescription) ?></div>
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$exceptionDescription ultimately gets rendered via parseMarkdown(), which allows raw HTML output from the Markdown parser and keeps tags like <a>/<img> without stripping unsafe attributes (e.g. onerror, onclick) or javascript: URLs. Since the description is sourced from third‑party exception class PHPDoc, it should be treated as untrusted and sanitized/escaped before rendering to avoid XSS in the debug page (e.g. escape HTML before parsing markdown and/or sanitize allowed tags/attributes + URL schemes).

Suggested change
<div class="exception-description solution"><?= $this->parseMarkdown($exceptionDescription) ?></div>
<div class="exception-description solution"><?= $this->parseMarkdown($this->htmlEncode($exceptionDescription)) ?></div>

Copilot uses AI. Check for mistakes.
<?php endif ?>

<?php if ($solution !== null): ?>
<div class="solution"><?= $this->parseMarkdown($solution) ?></div>
<?php endif ?>
Expand Down
41 changes: 41 additions & 0 deletions tests/Renderer/HtmlRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
use RuntimeException;
use Yiisoft\ErrorHandler\Exception\ErrorException;
use Yiisoft\ErrorHandler\Renderer\HtmlRenderer;
use Yiisoft\ErrorHandler\Tests\Support\TestDocBlockException;
use Yiisoft\ErrorHandler\Tests\Support\TestExceptionWithoutDocBlock;
use Yiisoft\ErrorHandler\Tests\Support\TestFriendlyException;
use Yiisoft\ErrorHandler\Tests\Support\TestHelper;

use function dirname;
Expand Down Expand Up @@ -66,6 +69,44 @@ public function testVerboseOutput(): void
$this->assertStringContainsString($exceptionMessage, (string) $errorData);
}

public function testVerboseOutputRendersThrowableDescriptionFromDocComment(): void
{
$renderer = new HtmlRenderer();
$exception = new TestDocBlockException('exception-test-message');

$errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock());
$result = (string) $errorData;

$this->assertStringContainsString('<div class="exception-description solution">', $result);
$this->assertStringContainsString('Test summary with <code>RuntimeException</code>.', $result);
$this->assertStringContainsString(
'<a href="https://www.yiiframework.com">Yii Framework</a>',
$result,
);
}

public function testVerboseOutputDoesNotRenderThrowableDescriptionWhenNoDocComment(): void
{
$renderer = new HtmlRenderer();
$exception = new TestExceptionWithoutDocBlock('exception-test-message');

$errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock());

$this->assertStringNotContainsString('<div class="exception-description solution">', (string) $errorData);
}

public function testVerboseOutputKeepsFriendlyExceptionBehaviorWithoutDescriptionDuplication(): void
{
$renderer = new HtmlRenderer();
$exception = new TestFriendlyException('exception-test-message');

$errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock());
$result = (string) $errorData;

$this->assertStringContainsString('<div class="solution">', $result);
$this->assertStringNotContainsString('<div class="exception-description solution">', $result);
}

public function testNonVerboseOutputWithCustomTemplate(): void
{
$templateFileContents = '<html><?php echo $throwable->getMessage();?></html>';
Expand Down
16 changes: 16 additions & 0 deletions tests/Support/TestDocBlockException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ErrorHandler\Tests\Support;

use RuntimeException;

/**
* Test summary with {@see RuntimeException}.
*
* More details with {@link https://www.yiiframework.com Yii Framework}.
*
* @link https://example.com Ignored tag.
*/
final class TestDocBlockException extends RuntimeException {}
9 changes: 9 additions & 0 deletions tests/Support/TestExceptionWithoutDocBlock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Yiisoft\ErrorHandler\Tests\Support;

use RuntimeException;

final class TestExceptionWithoutDocBlock extends RuntimeException {}
Loading