Skip to content

Commit f48a2be

Browse files
committed
Merge 4.1
2 parents 5e47ca3 + 7471860 commit f48a2be

File tree

16 files changed

+675
-11
lines changed

16 files changed

+675
-11
lines changed

CHANGELOG.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## v4.1.6
4+
5+
### Bug fixes
6+
7+
* [44e560839](https://github.com/api-platform/core/commit/44e56083996f2f00a1d87a149fd41aeb0149e4dd) fix(laravel): undefined variable app
8+
9+
### Features
10+
11+
## v4.1.5
12+
13+
### Bug fixes
14+
15+
* [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)
16+
* [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)
17+
318
## v4.1.4
419

520
### Bug fixes
@@ -17,9 +32,6 @@
1732

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

20-
21-
### Features
22-
2335
## v4.1.2
2436

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

3244

33-
### Features
34-
3545
## v4.1.1
3646

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

165+
## v4.0.22
166+
167+
### Bug fixes
168+
169+
* [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)
170+
* [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)
171+
* [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)
172+
155173
## v4.0.21
156174

157175
### Bug fixes
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\GraphQl\Metadata;
15+
16+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
17+
use ApiPlatform\Metadata\GraphQl\Query;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
20+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
21+
use Symfony\Component\Routing\Exception\ExceptionInterface as RoutingExceptionInterface;
22+
use Symfony\Component\Routing\RouterInterface;
23+
24+
/**
25+
* This factory runs in the ResolverFactory and is used to find out a Relay node's operation.
26+
*/
27+
final class RuntimeOperationMetadataFactory implements OperationMetadataFactoryInterface
28+
{
29+
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly RouterInterface $router)
30+
{
31+
}
32+
33+
public function create(string $uriTemplate, array $context = []): ?Operation
34+
{
35+
try {
36+
$parameters = $this->router->match($uriTemplate);
37+
} catch (RoutingExceptionInterface $e) {
38+
throw new InvalidArgumentException(\sprintf('No route matches "%s".', $uriTemplate), $e->getCode(), $e);
39+
}
40+
41+
if (!isset($parameters['_api_resource_class'])) {
42+
throw new InvalidArgumentException(\sprintf('The route "%s" is not an API route, it has no resource class in the defaults.', $uriTemplate));
43+
}
44+
45+
foreach ($this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class']) as $resource) {
46+
foreach ($resource->getGraphQlOperations() ?? [] as $operation) {
47+
if ($operation instanceof Query && !$operation->getResolver()) {
48+
return $operation;
49+
}
50+
}
51+
}
52+
53+
throw new InvalidArgumentException(\sprintf('No operation found for id "%s".', $uriTemplate));
54+
}
55+
}

src/GraphQl/Resolver/Factory/ResolverFactory.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,28 @@
1515

1616
use ApiPlatform\GraphQl\State\Provider\NoopProvider;
1717
use ApiPlatform\Metadata\DeleteOperationInterface;
18+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1819
use ApiPlatform\Metadata\GraphQl\Mutation;
1920
use ApiPlatform\Metadata\GraphQl\Operation;
2021
use ApiPlatform\Metadata\GraphQl\Query;
22+
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
2123
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2224
use ApiPlatform\State\Pagination\ArrayPaginator;
2325
use ApiPlatform\State\ProcessorInterface;
2426
use ApiPlatform\State\ProviderInterface;
2527
use GraphQL\Type\Definition\ResolveInfo;
28+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2629

