Skip to content

Commit 5571245

Browse files
committed
fix(graphql): access to unauthorized resource using node Relay
1 parent cba3acf commit 5571245

File tree

7 files changed

+239
-4
lines changed

7 files changed

+239
-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 <dunglas@gmail.com>
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 <dunglas@gmail.com>
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;
@@ -45,7 +46,7 @@ public function testGraphQlResolver(?string $resourceClass = null, ?string $root
4546
$resolveInfo = $this->createMock(ResolveInfo::class);
4647
$resolveInfo->fieldName = 'test';
4748

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

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

src/Metadata/Resource/Factory/OperationDefaultsTrait.php

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

113113
private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource
114114
{
115-
$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')];
115+
$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')];
116116
$graphQlOperations = [];
117117
foreach ($operations as $operation) {
118118
[$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
@@ -191,6 +191,12 @@
191191
<service id="api_platform.graphql.resolver.factory.item" class="ApiPlatform\GraphQl\Resolver\Factory\ResolverFactory" public="false">
192192
<argument type="service" id="api_platform.graphql.state_provider" />
193193
<argument type="service" id="api_platform.graphql.state_processor" />
194+
<argument type="service" id="api_platform.graphql.runtime_operation_metadata_factory" />
195+
</service>
196+
197+
<service id="api_platform.graphql.runtime_operation_metadata_factory" class="ApiPlatform\GraphQl\Metadata\RuntimeOperationMetadataFactory" public="false">
198+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
199+
<argument type="service" id="api_platform.router" />
194200
</service>
195201

196202
<!-- Resolver Stages -->

tests/Functional/GraphQl/SecurityTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ final class SecurityTest extends ApiTestCase
2626
{
2727
use RecreateSchemaTrait;
2828
use SetupClassResourcesTrait;
29-
protected static ?bool $alwaysBootKernel = false;
3029

3130
/**
3231
* @return class-string[]

0 commit comments

Comments
 (0)