Skip to content
Merged
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
33 changes: 33 additions & 0 deletions Classes/Exception/OriginNotAllowedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Exception;

use SourceBroker\T3api\OpenApi\Objects\Response as OpenApiResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

/**
* Cross-origin request changing data from an origin not allowed by the CORS configuration, see `OriginValidationProcessor`.
*/
class OriginNotAllowedException extends AbstractException implements OpenApiSupportingExceptionInterface
{
public static function getOpenApiResponse(): OpenApiResponse
{
return parent::getOpenApiResponse()
->statusCode(SymfonyResponse::HTTP_FORBIDDEN)
->description(self::translate('exception.origin_not_allowed.title'));
}

public function __construct(string $origin, string $method, int $code)
{
$this->title = self::translate('exception.origin_not_allowed.title');
parent::__construct(self::translate('exception.origin_not_allowed.description', [$origin, $method]), $code);
}

public function getStatusCode(): int
{
return Response::HTTP_FORBIDDEN;
}
}
38 changes: 38 additions & 0 deletions Classes/Exception/UnsupportedMediaTypeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Exception;

use SourceBroker\T3api\OpenApi\Objects\Response as OpenApiResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

class UnsupportedMediaTypeException extends AbstractException implements OpenApiSupportingExceptionInterface
{
public static function getOpenApiResponse(): OpenApiResponse
{
return parent::getOpenApiResponse()
->statusCode(SymfonyResponse::HTTP_UNSUPPORTED_MEDIA_TYPE)
->description(self::translate('exception.unsupported_media_type.title'));
}

/**
* @param string $contentType Content type of the request, empty string when the header is missing
*/
public function __construct(string $contentType, int $code)
{
$this->title = self::translate('exception.unsupported_media_type.title');
parent::__construct(
$contentType === ''
? self::translate('exception.unsupported_media_type.missing')
: self::translate('exception.unsupported_media_type.description', [$contentType]),
$code
);
}

public function getStatusCode(): int
{
return Response::HTTP_UNSUPPORTED_MEDIA_TYPE;
}
}
18 changes: 18 additions & 0 deletions Classes/OperationHandler/AbstractOperationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use SourceBroker\T3api\Event\AfterDeserializeOperationEvent;
use SourceBroker\T3api\Exception\BadRequestException;
use SourceBroker\T3api\Exception\OperationNotAllowedException;
use SourceBroker\T3api\Exception\UnsupportedMediaTypeException;
use SourceBroker\T3api\Security\OperationAccessChecker;
use SourceBroker\T3api\Serializer\ContextBuilder\DeserializationContextBuilder;
use SourceBroker\T3api\Service\CommonDateFieldsService;
Expand Down Expand Up @@ -59,12 +60,14 @@ protected function getRepositoryForOperation(OperationInterface $operation): Com
/**
* @throws BadRequestException
* @throws OperationNotAllowedException
* @throws UnsupportedMediaTypeException
*/
protected function deserializeOperation(
OperationInterface $operation,
Request $request,
?AbstractDomainObject $targetObject = null
): AbstractDomainObject {
$this->assertJsonContentType($request);
$content = $request->getContent();
$this->assertJsonObject($content);

Expand All @@ -87,6 +90,21 @@ protected function deserializeOperation(
return $afterDeserializeOperationEvent->getObject();
}

/**
* Accepts `application/json`, `application/ld+json` and other `application/*+json` types.
*
* @throws UnsupportedMediaTypeException
*/
protected function assertJsonContentType(Request $request): void
{
$contentType = trim((string)$request->headers->get('Content-Type'));
$mediaType = strtolower(trim(explode(';', $contentType)[0]));

if ($mediaType !== 'application/json' && preg_match('#^application/[a-z0-9.+-]+\+json$#', $mediaType) !== 1) {
throw new UnsupportedMediaTypeException($contentType, 1793646772);
}
}

/**
* Checked before deserialization, as the serializer reports malformed JSON with a generic runtime exception.
*
Expand Down
37 changes: 37 additions & 0 deletions Classes/Processor/OriginValidationProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace SourceBroker\T3api\Processor;

use Psr\Http\Message\ResponseInterface;
use SourceBroker\T3api\Exception\OriginNotAllowedException;
use SourceBroker\T3api\Service\CorsService;
use Symfony\Component\HttpFoundation\Request;

/**
* Rejects requests changing data (all methods except GET, HEAD, OPTIONS and TRACE) sent from another origin which
* is not allowed by the CORS configuration (`allowOrigin`), so the same origin policy applies to executing write
* requests and to reading responses.
*
* Browsers send `Origin` with every such request; requests without it (e.g. server-to-server) are not affected.
*/
class OriginValidationProcessor implements ProcessorInterface
{
public function __construct(private readonly CorsService $corsService) {}

/**
* @throws OriginNotAllowedException
*/
public function process(Request $request, ResponseInterface &$response): void
{
if ($request->isMethodSafe() || !$this->corsService->isCorsRequest($request)) {
return;
}

$origin = (string)$request->headers->get('Origin');
if (!$this->corsService->isAllowedOrigin($origin, $this->corsService->getOptions())) {
throw new OriginNotAllowedException($origin, $request->getMethod(), 1793646771);
}
}
}
19 changes: 17 additions & 2 deletions Classes/Service/CorsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,24 @@ public function getOptions(): CorsOptions

public function isCorsRequest(Request $request): bool
{
$origin = $request->headers->get('Origin');
$origin = (string)$request->headers->get('Origin');

return $origin !== '' && !$this->isSameOrigin($request, $origin);
}

/**
* Besides the `Host` header, the origin is compared with the request URL normalized by TYPO3 (respecting
* reverse proxy configuration), which is the source of `SERVER_NAME`, `SERVER_PORT` and `HTTPS` in requests
* converted from PSR-7. Behind a reverse proxy the `Host` header may contain an internal host name.
*/
public function isSameOrigin(Request $request, string $origin): bool
{
$scheme = $request->isSecure() ? 'https' : 'http';
$port = (int)$request->server->get('SERVER_PORT');
$normalizedOrigin = $scheme . '://' . $request->server->get('SERVER_NAME')
. ($port === 0 || $port === ($scheme === 'https' ? 443 : 80) ? '' : ':' . $port);

return $origin !== null && $origin !== '' && $origin !== $request->getSchemeAndHttpHost();
return in_array(strtolower($origin), [strtolower($request->getSchemeAndHttpHost()), strtolower($normalizedOrigin)], true);
}

public function isPreflightRequest(Request $request): bool
Expand Down
3 changes: 3 additions & 0 deletions Configuration/Services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ services:
SourceBroker\T3api\Processor\CorsProcessor:
public: true

SourceBroker\T3api\Processor\OriginValidationProcessor:
public: true

SourceBroker\T3api\Processor\LanguageProcessor:
public: true

Expand Down
28 changes: 26 additions & 2 deletions Documentation/Cors/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,32 @@ Existing ``Vary`` values are preserved. These headers tell HTTP caches which req
as described in the `Fetch specification <https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches>`__.
t3api's response cache stores the serialized content; CORS headers are evaluated for each request.

CORS controls whether a browser can read a response. It does not authorize an operation or provide CSRF
protection for writes authenticated with cookies. Configure operation access rules and CSRF protection separately.
.. _cors_write_requests:

Write requests from other origins
=================================

CORS controls whether a browser can read a response. Additionally t3api validates requests changing data
(all methods except ``GET``, ``HEAD``, ``OPTIONS`` and ``TRACE``) and rejects requests which:

- are sent from another origin (``Origin`` header differs from the API origin) not allowed by ``allowOrigin`` -
response ``403 Forbidden``. Requests without ``Origin`` header (e.g. server-to-server requests) are not affected,
browsers send this header with every such request,
- contain a JSON body without JSON content type (``application/json``, ``application/ld+json`` or other
``application/*+json``) - response ``415 Unsupported Media Type``.

To allow write requests from another origin (e.g. frontend application on a different domain), add it to
``allowOrigin``. Setting ``allowOrigin`` to ``*`` allows write requests from every origin.

The origin check is done by ``SourceBroker\T3api\Processor\OriginValidationProcessor``. If you need to accept
write requests from any origin without allowing them to read responses, remove the processor in ``ext_localconf.php``
of your extension:

.. code-block:: php

unset($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['t3api']['processors'][\SourceBroker\T3api\Processor\OriginValidationProcessor::class]);

CORS does not authorize an operation - configure operation access rules separately, see :ref:`security`.

Customizing CORS handling
=========================
Expand Down
1 change: 1 addition & 0 deletions Documentation/Miscellaneous/Changelog/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Changelog

5.0.0
=====
- [!!!] Validate origin and content type of requests changing data: requests from another origin not allowed by CORS ``allowOrigin`` return ``403 Forbidden``, JSON body without JSON ``Content-Type`` returns ``415 Unsupported Media Type``. Add origins of frontend applications to ``allowOrigin`` and send ``Content-Type: application/json``. See :ref:`cors_write_requests`.
- Fix response cache invalidation when the page cache is cleared from the backend (cache tags passed as a list were ignored, numeric tags caused errors).
- Log unexpected exceptions with an event ID. Outside of ``Development`` context HTTP 500 response contains a generic message with this event ID instead of the exception message. See :ref:`ThrowableSubscriber <serialization_subscribers_throwable_subscriber>`.
- Return HTTP 500 for PHP errors (e.g. ``TypeError``) as API response with CORS headers instead of rethrowing them to TYPO3. [`issue <https://github.com/sourcebroker/t3api/issues/38>`__]
Expand Down
6 changes: 6 additions & 0 deletions Documentation/Operations/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ T3api supports all REST methods. Method of the operation is set with ``method``
.. important::
Notice that ``POST`` operation is a type of collection operations

``POST``, ``PUT`` and ``PATCH`` operations expect a JSON object in the request body sent with header
``Content-Type: application/json`` (``application/ld+json`` and other ``application/*+json`` types are accepted too).
A missing or other content type returns ``415 Unsupported Media Type``, malformed JSON returns ``400 Bad Request``.
File upload operations use ``multipart/form-data`` instead. Write requests sent by a browser from another origin
have to be allowed by the CORS configuration, see :ref:`cors_write_requests`.

.. _operations_slugs:

Slug fields
Expand Down
15 changes: 15 additions & 0 deletions Resources/Private/Language/locallang.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@
<trans-unit id="exception.bad_request.language_header">
<source>Header `%s` has to contain the ID of an available language, `%s` given</source>
</trans-unit>
<trans-unit id="exception.origin_not_allowed.title">
<source>Origin not allowed</source>
</trans-unit>
<trans-unit id="exception.origin_not_allowed.description">
<source>Origin `%s` is not allowed to send `%s` requests</source>
</trans-unit>
<trans-unit id="exception.unsupported_media_type.title">
<source>Unsupported media type</source>
</trans-unit>
<trans-unit id="exception.unsupported_media_type.description">
<source>Content type `%s` is not supported, use `application/json`</source>
</trans-unit>
<trans-unit id="exception.unsupported_media_type.missing">
<source>Header `Content-Type` is required, use `application/json`</source>
</trans-unit>
<trans-unit id="exception.internal_server_error.title">
<source>Internal Server Error</source>
</trans-unit>
Expand Down
Loading
Loading