Skip to content

Commit 1e5f444

Browse files
bmackohader
authored andcommitted
[SECURITY] Avoid DoS when generating Error pages
TYPO3 now uses a lock strategy to avoid having to many request waiting for the generation of the error page (which cannot be generated via the external HTTP request, as there might be not enough workers / PHP processes available during a DoS attack). If a lock is in place, it directly returns a generic error response instead of waiting for the lock or that the error page is retrieved/rendered. Additionally, if the external error page could not be retrieved (HTTP status code other than 200), it will also create a generic response and cache that instead. This avoids keeping requesting for the errounous external HTTP page. This could happen when using external HTTP requests (Guzzle) to resolve an error page (via PageContentErrorHandler) for 404 sites. Only TYPO3 installations using the feature "subrequestPageErrors" via $TYPO3_CONF_VARS[SYS][features][subrequestPageErrors] = true are not affected as the error page is generated during the same PHP process, avoiding to create another external process. Resolves: #98384 Releases: 11.5, 10.4 Change-Id: Iae1cae882707a519b2cef85112525ea213a72eef Security-Bulletin: TYPO3-CORE-SA-2022-012 Security-References: CVE-2022-23500 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/77089 Tested-by: Oliver Hader <[email protected]> Reviewed-by: Oliver Hader <[email protected]>
1 parent 6c6f137 commit 1e5f444

File tree

1 file changed

+100
-31
lines changed

1 file changed

+100
-31
lines changed

typo3/sysext/core/Classes/Error/PageErrorHandler/PageContentErrorHandler.php

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,25 @@
1717

1818
namespace TYPO3\CMS\Core\Error\PageErrorHandler;
1919

