From 36b63a8c109a5a805e1835f67cf56d1a746f3bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20B=C3=BCrk?= <stefan@buerk.tech> Date: Sun, 21 Apr 2024 17:47:29 +0200 Subject: [PATCH] [BUGFIX] Use Guzzle request to fetch external error page content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TYPO3 provides the ability to configure different error handlers for specific (or all) error codes, as well as the error handler type to use (core handler or custom). The `\TYPO3\CMS\Core\Error\PageErrorHandler\PageContentErrorHandler` allows to specify a target using the link handler. The default config allows to define `LinkService::TYPE_PAGE` and `LinkService::TYPE_URL`. After implementing and stabilizing the TYPO3 sub-request feature, this particular error handler has been refactored to use an internal sub-request to resolve the error page content: An external URL not matching the same instance or having a page unavailable within the TYPO3 instance will not return any content. This change modifies the `PageContentErrorHandler` to send a Guzzle request for an external URL instead of using internal sub-requests, even if it would access the originating instance again. A limitation is that the requested URL **must** return a HTTP status-code 200. A custom request header is attached to this request. It is then checked to avoid recurring errors or loops in the page-resolving workflow. Resolves: #103399 Related: #98396 Related: #94402 Releases: main, 12.4 Change-Id: If09158abd2aa9246bcb7a4fa41a0ad6e4a0f942c Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/83947 Reviewed-by: Thomas Hohn <tho@gyldendal.dk> Reviewed-by: Stefan Bürk <stefan@buerk.tech> Tested-by: Benni Mack <benni@typo3.org> Tested-by: core-ci <typo3@b13.com> Tested-by: Christian Kuhn <lolli@schwarzbu.ch> Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch> Reviewed-by: Benni Mack <benni@typo3.org> Tested-by: Stefan Bürk <stefan@buerk.tech> --- .../PageContentErrorHandler.php | 57 +++++++++++++++++++ .../core/Tests/Unit/Site/Entity/SiteTest.php | 5 ++ 2 files changed, 62 insertions(+) diff --git a/typo3/sysext/core/Classes/Error/PageErrorHandler/PageContentErrorHandler.php b/typo3/sysext/core/Classes/Error/PageErrorHandler/PageContentErrorHandler.php index fadf96c1e7f1..9a68a9a1a379 100644 --- a/typo3/sysext/core/Classes/Error/PageErrorHandler/PageContentErrorHandler.php +++ b/typo3/sysext/core/Classes/Error/PageErrorHandler/PageContentErrorHandler.php @@ -17,10 +17,13 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Error\PageErrorHandler; +use GuzzleHttp\Exception\GuzzleException; +use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use TYPO3\CMS\Core\Exception\SiteNotFoundException; +use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory; use TYPO3\CMS\Core\Http\HtmlResponse; use TYPO3\CMS\Core\Http\Uri; use TYPO3\CMS\Core\LinkHandling\LinkService; @@ -44,6 +47,8 @@ class PageContentErrorHandler implements PageErrorHandlerInterface protected ResponseFactoryInterface $responseFactory; protected SiteFinder $siteFinder; protected LinkService $link; + protected RequestFactoryInterface $requestFactory; + protected GuzzleClientFactory $guzzleClientFactory; /** * PageContentErrorHandler constructor. @@ -63,6 +68,8 @@ class PageContentErrorHandler implements PageErrorHandlerInterface $this->responseFactory = $container->get(ResponseFactoryInterface::class); $this->siteFinder = GeneralUtility::makeInstance(SiteFinder::class); $this->link = $container->get(LinkService::class); + $this->requestFactory = $container->get(RequestFactoryInterface::class); + $this->guzzleClientFactory = $container->get(GuzzleClientFactory::class); } public function handlePageError(ServerRequestInterface $request, string $message, array $reasons = []): ResponseInterface @@ -70,6 +77,7 @@ class PageContentErrorHandler implements PageErrorHandlerInterface try { $urlParams = $this->link->resolve($this->errorHandlerConfiguration['errorContentSource']); $urlParams['pageuid'] = (int)($urlParams['pageuid'] ?? 0); + $urlType = $urlParams['type'] ?? LinkService::TYPE_UNKNOWN; $resolvedUrl = $this->resolveUrl($request, $urlParams); // avoid denial-of-service amplification scenario @@ -79,6 +87,11 @@ class PageContentErrorHandler implements PageErrorHandlerInterface $this->statusCode ); } + // External URL most likely pointing to additional hosts or pages not contained in the current instance, + // and using internal sub requests would never receive a valid page. Send an external request instead. + if ($urlType === LinkService::TYPE_URL) { + return $this->sendExternalRequest($resolvedUrl, $request); + } // Create a sub-request and do not take any special query parameters into account $subRequest = $request->withQueryParams([])->withUri(new Uri($resolvedUrl))->withMethod('GET'); $subResponse = $this->stashEnvironment(fn(): ResponseInterface => $this->sendSubRequest($subRequest, $urlParams['pageuid'], $request)); @@ -128,6 +141,50 @@ class PageContentErrorHandler implements PageErrorHandlerInterface return $this->application->handle($request); } + /** + * Sends an external request to fetch the error page from a remote resource. + * + * A custom header is added and checked to mitigate request loops, which + * indicates additional configuration error in the error handler config. + */ + protected function sendExternalRequest(string $url, ServerRequestInterface $originalRequest): ResponseInterface + { + if ($originalRequest->hasHeader('Requested-By') + && in_array('TYPO3 Error Handler', $originalRequest->getHeader('Requested-By'), true) + ) { + // If the header is set here, it is a recursive call within the same instance where an + // outer error handler called a page that results in another error handler call. To break + // the loop, we except here. + return new HtmlResponse( + 'The error page could not be resolved, the error page itself is not accessible', + $this->statusCode + ); + } + try { + $request = $this->requestFactory->createRequest('GET', $url) + ->withHeader('Content-Type', 'text/html') + ->withHeader('Requested-By', 'TYPO3 Error Handler'); + $response = $this->guzzleClientFactory->getClient()->send($request); + // In case global guzzle configuration has been changed to not throw an exception + // for error status codes, the response status code is checked here. + if ($response->getStatusCode() >= 300) { + return new HtmlResponse( + 'The error page could not be resolved, as the error page itself is not accessible', + $this->statusCode + ); + } + return $this->responseFactory + ->createResponse($this->statusCode) + ->withHeader('Content-Type', $response->getHeader('Content-Type')) + ->withBody($response->getBody()); + } catch (GuzzleException) { + return new HtmlResponse( + 'The error page could not be resolved, the error page itself is not accessible', + $this->statusCode + ); + } + } + /** * Resolve the URL (currently only page and external URL are supported) */ diff --git a/typo3/sysext/core/Tests/Unit/Site/Entity/SiteTest.php b/typo3/sysext/core/Tests/Unit/Site/Entity/SiteTest.php index 7285c9be5b25..787a64c37f19 100644 --- a/typo3/sysext/core/Tests/Unit/Site/Entity/SiteTest.php +++ b/typo3/sysext/core/Tests/Unit/Site/Entity/SiteTest.php @@ -19,6 +19,7 @@ namespace TYPO3\CMS\Core\Tests\Unit\Site\Entity; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ResponseFactoryInterface; use Symfony\Component\DependencyInjection\Container; use TYPO3\CMS\Core\Cache\CacheManager; @@ -190,7 +191,9 @@ final class SiteTest extends UnitTestCase $container = new Container(); $container->set(Application::class, $app); $container->set(Features::class, new Features()); + $container->set(GuzzleClientFactory::class, new GuzzleClientFactory()); $container->set(RequestFactory::class, new RequestFactory(new GuzzleClientFactory())); + $container->set(RequestFactoryInterface::class, new RequestFactory(new GuzzleClientFactory())); $container->set(ResponseFactoryInterface::class, new ResponseFactory()); $container->set(LinkService::class, $link); $container->set(SiteFinder::class, $siteFinder); @@ -267,7 +270,9 @@ final class SiteTest extends UnitTestCase $container = new Container(); $container->set(Application::class, $app); $container->set(Features::class, new Features()); + $container->set(GuzzleClientFactory::class, new GuzzleClientFactory()); $container->set(RequestFactory::class, new RequestFactory(new GuzzleClientFactory())); + $container->set(RequestFactoryInterface::class, new RequestFactory(new GuzzleClientFactory())); $container->set(ResponseFactoryInterface::class, new ResponseFactory()); $container->set(LinkService::class, $link); $container->set(SiteFinder::class, $siteFinder); -- GitLab