2730
class ResolverFactory implements ResolverFactoryInterface
2831
{
2932
public function __construct(
3033
private readonly ProviderInterface $provider,
3134
private readonly ProcessorInterface $processor,
35+
private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null,
3236
) {
37+
if (!$operationMetadataFactory) {
38+
throw new InvalidArgumentException(\sprintf('Not injecting the "%s" exposes Relay nodes to a security risk.', OperationMetadataFactoryInterface::class));
39+
}
3340
}
3441

3542
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
7077
private function resolve(?array $source, array $args, ResolveInfo $info, ?string $rootClass = null, ?Operation $operation = null, mixed $body = null)
7178
{
7279
// Handles relay nodes
73-
$operation ??= new Query();
80+
if (!$operation) {
81+
if (!isset($args['id'])) {
82+
throw new NotFoundHttpException('No node found.');
83+
}
84+
85+
$operation = $this->operationMetadataFactory->create($args['id']);
86+
}
7487

7588
$graphQlContext = [];
7689
$context = ['source' => $source, 'args' => $args, 'info' => $info, 'root_class' => $rootClass, 'graphql_context' => &$graphQlContext];

src/GraphQl/Serializer/ItemNormalizer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ public function normalize(mixed $object, ?string $format = null, array $context
8989

9090
if ($this->isCacheKeySafe($context)) {
9191
$context['cache_key'] = $this->getCacheKey($format, $context);
92+
} else {
93+
$context['cache_key'] = false;
9294
}
9395

9496
unset($context['operation_name'], $context['operation']); // Remove operation and operation_name only when cache key has been created
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\GraphQl\Tests\Metadata;
15+
16+
use ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\GraphQl\Query;
19+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
20+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
21+
use PHPUnit\Framework\TestCase;
22+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
23+
use Symfony\Component\Routing\RouterInterface;
24+
25+
class RuntimeOperationMetadataFactoryTest extends TestCase
26+
{
27+
public function testCreate(): void
28+
{
29+
$resourceClass = 'Dummy';
30+
$operationName = 'item_query';
31+
32+
$operation = (new Query())->withName($operationName);
33+
$resourceMetadata = (new ApiResource())->withGraphQlOperations([$operationName => $operation]);
34+
$resourceMetadataCollection = new ResourceMetadataCollection($resourceClass, [$resourceMetadata]);
35+
36+
$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
37+
$resourceMetadataCollectionFactory->expects($this->once())
38+
->method('create')
39+
->with($resourceClass)
40+
->willReturn($resourceMetadataCollection);
41+
42+
$router = $this->createMock(RouterInterface::class);
43+
$router->expects($this->once())
44+
->method('match')
45+
->with('/dummies/1')
46+
->willReturn([
47+
'_api_resource_class' => $resourceClass,
48+
'_api_operation_name' => $operationName,
49+
]);
50+
51+
$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
52+
$this->assertEquals($operation, $factory->create('/dummies/1'));
53+
}
54+
55+
public function testCreateThrowsExceptionWhenRouteNotFound(): void
56+
{
57+
$this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class);
58+
$this->expectExceptionMessage('No route matches "/unknown".');
59+
60+
$router = $this->createMock(RouterInterface::class);
61+
$router->expects($this->once())
62+
->method('match')
63+
->with('/unknown')
64+
->willThrowException(new ResourceNotFoundException());
65+
66+
$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
67+
68+
$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
69+
$factory->create('/unknown');
70+
}
71+
72+
public function testCreateThrowsExceptionWhenResourceClassMissing(): void
73+
{
74+
$this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class);
75+
$this->expectExceptionMessage('The route "/dummies/1" is not an API route, it has no resource class in the defaults.');
76+
77+
$router = $this->createMock(RouterInterface::class);
78+
$router->expects($this->once())
79+
->method('match')
80+
->with('/dummies/1')
81+
->willReturn([]);
82+
83+
$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
84+
85+
$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
86+
$factory->create('/dummies/1');
87+
}
88+
89+
public function testCreateThrowsExceptionWhenOperationNotFound(): void
90+
{
91+
$this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class);
92+
$this->expectExceptionMessage('No operation found for id "/dummies/1".');
93+
94+
$resourceClass = 'Dummy';
95+
96+
$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
97+
$resourceMetadataCollectionFactory->expects($this->once())
98+
->method('create')
99+
->with($resourceClass)
100+
->willReturn(new ResourceMetadataCollection($resourceClass, [new ApiResource()]));
101+
102+
$router = $this->createMock(RouterInterface::class);
103+
$router->expects($this->once())
104+
->method('match')
105+
->with('/dummies/1')
106+
->willReturn([
107+
'_api_resource_class' => $resourceClass,
108+
]);
109+
110+
$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
111+
$factory->create('/dummies/1');
112+
}
113+
114+
public function testCreateIgnoresOperationsWithResolvers(): void
115+
{
116+
$this->expectException(\ApiPlatform\Metadata\Exception\InvalidArgumentException::class);
117+
$this->expectExceptionMessage('No operation found for id "/dummies/1".');
118+
119+
$resourceClass = 'Dummy';
120+
$operationName = 'item_query';
121+
122+
$operation = (new Query())->withResolver('t')->withName($operationName);
123+
$resourceMetadata = (new ApiResource())->withGraphQlOperations([$operationName => $operation]);
124+
$resourceMetadataCollection = new ResourceMetadataCollection($resourceClass, [$resourceMetadata]);
125+
126+
$resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
127+
$resourceMetadataCollectionFactory->expects($this->once())
128+
->method('create')
129+
->with($resourceClass)
130+
->willReturn($resourceMetadataCollection);
131+
132+
$router = $this->createMock(RouterInterface::class);
133+
$router->expects($this->once())
134+
->method('match')
135+
->with('/dummies/1')
136+
->willReturn([
137+
'_api_resource_class' => $resourceClass,
138+
'_api_operation_name' => $operationName,
139+
]);
140+
141+
$factory = new RuntimeOperationMetadataFactory($resourceMetadataCollectionFactory, $router);
142+
$factory->create('/dummies/1');
143+
}
144+
}