20+
use GuzzleHttp\Exception\ClientException;
2021
use Psr\Http\Message\ResponseFactoryInterface;
2122
use Psr\Http\Message\ResponseInterface;
2223
use Psr\Http\Message\ServerRequestInterface;
2324
use TYPO3\CMS\Core\Cache\CacheManager;
2425
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
2526
use TYPO3\CMS\Core\Configuration\Features;
27+
use TYPO3\CMS\Core\Controller\ErrorPageController;
2628
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
2729
use TYPO3\CMS\Core\Http\HtmlResponse;
2830
use TYPO3\CMS\Core\Http\RequestFactory;
2931
use TYPO3\CMS\Core\Http\Response;
3032
use TYPO3\CMS\Core\Http\Stream;
3133
use TYPO3\CMS\Core\Http\Uri;
3234
use TYPO3\CMS\Core\LinkHandling\LinkService;
35+
use TYPO3\CMS\Core\Locking\Exception\LockAcquireWouldBlockException;
36+
use TYPO3\CMS\Core\Locking\LockFactory;
37+
use TYPO3\CMS\Core\Locking\LockingStrategyInterface;
38+
use TYPO3\CMS\Core\Messaging\AbstractMessage;
3339
use TYPO3\CMS\Core\Routing\InvalidRouteArgumentsException;
3440
use TYPO3\CMS\Core\Site\Entity\Site;
3541
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
@@ -92,7 +98,7 @@ public function handlePageError(ServerRequestInterface $request, string $message
9298
{
9399
try {
94100
$urlParams = $this->link->resolve($this->errorHandlerConfiguration['errorContentSource']);
95-
$urlParams['pageuid'] = (int)($urlParams['pageuid'] ?? 0);
101+
$this->pageUid = $urlParams['pageuid'] = (int)($urlParams['pageuid'] ?? 0);
96102
$resolvedUrl = $this->resolveUrl($request, $urlParams);
97103

98104
// avoid denial-of-service amplification scenario
@@ -105,13 +111,24 @@ public function handlePageError(ServerRequestInterface $request, string $message
105111
if ($this->useSubrequest) {
106112
// Create a subrequest and do not take any special query parameters into account
107113
$subRequest = $request->withQueryParams([])->withUri(new Uri($resolvedUrl))->withMethod('GET');
108-
$subResponse = $this->stashEnvironment(fn (): ResponseInterface => $this->sendSubRequest($subRequest, $urlParams['pageuid']));
114+
$subResponse = $this->stashEnvironment(fn (): ResponseInterface => $this->sendSubRequest($subRequest, $this->pageUid));
109115
} else {
116+
$cacheIdentifier = 'errorPage_' . md5($resolvedUrl);
110117
try {
111-
$subResponse = $this->cachePageRequest($resolvedUrl, $this->pageUid, fn () => $this->sendRawRequest($resolvedUrl));
118+
$subResponse = $this->cachePageRequest(
119+
$this->pageUid,
120+
fn () => $this->sendRawRequest($resolvedUrl),
121+
$cacheIdentifier
122+
);
112123
} catch (\Exception $e) {
113124
throw new \RuntimeException(sprintf('Error handler could not fetch error page "%s", reason: %s', $resolvedUrl, $e->getMessage()), 1544172838, $e);
114125
}
126+
// Ensure that 503 status code is kept, and not changed to 500.
127+
if ($subResponse->getStatusCode() === 503) {
128+
return $this->responseFactory->createResponse($subResponse->getStatusCode())
129+
->withHeader('content-type', $subResponse->getHeader('content-type'))
130+
->withBody($subResponse->getBody());
131+
}
115132
}
116133

117134
if ($subResponse->getStatusCode() >= 300) {
@@ -144,40 +161,92 @@ protected function stashEnvironment(callable $fetcher): ResponseInterface
144161
/**
145162
* Caches a subrequest fetch.
146163
*/
147-
protected function cachePageRequest(string $resolvedUrl, int $pageId, callable $fetcher): ResponseInterface
164+
protected function cachePageRequest(int $pageId, callable $fetcher, string $cacheIdentifier): ResponseInterface
148165
{
149-
$cacheIdentifier = 'errorPage_' . md5($resolvedUrl);
150166
$responseData = $this->cache->get($cacheIdentifier);
151-
152-
if (!is_array($responseData)) {
167+
if (is_array($responseData) && $responseData !== []) {
168+
return $this->createCachedPageRequestResponse($responseData);
169+
}
170+
$cacheTags = [];
171+
$cacheTags[] = 'errorPage';
172+
if ($pageId > 0) {
173+
// Cache Tag "pageId_" ensures, cache is purged when content of 404 page changes
174+
$cacheTags[] = 'pageId_' . $pageId;
175+
}
176+
$lockFactory = GeneralUtility::makeInstance(LockFactory::class);
177+
$lock = $lockFactory->createLocker(
178+
$cacheIdentifier,
179+
LockingStrategyInterface::LOCK_CAPABILITY_EXCLUSIVE | LockingStrategyInterface::LOCK_CAPABILITY_NOBLOCK
180+
);
181+
try {
182+
$locked = $lock->acquire(
183+
LockingStrategyInterface::LOCK_CAPABILITY_EXCLUSIVE | LockingStrategyInterface::LOCK_CAPABILITY_NOBLOCK
184+
);
185+
if (!$locked) {
186+
return $this->createGenericErrorResponse('Lock could not be acquired.');
187+
}
153188
/** @var ResponseInterface $response */
154189
$response = $fetcher();
155-
$cacheTags = [];
156-
if ($response->getStatusCode() === 200) {
157-
$cacheTags[] = 'errorPage';
158-
if ($pageId > 0) {
159-
// Cache Tag "pageId_" ensures, cache is purged when content of 404 page changes
160-
$cacheTags[] = 'pageId_' . $pageId;
161-
}
162-
$responseData = [
163-
'headers' => $response->getHeaders(),
164-
'body' => $response->getBody()->getContents(),
165-
'reasonPhrase' => $response->getReasonPhrase(),
166-
];
167-
$this->cache->set($cacheIdentifier, $responseData, $cacheTags);
190+
if ($response->getStatusCode() !== 200) {
191+
// External request lead to an error. Create a generic error response,
192+
// cache and use that instead of the external error response.
193+
$response = $this->createGenericErrorResponse('External error page could not be retrieved.');
168194
}
169-
} else {
170-
$body = new Stream('php://temp', 'wb+');
171-
$body->write($responseData['body'] ?? '');
172-
$body->rewind();
173-
$response = new Response(
174-
$body,
175-
200,
176-
$responseData['headers'] ?? [],
177-
$responseData['reasonPhrase'] ?? ''
178-
);
195+
$responseData = [
196+
'statuscode' => $response->getStatusCode(),
197+
'headers' => $response->getHeaders(),
198+
'body' => $response->getBody()->getContents(),
199+
'reasonPhrase' => $response->getReasonPhrase(),
200+
];
201+
$this->cache->set($cacheIdentifier, $responseData, $cacheTags);
202+
$lock->release();
203+
} catch (ClientException $e) {
204+
$response = $this->createGenericErrorResponse('External error page could not be retrieved. ' . $e->getMessage());
205+
$responseData = [
206+
'statuscode' => $response->getStatusCode(),
207+
'headers' => $response->getHeaders(),
208+
'body' => $response->getBody()->getContents(),
209+
'reasonPhrase' => $response->getReasonPhrase(),
210+
];
211+
$this->cache->set($cacheIdentifier, $responseData, $cacheTags);
212+
} catch (LockAcquireWouldBlockException $e) {
213+
// Currently a lock is active, thus returning a generic error directly to avoid
214+
// long wait times and thus consuming too much php worker processes. Caching is
215+
// not done here, as we do not know if the error page can be retrieved or not.
216+
$lock->release();
217+
return $this->createGenericErrorResponse('Lock could not be acquired. ' . $e->getMessage());
218+
} catch (\Throwable $e) {
219+
// Any other error happened
220+
$lock->release();
221+
return $this->createGenericErrorResponse('Error page could not be retrieved' . $e->getMessage());
179222
}
223+
$lock->release();
224+
return $this->createCachedPageRequestResponse($responseData);
225+
}
226+
227+
protected function createGenericErrorResponse(string $message = ''): ResponseInterface
228+
{
229+
$content = GeneralUtility::makeInstance(ErrorPageController::class)->errorAction(
230+
'Page Not Found',
231+
$message ?: 'Error page is being generated',
232+
AbstractMessage::ERROR,
233+
0,
234+
503
235+
);
236+
return new HtmlResponse($content, 503);
237+
}
180238

239+
protected function createCachedPageRequestResponse(array $responseData): ResponseInterface
240+
{
241+
$body = new Stream('php://temp', 'wb+');
242+
$body->write($responseData['body'] ?? '');
243+
$body->rewind();
244+
$response = new Response(
245+
$body,
246+
$responseData['statuscode'] ?? 200,
247+
$responseData['headers'] ?? [],
248+
$responseData['reasonPhrase'] ?? ''
249+
);
181250
return $response;
182251
}
183252

@@ -215,7 +284,7 @@ protected function getSubRequestOptions(): array
215284
$options = [];
216285
if ((int)$GLOBALS['TYPO3_CONF_VARS']['HTTP']['timeout'] === 0) {
217286
$options = [
218-
'timeout' => 30,
287+
'timeout' => 10,
219288
];
220289
}
221290
return $options;

0 commit comments

Comments
 (0)