Skip to content

Commit 60747cc

Browse files
committed
fix(graphql): access to unauthorized resource using node Relay
1 parent e3a9346 commit 60747cc

File tree

8 files changed

+365
-4
lines changed

8 files changed

+365
-4
lines changed
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];
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;
@@ -1273,7 +1274,15 @@ private function registerGraphQl(Application $app): void
12731274
$app->singleton(ResolverFactoryInterface::class, function (Application $app) {
12741275
return new ResolverFactory(
12751276
$app->make('api_platform.graphql.state_provider.access_checker'),
1276-
$app->make('api_platform.graphql.state_processor')
1277+
$app->make('api_platform.graphql.state_processor'),
1278+
$app->make('api_platform.graphql.runtime_operation_metadata_factory'),
1279+
);
1280+
});
1281+
1282+
$app->singleton('api_platform.graphql.runtime_operation_metadata_factory', function (Application $app) {
1283+
return new RuntimeOperationMetadataFactory(
1284+
$app->make(ResourceMetadataCollectionFactoryInterface::class),
1285+
$app->make(UrlGeneratorRouter::class)
12771286
);
12781287
});
12791288

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);

src/Symfony/Bundle/Resources/config/graphql.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@
187187
<service id="api_platform.graphql.resolver.factory" class="ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory" public="false">
188188
<argument type="service" id="api_platform.graphql.state_provider" />
189189
<argument type="service" id="api_platform.graphql.state_processor" />
190+
<argument type="service" id="api_platform.graphql.runtime_operation_metadata_factory" />
191+
</service>
192+
193+
<service id="api_platform.graphql.runtime_operation_metadata_factory" class="ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory" public="false">
194+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
195+
<argument type="service" id="api_platform.router" />
190196
</service>
191197

192198
<!-- Resolver Stages -->

0 commit comments

Comments
 (0)