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
14 changes: 10 additions & 4 deletions Classes/Configuration/CorsOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ public function __construct(array $options)
$this->allowHeaders,
(isset($options['simpleHeaders']) ? (array)$options['simpleHeaders'] : [])
);
$this->allowHeaders = array_map('strtolower', $this->allowHeaders);
$this->allowMethods = isset($options['allowMethods']) ?
array_map('strtoupper', (array)$options['allowMethods']) : $this->allowMethods;
$this->exposeHeaders = isset($options['exposeHeaders']) ? (array)$options['exposeHeaders'] : $this->exposeHeaders;
$this->allowHeaders = $this->normalizeList($this->allowHeaders, 'strtolower');
$this->allowMethods = $this->normalizeList((array)($options['allowMethods'] ?? $this->allowMethods), 'strtoupper');
$this->exposeHeaders = $this->normalizeList((array)($options['exposeHeaders'] ?? $this->exposeHeaders), 'strtolower');
$this->maxAge = isset($options['maxAge']) ? (int)$options['maxAge'] : $this->maxAge;
$this->originRegex = isset($options['originRegex']) ? (bool)$options['originRegex'] : $this->originRegex;
}

private function normalizeList(array $values, callable $normalizeCase): array
{
$values = array_map(static fn($value): string => $normalizeCase(trim((string)$value)), $values);

return array_values(array_unique(array_filter($values, static fn(string $value): bool => $value !== '')));
}
}
65 changes: 1 addition & 64 deletions Classes/OperationHandler/OptionsOperationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,71 +46,8 @@ public static function supports(OperationInterface $operation, Request $request)
*/
public function handle(OperationInterface $operation, Request $request, array $route, ?ResponseInterface &$response)
{
$options = $this->corsService->getOptions();
$response = $response instanceof ResponseInterface ? $response : new Response();
$response = $this->corsService->applyPreflightResponse($request, $response ?? new Response());

if ($options->allowCredentials) {
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
}

if ($options->allowMethods !== []) {
$response = $response->withHeader('Access-Control-Allow-Methods', implode(', ', $options->allowMethods));
}

if ($options->allowHeaders !== []) {
$allowHeaders = $this->corsService->isWildcard($options->allowHeaders)
? $request->headers->get('Access-Control-Request-Headers')
: implode(', ', $options->allowHeaders);

if ($allowHeaders !== null && $allowHeaders !== '') {
$response = $response->withHeader('Access-Control-Allow-Headers', $allowHeaders);
}
}

if ($options->maxAge !== null) {
$response = $response->withHeader('Access-Control-Max-Age', (string)$options->maxAge);
}

if (!$this->corsService->isAllowedOrigin($request->headers->get('Origin'), $options)) {
$response = $response->withoutHeader('Access-Control-Allow-Origin');

return;
}

$response = $response->withHeader('Access-Control-Allow-Origin', $request->headers->get('Origin'));

if (!in_array(
strtoupper($request->headers->get('Access-Control-Request-Method')),
$options->allowMethods,
true
)) {
$response = $response->withStatus(405);

return;
}

// Allow header in case-set received from client as some browsers may send it differently
if (!in_array($request->headers->get('Access-Control-Request-Method'), $options->allowMethods, true)) {
$allowMethods = array_merge(
$options->allowMethods,
[$request->headers->get('Access-Control-Request-Method')]
);
$response = $response->withHeader('Access-Control-Allow-Methods', implode(', ', $allowMethods));
}

$headers = $request->headers->get('Access-Control-Request-Headers');
if ($headers !== null) {
$headers = strtolower(trim($headers));
if ($headers !== '' && !$this->corsService->isWildcard($options->allowHeaders)) {
foreach (preg_split('{, *}', $headers) as $header) {
if (!in_array($header, $options->allowHeaders, true)) {
$response = $response->withStatus(405);

return 'Unauthorized header ' . $header;
}
}
}
}
return null;
}
}
36 changes: 7 additions & 29 deletions Classes/Processor/CorsProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,47 +14,25 @@ public function __construct(private readonly ?CorsService $corsService) {}