src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use ApiPlatform\Metadata\GraphQl\Mutation;
1919
use ApiPlatform\Metadata\GraphQl\Operation;
2020
use ApiPlatform\Metadata\GraphQl\Query;
21+
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
2122
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2223
use ApiPlatform\State\ProcessorInterface;
2324
use ApiPlatform\State\ProviderInterface;
@@ -43,7 +44,7 @@ public function testGraphQlResolver(?string $resourceClass = null, ?string $root
4344
$resolveInfo = $this->createMock(ResolveInfo::class);
4445
$resolveInfo->fieldName = 'test';
4546

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

@@ -54,4 +55,21 @@ public static function graphQlQueries(): array
5455
['Dummy', 'Dummy', new Mutation(), (new Mutation())->withValidate(true), (new Mutation())->withValidate(true)->withWrite(true)],
5556
];
5657
}
58+
59+
public function testGraphQlResolverWithNode(): void
60+
{
61+
$returnValue = new \stdClass();
62+
$op = new Query(name: 'hi');
63+
$provider = $this->createMock(ProviderInterface::class);
64+
$provider->expects($this->once())->method('provide')->with($op)->willReturn($returnValue);
65+
$processor = $this->createMock(ProcessorInterface::class);
66+
$processor->expects($this->once())->method('process')->with($returnValue, $op)->willReturn($returnValue);
67+
$resolveInfo = $this->createMock(ResolveInfo::class);
68+
$resolveInfo->fieldName = 'test';
69+
70+
$operationFactory = $this->createMock(OperationMetadataFactoryInterface::class);
71+
$operationFactory->method('create')->with('/foo')->willReturn($op);
72+
$resolverFactory = new ResolverFactory($provider, $processor, $operationFactory);
73+
$this->assertSame($returnValue, $resolverFactory->__invoke()([], ['id' => '/foo'], [], $resolveInfo));
74+
}
5775
}

src/Laravel/ApiPlatformProvider.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\GraphQl\Error\ErrorHandlerInterface;
1818
use ApiPlatform\GraphQl\Executor;
1919
use ApiPlatform\GraphQl\ExecutorInterface;
20+
use ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory;
2021
use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory;
2122
use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface;
2223
use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface;
@@ -1086,7 +1087,15 @@ private function registerGraphQl(): void
10861087
$this->app->singleton(ResolverFactoryInterface::class, function (Application $app) {
10871088
return new ResolverFactory(
10881089
$app->make('api_platform.graphql.state_provider.access_checker'),
1089-
$app->make('api_platform.graphql.state_processor')
1090+
$app->make('api_platform.graphql.state_processor'),
1091+
$app->make('api_platform.graphql.runtime_operation_metadata_factory'),
1092+
);
1093+
});
1094+
1095+
$this->app->singleton('api_platform.graphql.runtime_operation_metadata_factory', function (Application $app) {
1096+
return new RuntimeOperationMetadataFactory(
1097+
$app->make(ResourceMetadataCollectionFactoryInterface::class),
1098+
$app->make(UrlGeneratorRouter::class)
10901099
);
10911100
});
10921101

src/Laravel/Eloquent/Paginator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
final class Paginator implements PaginatorInterface, HasNextPagePaginatorInterface, \IteratorAggregate
2626
{
2727
/**
28-
* @param LengthAwarePaginator<object> $paginator
28+
* @param LengthAwarePaginator<int, object> $paginator
2929
*/
3030
public function __construct(
3131
private readonly LengthAwarePaginator $paginator,

src/Laravel/Eloquent/PartialPaginator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
final class PartialPaginator implements PartialPaginatorInterface, \IteratorAggregate
2525
{
2626
/**
27-
* @param AbstractPaginator<object> $paginator
27+
* @param AbstractPaginator<int, object> $paginator
2828
*/
2929
public function __construct(
3030
private readonly AbstractPaginator $paginator,

src/Metadata/Resource/Factory/OperationDefaultsTrait.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ private function getDefaultHttpOperations($resource): iterable
121121

122122
private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource
123123
{
124-
$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')];
124+
$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')];
125125
$graphQlOperations = [];
126126
foreach ($operations as $operation) {
127127
[$key, $operation] = $this->getOperationWithDefaults($resource, $operation);

0 commit comments

Comments
 (0)