CVE-2025-31485
Description
API Platform Core is a system to create hypermedia-driven REST and GraphQL APIs. Prior to 4.0.22 and 3.4.17, a GraphQL grant on a property might be cached with different objects. The ApiPlatform\GraphQl\Serializer\ItemNormalizer::isCacheKeySafe() method is meant to prevent the caching but the parent::normalize method that is called afterwards still creates the cache key and causes the issue. This vulnerability is fixed in 4.0.22 and 3.4.17.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
api-platform/graphqlPackagist | >= 4.0.0-alpha.1, < 4.0.22 | 4.0.22 |
api-platform/corePackagist | >= 4.0.0-alpha.1, < 4.0.22 | 4.0.22 |
api-platform/graphqlPackagist | < 3.4.17 | 3.4.17 |
api-platform/corePackagist | < 3.4.17 | 3.4.17 |
api-platform/corePackagist | >= 4.1.0-alpha.1, < 4.1.5 | 4.1.5 |
api-platform/graphqlPackagist | >= 4.1.0-alpha.1, < 4.1.5 | 4.1.5 |
Patches
3cba3acfbd517fix(graphql): property security might be cached w/ different objects
6 files changed · +403 −0
src/GraphQl/Serializer/ItemNormalizer.php+2 −0 modified@@ -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
tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php+45 −0 added@@ -0,0 +1,45 @@ +<?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\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ODM\Document] +class SecuredDummyCollectionParent +{ + #[ODM\Id] + #[ODM\Field(type: 'id')] + public ?string $id = null; + + #[ODM\ReferenceOne(targetDocument: SecuredDummyCollection::class, inversedBy: 'parents')] + #[ODM\Field(nullable: false)] + public SecuredDummyCollection $child; +}
tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php+60 −0 added@@ -0,0 +1,60 @@ +<?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\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ODM\Document] +class SecuredDummyCollection +{ + #[ODM\Id(strategy: 'AUTO', type: 'integer')] + public ?int $id = null; + + /** + * @var string The title + */ + #[ODM\Field] + public string $title; + + /** + * @var string Secret property, only readable/writable by owners + */ + #[ApiProperty(security: 'object == null or object.owner == user', securityPostDenormalize: 'object.owner == user')] + #[ODM\Field] + public ?string $ownerOnlyProperty = null; + + /** + * @var string The owner + */ + #[ODM\Field] + public string $owner; +}
tests/Fixtures/TestBundle/Entity/SecuredDummyCollectionParent.php+46 −0 added@@ -0,0 +1,46 @@ +<?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\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ORM\Mapping as ORM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ORM\Entity] +class SecuredDummyCollectionParent +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: false)] + public SecuredDummyCollection $child; +}
tests/Fixtures/TestBundle/Entity/SecuredDummyCollection.php+62 −0 added@@ -0,0 +1,62 @@ +<?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\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ORM\Mapping as ORM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ORM\Entity] +class SecuredDummyCollection +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + /** + * @var string The title + */ + #[ORM\Column] + public string $title; + + /** + * @var string Secret property, only readable/writable by owners + */ + #[ApiProperty(security: 'object == null or object.owner == user', securityPostDenormalize: 'object.owner == user')] + #[ORM\Column] + public ?string $ownerOnlyProperty = null; + + /** + * @var string The owner + */ + #[ORM\Column] + public string $owner; +}
tests/Functional/GraphQl/SecurityTest.php+188 −0 added@@ -0,0 +1,188 @@ +<?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\Fixtures\TestBundle\Document\SecuredDummyCollection as DocumentSecuredDummyCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollectionParent; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SecurityTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [SecuredDummy::class, SecuredDummyCollection::class, SecuredDummyCollectionParent::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(); + } + + public function testQueryCollection(): void + { + $resource = $this->isMongoDB() ? DocumentSecuredDummyCollection::class : SecuredDummyCollection::class; + $this->recreateSchema([$resource, $resource.'Parent']); + $this->loadFixturesQueryCollection($resource); + $client = self::createClient(); + + $response = $client->request('POST', '/graphql', ['headers' => ['Authorization' => 'Basic ZHVuZ2xhczprZXZpbg=='], 'json' => [ + 'query' => <<<QUERY + { + securedDummyCollectionParents { + edges { + node { + child { + title, ownerOnlyProperty, owner + } + } + } + } + } +QUERY, + ]]); + + $d = $response->toArray(); + $this->assertNull($d['data']['securedDummyCollectionParents']['edges'][1]['node']['child']['ownerOnlyProperty']); + } + + public function loadFixturesQueryCollection(string $resourceClass): void + { + $parentResourceClass = $resourceClass.'Parent'; + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); + $s = new $resourceClass(); + $s->title = 'Foo'; + $s->ownerOnlyProperty = 'Foo by dunglas'; + $s->owner = 'dunglas'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->title = 'Bar'; + $s->ownerOnlyProperty = 'Bar by admin'; + $s->owner = 'admin'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->title = 'Baz'; + $s->ownerOnlyProperty = 'Baz by dunglas'; + $s->owner = 'dunglas'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->ownerOnlyProperty = 'Bat by admin'; + $s->owner = 'admin'; + $s->title = 'Bat'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $manager->flush(); + } +}
7af65aad1303fix(graphql): property security might be cached w/ different objects
6 files changed · +288 −1
src/GraphQl/Serializer/ItemNormalizer.php+2 −0 modified@@ -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
tests/Fixtures/TestBundle/Document/SecuredDummyCollectionParent.php+45 −0 added@@ -0,0 +1,45 @@ +<?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\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ODM\Document] +class SecuredDummyCollectionParent +{ + #[ODM\Id] + #[ODM\Field(type: 'id')] + public ?string $id = null; + + #[ODM\ReferenceOne(targetDocument: SecuredDummyCollection::class, inversedBy: 'parents')] + #[ODM\Field(nullable: false)] + public SecuredDummyCollection $child; +}
tests/Fixtures/TestBundle/Document/SecuredDummyCollection.php+60 −0 added@@ -0,0 +1,60 @@ +<?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\Fixtures\TestBundle\Document; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ODM\Document] +class SecuredDummyCollection +{ + #[ODM\Id(strategy: 'AUTO', type: 'integer')] + public ?int $id = null; + + /** + * @var string The title + */ + #[ODM\Field] + public string $title; + + /** + * @var string Secret property, only readable/writable by owners + */ + #[ApiProperty(security: 'object == null or object.owner == user', securityPostDenormalize: 'object.owner == user')] + #[ODM\Field] + public ?string $ownerOnlyProperty = null; + + /** + * @var string The owner + */ + #[ODM\Field] + public string $owner; +}
tests/Fixtures/TestBundle/Entity/SecuredDummyCollectionParent.php+46 −0 added@@ -0,0 +1,46 @@ +<?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\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ORM\Mapping as ORM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ORM\Entity] +class SecuredDummyCollectionParent +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: false)] + public SecuredDummyCollection $child; +}
tests/Fixtures/TestBundle/Entity/SecuredDummyCollection.php+62 −0 added@@ -0,0 +1,62 @@ +<?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\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\NotExposed; +use Doctrine\ORM\Mapping as ORM; + +/** + * Secured resource. + */ +#[ApiResource( + operations: [ + new NotExposed(), + ], + graphQlOperations: [ + new Query(), + new QueryCollection(), + ], + security: 'is_granted(\'ROLE_USER\')' +)] +#[ORM\Entity] +class SecuredDummyCollection +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + /** + * @var string The title + */ + #[ORM\Column] + public string $title; + + /** + * @var string Secret property, only readable/writable by owners + */ + #[ApiProperty(security: 'object == null or object.owner == user', securityPostDenormalize: 'object.owner == user')] + #[ORM\Column] + public ?string $ownerOnlyProperty = null; + + /** + * @var string The owner + */ + #[ORM\Column] + public string $owner; +}
tests/Functional/GraphQl/SecurityTest.php+73 −1 modified@@ -16,20 +16,24 @@ 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\Fixtures\TestBundle\Document\SecuredDummyCollection as DocumentSecuredDummyCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyCollectionParent; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; final class SecurityTest extends ApiTestCase { use RecreateSchemaTrait; use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; /** * @return class-string[] */ public static function getResources(): array { - return [SecuredDummy::class]; + return [SecuredDummy::class, SecuredDummyCollection::class, SecuredDummyCollectionParent::class]; } public function testQueryItem(): void @@ -113,4 +117,72 @@ public function loadFixtures(string $resourceClass): void $manager->persist($s); $manager->flush(); } + + public function testQueryCollection(): void + { + $resource = $this->isMongoDB() ? DocumentSecuredDummyCollection::class : SecuredDummyCollection::class; + $this->recreateSchema([$resource, $resource.'Parent']); + $this->loadFixturesQueryCollection($resource); + $client = self::createClient(); + + $response = $client->request('POST', '/graphql', ['headers' => ['Authorization' => 'Basic ZHVuZ2xhczprZXZpbg=='], 'json' => [ + 'query' => <<<QUERY + { + securedDummyCollectionParents { + edges { + node { + child { + title, ownerOnlyProperty, owner + } + } + } + } + } +QUERY, + ]]); + + $d = $response->toArray(); + $this->assertNull($d['data']['securedDummyCollectionParents']['edges'][1]['node']['child']['ownerOnlyProperty']); + } + + public function loadFixturesQueryCollection(string $resourceClass): void + { + $parentResourceClass = $resourceClass.'Parent'; + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); + $s = new $resourceClass(); + $s->title = 'Foo'; + $s->ownerOnlyProperty = 'Foo by dunglas'; + $s->owner = 'dunglas'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->title = 'Bar'; + $s->ownerOnlyProperty = 'Bar by admin'; + $s->owner = 'admin'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->title = 'Baz'; + $s->ownerOnlyProperty = 'Baz by dunglas'; + $s->owner = 'dunglas'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $s = new $resourceClass(); + $s->ownerOnlyProperty = 'Bat by admin'; + $s->owner = 'admin'; + $s->title = 'Bat'; + $manager->persist($s); + $p = new $parentResourceClass(); + $p->child = $s; + $manager->persist($p); + $manager->flush(); + } }
c5fb664d17edVulnerability 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
8- github.com/advisories/GHSA-428q-q3vv-3fq3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-31485ghsaADVISORY
- github.com/FriendsOfPHP/security-advisories/blob/master/api-platform/core/CVE-2025-31485.yamlghsaWEB
- github.com/FriendsOfPHP/security-advisories/blob/master/api-platform/graphql/CVE-2025-31485.yamlghsaWEB
- github.com/api-platform/core/commit/7af65aad13037d7649348ee3dcd88e084ef771f8nvdWEB
- github.com/api-platform/core/commit/cba3acfbd517763cf320167250c5bed6d569696anvdWEB
- github.com/api-platform/core/releases/tag/v3.4.17nvdWEB
- github.com/api-platform/core/security/advisories/GHSA-428q-q3vv-3fq3nvdWEB
News mentions
0No linked articles in our index yet.