public function process(Request $request, ResponseInterface &$response): void
{
if (
!$this->isCorsRequest($request)
|| $this->isPreflightRequest($request)
) {
if ($this->corsService === null) {
return;
}

$options = $this->corsService->getOptions();

$requestOrigin = $request->headers->get('Origin');

if (!$this->corsService->isAllowedOrigin($requestOrigin, $options)) {
$response = $response->withoutHeader('Access-Control-Allow-Origin');
}

$response = $response->withHeader(
'Access-Control-Allow-Origin',
$requestOrigin
);

if ($options->allowCredentials) {
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
$response = $this->corsService->addVary($response, ['Origin']);
if (!$this->isCorsRequest($request) || $this->isPreflightRequest($request)) {
return;
}

if ($options->exposeHeaders !== []) {
$response = $response->withHeader(
'Access-Control-Expose-Headers',
strtolower(implode(', ', $options->exposeHeaders))
);
}
$response = $this->corsService->applyActualResponse($request, $response);
}

protected function isCorsRequest(Request $request): bool
{
return $request->headers->has('Origin')
&& $request->headers->get('Origin')
!== $request->getSchemeAndHttpHost();
return $this->corsService->isCorsRequest($request);
}

protected function isPreflightRequest(Request $request): bool
{
return $request->getMethod() === Request::METHOD_OPTIONS;
return $this->corsService->isPreflightRequest($request);
}
}
123 changes: 122 additions & 1 deletion Classes/Service/CorsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

namespace SourceBroker\T3api\Service;

use Psr\Http\Message\ResponseInterface;
use SourceBroker\T3api\Configuration\Configuration;
use SourceBroker\T3api\Configuration\CorsOptions;
use Symfony\Component\HttpFoundation\Request;
use TYPO3\CMS\Core\Utility\GeneralUtility;

class CorsService
Expand All @@ -15,6 +17,84 @@ public function getOptions(): CorsOptions
return GeneralUtility::makeInstance(CorsOptions::class, Configuration::getCors());
}

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

return $origin !== null && $origin !== '' && $origin !== $request->getSchemeAndHttpHost();
}

public function isPreflightRequest(Request $request): bool
{
return $request->isMethod(Request::METHOD_OPTIONS)
&& $request->headers->has('Origin')
&& $request->headers->has('Access-Control-Request-Method');
}

public function applyActualResponse(Request $request, ResponseInterface $response): ResponseInterface
{
// Include requests without Origin: a shared cache must not reuse those for CORS requests.
$response = $this->addVary($this->removeCorsHeaders($response), ['Origin']);
$options = $this->getOptions();
$origin = (string)$request->headers->get('Origin');
if ($origin === '' || !$this->isAllowedOrigin($origin, $options)) {
return $response;
}

$response = $this->allowOrigin($response, $origin, $options);
if ($options->exposeHeaders !== []) {
$response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $options->exposeHeaders));
}

return $response;
}

public function applyPreflightResponse(Request $request, ResponseInterface $response): ResponseInterface
{
$response = $this->addVary($response, ['Origin', 'Access-Control-Request-Method', 'Access-Control-Request-Headers']);
if (!$this->isPreflightRequest($request)) {
return $response;
}

$response = $this->removeCorsHeaders($response);
$options = $this->getOptions();
$origin = (string)$request->headers->get('Origin');
if ($origin === '' || !$this->isAllowedOrigin($origin, $options)) {
return $response->withStatus(403);
}

$method = (string)$request->headers->get('Access-Control-Request-Method');
if (!in_array(strtoupper($method), $options->allowMethods, true)) {
return $response->withStatus(405);
}

$requestedHeaders = array_values(array_filter(array_map(
static fn(string $header): string => strtolower(trim($header)),
explode(',', (string)$request->headers->get('Access-Control-Request-Headers'))
), static fn(string $header): bool => $header !== ''));
$wildcardHeaders = $this->isWildcard($options->allowHeaders);
if (!$wildcardHeaders && array_diff($requestedHeaders, $options->allowHeaders) !== []) {
return $response->withStatus(405);
}

// Grant access only after the complete preflight has been validated.
$response = $this->allowOrigin($response, $origin, $options);
// Preserve legacy case-insensitive approval while also advertising the exact requested spelling.
$methods = array_unique([...$options->allowMethods, $method]);
$response = $response->withHeader('Access-Control-Allow-Methods', implode(', ', $methods));

// Echo concrete names for wildcard configuration, including credentialed requests.
$headers = $wildcardHeaders ? $requestedHeaders : $options->allowHeaders;
if ($headers !== []) {
$response = $response->withHeader('Access-Control-Allow-Headers', implode(', ', array_unique($headers)));
}
if ($options->maxAge !== null) {
$response = $response->withHeader('Access-Control-Max-Age', (string)$options->maxAge);
}

return $response;
}

