VYPR
High severity7.5NVD Advisory· Published Apr 3, 2025· Updated Apr 15, 2026

CVE-2025-31481

CVE-2025-31481

Description

API Platform Core is a system to create hypermedia-driven REST and GraphQL APIs. Using the Relay special node type you can bypass the configured security on an operation. This vulnerability is fixed in 4.0.22 and 3.4.17.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
api-platform/graphqlPackagist
>= 4.0.0-alpha.1, < 4.0.224.0.22
api-platform/corePackagist
>= 4.0.0-alpha.1, < 4.0.224.0.22
api-platform/graphqlPackagist
< 3.4.173.4.17
api-platform/corePackagist
< 3.4.173.4.17
api-platform/graphqlPackagist
>= 4.1.0-alpha.1, < 4.1.54.1.5
api-platform/corePackagist
>= 4.1.0-alpha.1, < 4.1.54.1.5

Patches

3
55712452b4f6

fix(graphql): access to unauthorized resource using node Relay

https://github.com/api-platform/coreAntoine BluchetApr 3, 2025via ghsa
7 files changed · +239 4
  • src/GraphQl/Metadata/RuntimeOperationMetadataFactory.php+55 0 added
    @@ -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));
    +    }
    +}
    
  • src/GraphQl/Resolver/Factory/ResolverFactory.php+14 1 modified
    @@ -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];
    
  • src/GraphQl/Tests/Metadata/RuntimeOperationMetadataFactoryTest.php+144 0 added
    @@ -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');
    +    }
    +}
    
  • src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php+19 1 modified
    @@ -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;
    @@ -45,7 +46,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);
         }
     
    @@ -56,4 +57,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));
    +    }
     }
    
  • src/Metadata/Resource/Factory/OperationDefaultsTrait.php+1 1 modified
    @@ -112,7 +112,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);
    
  • src/Symfony/Bundle/Resources/config/graphql.xml+6 0 modified
    @@ -191,6 +191,12 @@
             <service id="api_platform.graphql.resolver.factory.item" 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 -->
    
  • tests/Functional/GraphQl/SecurityTest.php+0 1 modified
    @@ -26,7 +26,6 @@ final class SecurityTest extends ApiTestCase
     {
         use RecreateSchemaTrait;
         use SetupClassResourcesTrait;
    -    protected static ?bool $alwaysBootKernel = false;
     
         /**
          * @return class-string[]
    
60747cc8c2fb

fix(graphql): access to unauthorized resource using node Relay

https://github.com/api-platform/coreAntoine BluchetApr 3, 2025via ghsa
8 files changed · +365 4
  • src/GraphQl/Metadata/RuntimeOperationMetadataFactory.php+55 0 added
    @@ -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));
    +    }
    +}
    
  • src/GraphQl/Resolver/Factory/ResolverFactory.php+14 1 modified
    @@ -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];
    
  • src/GraphQl/Tests/Metadata/RuntimeOperationMetadataFactoryTest.php+144 0 added
    @@ -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');
    +    }
    +}
    
  • src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php+19 1 modified
    @@ -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));
    +    }
     }
    
  • src/Laravel/ApiPlatformProvider.php+10 1 modified
    @@ -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;
    @@ -1273,7 +1274,15 @@ private function registerGraphQl(Application $app): void
             $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)
                 );
             });
     
    
  • src/Metadata/Resource/Factory/OperationDefaultsTrait.php+1 1 modified
    @@ -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);
    
  • src/Symfony/Bundle/Resources/config/graphql.xml+6 0 modified
    @@ -187,6 +187,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 -->
    
  • tests/Functional/GraphQl/SecurityTest.php+116 0 added
    @@ -0,0 +1,116 @@
    +<?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\Tests\Functional\GraphQl;
    +
    +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
    +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as DocumentSecuredDummy;
    +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy;
    +use ApiPlatform\Tests\RecreateSchemaTrait;
    +use ApiPlatform\Tests\SetupClassResourcesTrait;
    +
    +final class SecurityTest extends ApiTestCase
    +{
    +    use RecreateSchemaTrait;
    +    use SetupClassResourcesTrait;
    +
    +    /**
    +     * @return class-string[]
    +     */
    +    public static function getResources(): array
    +    {
    +        return [SecuredDummy::class];
    +    }
    +
    +    public function testQueryItem(): void
    +    {
    +        $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class;
    +        $this->recreateSchema([$resource]);
    +        $this->loadFixtures($resource);
    +        $client = self::createClient();
    +        $response = $client->request('POST', '/graphql', ['json' => [
    +            'query' => <<<QUERY
    +    {
    +      securedDummy(id: "/secured_dummies/1") {
    +        title
    +        description
    +      }
    +    }
    +QUERY,
    +        ]]);
    +
    +        $d = $response->toArray();
    +        $this->assertEquals('Access Denied.', $d['errors'][0]['message']);
    +    }
    +
    +    public function testCreateItemUnauthorized(): void
    +    {
    +        $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class;
    +        $this->recreateSchema([$resource]);
    +        $client = self::createClient();
    +        $response = $client->request('POST', '/graphql', ['json' => [
    +            'query' => <<<QUERY
    +mutation {
    +    createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) {
    +        securedDummy {
    +            title
    +            owner
    +        }
    +    }
    +}
    +QUERY,
    +        ]]);
    +
    +        $d = $response->toArray();
    +        $this->assertEquals('Only admins can create a secured dummy.', $d['errors'][0]['message']);
    +    }
    +
    +    public function testQueryItemWithNode(): void
    +    {
    +        $resource = $this->isMongoDB() ? DocumentSecuredDummy::class : SecuredDummy::class;
    +        $this->recreateSchema([$resource]);
    +        $this->loadFixtures($resource);
    +        $client = self::createClient();
    +        $response = $client->request('POST', '/graphql', ['json' => [
    +            'query' => <<<QUERY
    +    {
    +      node(id: "/secured_dummies/1") {
    +        ... on SecuredDummy {
    +            title
    +        }
    +      }
    +    }
    +QUERY,
    +        ]]);
    +
    +        $d = $response->toArray();
    +        $this->assertEquals('Access Denied.', $d['errors'][0]['message']);
    +    }
    +
    +    public function loadFixtures(string $resourceClass): void
    +    {
    +        $container = static::$kernel->getContainer();
    +        $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine');
    +        $manager = $registry->getManager();
    +        $s = new $resourceClass();
    +        $s->setTitle('Secured Dummy 1');
    +        $s->setDescription('Description 1');
    +        $s->setAdminOnlyProperty('admin secret');
    +        $s->setOwnerOnlyProperty('owner secret');
    +        $s->setAttributeBasedProperty('attribute based secret');
    +        $s->setOwner('user1');
    +
    +        $manager->persist($s);
    +        $manager->flush();
    +    }
    +}
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

9

News mentions

0

No linked articles in our index yet.