Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: api-platform/core
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v4.1.4
Choose a base ref
...
head repository: api-platform/core
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v4.1.5
Choose a head ref
  • 6 commits
  • 14 files changed
  • 1 contributor

Commits on Apr 3, 2025

  1. Copy the full SHA
    60747cc View commit details
  2. fix(graphql): property security might be cached w/ different objects

    soyuka committed Apr 3, 2025
    Copy the full SHA
    7af65aa View commit details
  3. test: various fixes (#7063)

    soyuka authored Apr 3, 2025

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    36fd6af View commit details
  4. docs: changelog 4.0.22

    soyuka committed Apr 3, 2025
    Copy the full SHA
    8de9bf8 View commit details
  5. Merge 4.0

    soyuka committed Apr 3, 2025

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    e51273e View commit details
  6. docs: changelog 4.1.5

    soyuka committed Apr 3, 2025
    Copy the full SHA
    2a76527 View commit details
20 changes: 15 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## v4.1.5

### Bug fixes

* [60747cc8c](https://github.com/api-platform/core/commit/60747cc8c2fb855798c923b5537888f8d0969568) fix(graphql): access to unauthorized resource using node Relay [CVE-2025-31481](https://github.com/api-platform/core/security/advisories/GHSA-cg3c-245w-728m)
* [7af65aad1](https://github.com/api-platform/core/commit/7af65aad13037d7649348ee3dcd88e084ef771f8) fix(graphql): property security might be cached w/ different objects [CVE-2025-31485](https://github.com/api-platform/core/security/advisories/GHSA-428q-q3vv-3fq3)

## v4.1.4

### Bug fixes
@@ -17,9 +24,6 @@

* [8a2265041](https://github.com/api-platform/core/commit/8a22650419fd32efdafad43493f2327b38dd3ee6) fix(laravel): defer "filters" dependent services (#7045)


### Features

## v4.1.2

### Bug fixes
@@ -30,8 +34,6 @@
* [a2824ff4b](https://github.com/api-platform/core/commit/a2824ff4be6276e37e37a3b4e4fb2e9a0096789c) fix(laravel): defer autoconfiguration (#7040)


### Features

## v4.1.1

### Bug fixes
@@ -152,6 +154,14 @@ On write operations, we added the [expectsHeader](https://www.hydra-cg.com/spec/
* [d0a442786](https://github.com/api-platform/core/commit/d0a44278630d201b91cbba0774a09f4eeaac88f7) feat(doctrine): enhance getLinksHandler with method validation and typo suggestions (#6874)
* [f67f6f1ac](https://github.com/api-platform/core/commit/f67f6f1acb6476182c18a3503f2a8bc80ae89a0b) feat(doctrine): doctrine filters like laravel eloquent filters (#6775)

## v4.0.22

### Bug fixes

* [60747cc8c](https://github.com/api-platform/core/commit/60747cc8c2fb855798c923b5537888f8d0969568) fix(graphql): access to unauthorized resource using node Relay [CVE-2025-31481](https://github.com/api-platform/core/security/advisories/GHSA-cg3c-245w-728m)
* [7af65aad1](https://github.com/api-platform/core/commit/7af65aad13037d7649348ee3dcd88e084ef771f8) fix(graphql): property security might be cached w/ different objects [CVE-2025-31485](https://github.com/api-platform/core/security/advisories/GHSA-428q-q3vv-3fq3)
* [f4c426d71](https://github.com/api-platform/core/commit/f4c426d719b01debaa993b00d03cce8964057ecc) Revert "fix(doctrine): throw an exception when a filter is not found in a par…" (#7046)

## v4.0.21

### Bug fixes
55 changes: 55 additions & 0 deletions src/GraphQl/Metadata/RuntimeOperationMetadataFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\GraphQl\Metadata;

use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use Symfony\Component\Routing\Exception\ExceptionInterface as RoutingExceptionInterface;
use Symfony\Component\Routing\RouterInterface;

/**
* This factory runs in the ResolverFactory and is used to find out a Relay node's operation.
*/
final class RuntimeOperationMetadataFactory implements OperationMetadataFactoryInterface
{
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly RouterInterface $router)
{
}

public function create(string $uriTemplate, array $context = []): ?Operation
{
try {
$parameters = $this->router->match($uriTemplate);
} catch (RoutingExceptionInterface $e) {
throw new InvalidArgumentException(\sprintf('No route matches "%s".', $uriTemplate), $e->getCode(), $e);
}

if (!isset($parameters['_api_resource_class'])) {
throw new InvalidArgumentException(\sprintf('The route "%s" is not an API route, it has no resource class in the defaults.', $uriTemplate));
}

foreach ($this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class']) as $resource) {
foreach ($resource->getGraphQlOperations() ?? [] as $operation) {
if ($operation instanceof Query && !$operation->getResolver()) {
return $operation;
}
}
}

throw new InvalidArgumentException(\sprintf('No operation found for id "%s".', $uriTemplate));
}
}
15 changes: 14 additions & 1 deletion src/GraphQl/Resolver/Factory/ResolverFactory.php
Original file line number Diff line number Diff line change
@@ -15,21 +15,28 @@

use ApiPlatform\GraphQl\State\Provider\NoopProvider;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Operation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\State\Pagination\ArrayPaginator;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use GraphQL\Type\Definition\ResolveInfo;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ResolverFactory implements ResolverFactoryInterface
{
public function __construct(
private readonly ProviderInterface $provider,
private readonly ProcessorInterface $processor,
private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null,
) {
if (!$operationMetadataFactory) {
throw new InvalidArgumentException(\sprintf('Not injecting the "%s" exposes Relay nodes to a security risk.', OperationMetadataFactoryInterface::class));
}
}

public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null, ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null): callable
@@ -70,7 +77,13 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
private function resolve(?array $source, array $args, ResolveInfo $info, ?string $rootClass = null, ?Operation $operation = null, mixed $body = null)
{
// Handles relay nodes
$operation ??= new Query();
if (!$operation) {
if (!isset($args['id'])) {
throw new NotFoundHttpException('No node found.');
}

$operation = $this->operationMetadataFactory->create($args['id']);
}

$graphQlContext = [];
$context = ['source' => $source, 'args' => $args, 'info' => $info, 'root_class' => $rootClass, 'graphql_context' => &$graphQlContext];
2 changes: 2 additions & 0 deletions src/GraphQl/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
@@ -89,6 +89,8 @@ public function normalize(mixed $object, ?string $format = null, array $context

if ($this->isCacheKeySafe($context)) {
$context['cache_key'] = $this->getCacheKey($format, $context);
} else {
$context['cache_key'] = false;
}

unset($context['operation_name'], $context['operation']); // Remove operation and operation_name only when cache key has been created
144 changes: 144 additions & 0 deletions src/GraphQl/Tests/Metadata/RuntimeOperationMetadataFactoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\GraphQl\Tests\Metadata;

use ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\RouterInterface;

class RuntimeOperationMetadataFactoryTest extends TestCase
{
public function testCreate(): void
{
$resourceClass = 'Dummy';
$operationName = 'item_query';

$operation = (new Query())->withName($operationName);
$resourceMetadata = (new ApiResource())->withGraphQlOperations([$operationName => $operation]);
$resourceMetadataCollection = new ResourceMetadataCollection($resourceClass, [$resourceMetadata]);

$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataCollectionFactory->expects($this->once())
->method('create')
->with($resourceClass)
->willReturn($resourceMetadataCollection);

$router = $this->createMock(RouterInterface::class);
$router->expects($this->once())
->method('match')
->with('/dummies/1')
->willReturn([
'_api_resource_class' => $resourceClass,
'_api_operation_name' => $operationName,
]);

$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
$this->assertEquals($operation, $factory->create('/dummies/1'));
}

public function testCreateThrowsExceptionWhenRouteNotFound(): void
{
$this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class);
$this->expectExceptionMessage('No route matches "/unknown".');

$router = $this->createMock(RouterInterface::class);
$router->expects($this->once())
->method('match')
->with('/unknown')
->willThrowException(new ResourceNotFoundException());

$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);

$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
$factory->create('/unknown');
}

public function testCreateThrowsExceptionWhenResourceClassMissing(): void
{
$this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class);
$this->expectExceptionMessage('The route "/dummies/1" is not an API route, it has no resource class in the defaults.');

$router = $this->createMock(RouterInterface::class);
$router->expects($this->once())
->method('match')
->with('/dummies/1')
->willReturn([]);

$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);

$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
$factory->create('/dummies/1');
}

public function testCreateThrowsExceptionWhenOperationNotFound(): void
{
$this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class);
$this->expectExceptionMessage('No operation found for id "/dummies/1".');

$resourceClass = 'Dummy';

$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataCollectionFactory->expects($this->once())
->method('create')
->with($resourceClass)
->willReturn(new ResourceMetadataCollection($resourceClass, [new ApiResource()]));

$router = $this->createMock(RouterInterface::class);
$router->expects($this->once())
->method('match')
->with('/dummies/1')
->willReturn([
'_api_resource_class' => $resourceClass,
]);

$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
$factory->create('/dummies/1');
}

public function testCreateIgnoresOperationsWithResolvers(): void
{
$this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class);
$this->expectExceptionMessage('No operation found for id "/dummies/1".');

$resourceClass = 'Dummy';
$operationName = 'item_query';

$operation = (new Query())->withResolver('t')->withName($operationName);
$resourceMetadata = (new ApiResource())->withGraphQlOperations([$operationName => $operation]);
$resourceMetadataCollection = new ResourceMetadataCollection($resourceClass, [$resourceMetadata]);

$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataCollectionFactory->expects($this->once())
->method('create')
->with($resourceClass)
->willReturn($resourceMetadataCollection);

$router = $this->createMock(RouterInterface::class);
$router->expects($this->once())
->method('match')
->with('/dummies/1')
->willReturn([
'_api_resource_class' => $resourceClass,
'_api_operation_name' => $operationName,
]);

$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
$factory->create('/dummies/1');
}
}
20 changes: 19 additions & 1 deletion src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Operation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
@@ -43,7 +44,7 @@ public function testGraphQlResolver(?string $resourceClass = null, ?string $root
$resolveInfo = $this->createMock(ResolveInfo::class);
$resolveInfo->fieldName = 'test';

$resolverFactory = new ResolverFactory($provider, $processor);
$resolverFactory = new ResolverFactory($provider, $processor, $this->createMock(OperationMetadataFactoryInterface::class));
$this->assertEquals($resolverFactory->__invoke($resourceClass, $rootClass, $operation, $propertyMetadataFactory)(['test' => null], [], [], $resolveInfo), $returnValue);
}

@@ -54,4 +55,21 @@ public static function graphQlQueries(): array
['Dummy', 'Dummy', new Mutation(), (new Mutation())->withValidate(true), (new Mutation())->withValidate(true)->withWrite(true)],
];
}

public function testGraphQlResolverWithNode(): void
{
$returnValue = new \stdClass();
$op = new Query(name: 'hi');
$provider = $this->createMock(ProviderInterface::class);
$provider->expects($this->once())->method('provide')->with($op)->willReturn($returnValue);
$processor = $this->createMock(ProcessorInterface::class);
$processor->expects($this->once())->method('process')->with($returnValue, $op)->willReturn($returnValue);
$resolveInfo = $this->createMock(ResolveInfo::class);
$resolveInfo->fieldName = 'test';

$operationFactory = $this->createMock(OperationMetadataFactoryInterface::class);
$operationFactory->method('create')->with('/foo')->willReturn($op);
$resolverFactory = new ResolverFactory($provider, $processor, $operationFactory);
$this->assertSame($returnValue, $resolverFactory->__invoke()([], ['id' => '/foo'], [], $resolveInfo));
}
}
11 changes: 10 additions & 1 deletion src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@
use ApiPlatform\GraphQl\Error\ErrorHandlerInterface;
use ApiPlatform\GraphQl\Executor;
use ApiPlatform\GraphQl\ExecutorInterface;
use ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory;
use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory;
use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface;
use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface;
@@ -1086,7 +1087,15 @@ private function registerGraphQl(): void
$this->app->singleton(ResolverFactoryInterface::class, function (Application $app) {
return new ResolverFactory(
$app->make('api_platform.graphql.state_provider.access_checker'),
$app->make('api_platform.graphql.state_processor')
$app->make('api_platform.graphql.state_processor'),
$app->make('api_platform.graphql.runtime_operation_metadata_factory'),
);
});

$app->singleton('api_platform.graphql.runtime_operation_metadata_factory', function (Application $app) {
return new RuntimeOperationMetadataFactory(
$app->make(ResourceMetadataCollectionFactoryInterface::class),
$app->make(UrlGeneratorRouter::class)
);
});

2 changes: 1 addition & 1 deletion src/Metadata/Resource/Factory/OperationDefaultsTrait.php
Original file line number Diff line number Diff line change
@@ -121,7 +121,7 @@ private function getDefaultHttpOperations($resource): iterable

private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource
{
$operations = enum_exists($resource->getClass()) ? [new QueryCollection(paginationEnabled: false), new Query()] : [new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')];
$operations = enum_exists($resource->getClass()) ? [new Query(), new QueryCollection(paginationEnabled: false)] : [new Query(), new QueryCollection(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')];
$graphQlOperations = [];
foreach ($operations as $operation) {
[$key, $operation] = $this->getOperationWithDefaults($resource, $operation);
6 changes: 6 additions & 0 deletions src/Symfony/Bundle/Resources/config/graphql.xml
Original file line number Diff line number Diff line change
@@ -189,6 +189,12 @@
<service id="api_platform.graphql.resolver.factory" class="ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory" public="false">
<argument type="service" id="api_platform.graphql.state_provider" />
<argument type="service" id="api_platform.graphql.state_processor" />
<argument type="service" id="api_platform.graphql.runtime_operation_metadata_factory" />
</service>

<service id="api_platform.graphql.runtime_operation_metadata_factory" class="ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory" public="false">
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
<argument type="service" id="api_platform.router" />
</service>

<!-- Resolver Stages -->
Loading