public function isAllowedOrigin(string $origin, CorsOptions $options): bool
{
if ($this->isWildcard($options->allowOrigin)) {
Expand All @@ -23,7 +103,7 @@ public function isAllowedOrigin(string $origin, CorsOptions $options): bool

if ($options->originRegex) {
foreach ($options->allowOrigin as $originRegexp) {
if (preg_match('{' . $originRegexp . '}i', $origin)) {
if (preg_match('{\A(?:' . $originRegexp . ')\z}i', $origin) === 1) {
return true;
}
}
Expand All @@ -40,4 +120,45 @@ public function isWildcard($option): bool
|| (is_array($option) && in_array('*', $option, true))
|| (is_string($option) && $option === '*');
}

private function allowOrigin(ResponseInterface $response, string $origin, CorsOptions $options): ResponseInterface
{
$response = $response->withHeader('Access-Control-Allow-Origin', $origin);
if ($options->allowCredentials) {
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
}

return $response;
}

private function removeCorsHeaders(ResponseInterface $response): ResponseInterface
{
foreach ([
'Access-Control-Allow-Origin',
'Access-Control-Allow-Credentials',
'Access-Control-Allow-Methods',
'Access-Control-Allow-Headers',
'Access-Control-Expose-Headers',
'Access-Control-Max-Age',
] as $header) {
$response = $response->withoutHeader($header);
}

return $response;
}

public function addVary(ResponseInterface $response, array $headers): ResponseInterface
{
$vary = array_values(array_filter(array_map('trim', explode(',', $response->getHeaderLine('Vary')))));
if (in_array('*', $vary, true)) {
return $response;
}
foreach ($headers as $header) {
if (!in_array(strtolower($header), array_map('strtolower', $vary), true)) {
$vary[] = $header;
}
}

return $response->withHeader('Vary', implode(', ', $vary));
}
}
50 changes: 48 additions & 2 deletions Documentation/Cors/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,60 @@ In code below there is a list of all supported configuration options and their d

- ``allowCredentials`` (``boolean``) - when set to ``true`` adds response header ``Access-Control-Allow-Credentials`` with value ``true``.

- ``allowOrigin`` (``array`` or ``string``) - can be set to ``*`` to accept any value. Can be an array with string values of allowed origins (e.g. ``['http://www.example.com', 'https://www.example.com']``) or regular expressions when ``originRegex`` is set to ``true`` (e.g. ``['http://.*\.example\.com', 'https://.*\.example\.com']``).
- ``allowOrigin`` (``array`` or ``string``) - can be set to ``*`` to accept any value. Can be an array with string values of allowed origins (e.g. ``['http://www.example.com', 'https://www.example.com']``) or regular expressions when ``originRegex`` is set to ``true`` (e.g. ``['https://[a-z0-9-]+\.example\.com']``). Patterns must match the complete origin, including its scheme and any non-default port: t3api wraps each pattern in ``\A(?:...)\z``. Existing patterns with ``^`` and ``$`` remain supported.

- ``allowHeaders`` (``array`` or ``string``) - can be set to ``*`` to accept any value or an array of strings with allowed headers (e.g. ``['Content-Type']``).

- ``allowMethods`` (``array``) - array of strings with HTTP methods (e.g. ``['GET', 'POST', 'PUT']``).

- ``exposeHeaders`` (``array``) - controls the value of ``Access-Control-Expose-Headers``.

- ``maxAge`` (``int``) - controls the value of ``Access-Control-Max-Age``.
- ``maxAge`` (``int`` or ``null``) - controls the value of ``Access-Control-Max-Age``. The default ``0`` disables preflight caching. Set a positive number of seconds to allow caching (e.g. ``600``), or ``null`` to omit the header and let the browser apply its default.

- ``originRegex`` (``boolean``) - indicates if values from ``allowOrigin`` should be treated as regular expression.

Request and response handling
=============================

An allowed cross-origin request receives ``Access-Control-Allow-Origin`` containing its request origin.
This also applies when ``allowOrigin`` is ``*``; t3api reflects the concrete origin so that
``allowCredentials`` can work with the same configuration. Combining ``*`` with ``allowCredentials=true``
allows every origin to read credentialed responses. Use an explicit list of trusted origins for private APIs.
An origin outside the configured policy receives no CORS permission headers.

A preflight is an ``OPTIONS`` request containing both ``Origin`` and ``Access-Control-Request-Method``.
It succeeds only when the origin, requested method and requested headers are allowed.
A disallowed origin returns ``403``; a disallowed method or header returns ``405``. Rejected preflights have
an empty body and no CORS permission headers. Ordinary ``OPTIONS`` without preflight headers returns an empty
successful response for an existing endpoint. Cross-origin ``OPTIONS`` without ``Access-Control-Request-Method``
uses the policy for an actual request.

Header names are compared without regard to case, and surrounding whitespace in header lists is ignored.
With ``allowHeaders`` set to ``*``, t3api returns the concrete requested header names, including
``Authorization``, instead of a literal wildcard. The configured ``simpleHeaders`` list is merged into
``allowHeaders``; by default it includes ``Accept``, ``Accept-Language``, ``Content-Language``, ``Origin``
and the configured language header. Add ``Content-Type`` to ``allowHeaders`` for JSON requests.
Method, allowed-header and exposed-header lists are trimmed and deduplicated; null and empty entries are ignored.
Methods are normalized to uppercase and header names to lowercase.

Responses include ``Vary: Origin``, including requests without an origin and requests from disallowed origins.
Preflight responses also vary by ``Access-Control-Request-Method`` and ``Access-Control-Request-Headers``.
Existing ``Vary`` values are preserved. These headers tell HTTP caches which request headers affect the response,
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.

Customizing CORS handling
=========================

``CorsService`` provides the shared policy for ``CorsProcessor`` and ``OptionsOperationHandler``.
The protected ``CorsProcessor::isCorsRequest()`` and ``CorsProcessor::isPreflightRequest()`` hooks control only
whether that processor applies headers. They do not control the subsequent operation handler:
``OptionsOperationHandler`` independently uses ``CorsService::isPreflightRequest()``.

To customize preflight detection consistently, supply the same custom ``CorsService`` implementation to both
components through dependency injection, and leave the processor hook delegating to that service.

For release changes, see :ref:`changelog`.
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
=====
- [!!!] CORS refactor: unify response handling, normalize configuration lists and improve preflight and cache behavior. Origin patterns now match complete origins. Review ``allowOrigin`` configuration and custom ``OPTIONS`` response handling when upgrading. See :ref:`cors`.
- [!!!] Embed ``goldspecdigital/oooas`` library (PHP 8.5 compatibility). The package is no longer a dependency, namespace ``GoldSpecDigital\ObjectOrientedOAS`` is renamed to ``SourceBroker\T3api\OpenApi``. Update imports in custom filters implementing ``OpenApiSupportingFilterInterface`` and custom :ref:`collection response classes <customization_collection-response-schema>`. [`commit <https://github.com/sourcebroker/t3api/commit/9d63ab0>`__]
- [!!!] ``@T3api\Serializer\Type\PasswordHash`` returns ``null`` on serialization instead of the stored password hash. Use ``@T3api\Serializer\Type\PasswordHash(serialize=true)`` to return the hash as before, see :ref:`serialization_handlers_password-hash`. [`commit <https://github.com/sourcebroker/t3api/commit/b08a512>`__]
- [!!!] Refactor expression evaluation in access checkers. Protected method ``AbstractAccessChecker::getExpressionLanguageResolver()`` is removed, use ``evaluateExpression()`` instead. [`commit <https://github.com/sourcebroker/t3api/commit/77e4c0e>`__]
Expand Down
Loading
Loading