Skip to content

Commit d70eec5

Browse files
authored
fix(serializer): prevent context leakage with service-based entity resolution (#7756)
* fix(serializer): prevent context leakage with service-based entity resolution (#7733) | Q | A | ------------- | --- | Branch? | 4.2 | Tickets | Fixes #7733 | License | MIT | Doc PR | ∅ * Create OperationResourceResolverInterface service to validate entity-to-resource mappings * Add framework-specific decorators (Doctrine, Eloquent) to handle stateOptions validation * Remove force_resource_class propagation to nested objects preventing DateTimeImmutable issues
1 parent 5961292 commit d70eec5

22 files changed

Lines changed: 586 additions & 59 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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\Doctrine\Odm\Serializer;
15+
16+
use ApiPlatform\Doctrine\Odm\State\Options;
17+
use ApiPlatform\Metadata\Operation;
18+
use ApiPlatform\Metadata\Util\ClassInfoTrait;
19+
use ApiPlatform\Serializer\OperationResourceClassResolver;
20+
21+
/**
22+
* Doctrine ODM operation resource resolver.
23+
*
24+
* Handles document-to-resource mappings from Doctrine ODM's stateOptions.
25+
*
26+
* @author Kévin Dunglas <dunglas@gmail.com>
27+
*/
28+
final class DoctrineOdmOperationResourceClassResolver extends OperationResourceClassResolver
29+
{
30+
use ClassInfoTrait;
31+
32+
public function resolve(object|string $resource, Operation $operation): string
33+
{
34+
if (\is_string($resource)) {
35+
return $resource;
36+
}
37+
38+
$objectClass = $this->getObjectClass($resource);
39+
$stateOptions = $operation->getStateOptions();
40+
41+
// Doctrine ODM: Check for document class in stateOptions
42+
if ($stateOptions instanceof Options) {
43+
$documentClass = $stateOptions->getDocumentClass();
44+
45+
// Validate object matches the backing document class
46+
if ($documentClass && is_a($objectClass, $documentClass, true)) {
47+
return $operation->getClass();
48+
}
49+
}
50+
51+
// Fallback to core behavior
52+
return parent::resolve($resource, $operation);
53+
}
54+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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\Doctrine\Orm\Serializer;
15+
16+
use ApiPlatform\Doctrine\Orm\State\Options;
17+
use ApiPlatform\Metadata\Operation;
18+
use ApiPlatform\Metadata\Util\ClassInfoTrait;
19+
use ApiPlatform\Serializer\OperationResourceClassResolver;
20+
21+
/**
22+
* Doctrine ORM operation resource resolver.
23+
*
24+
* Handles entity-to-resource mappings from Doctrine ORM's stateOptions.
25+
*
26+
* @author Kévin Dunglas <dunglas@gmail.com>
27+
*/
28+
final class DoctrineOrmOperationResourceClassResolver extends OperationResourceClassResolver
29+
{
30+
use ClassInfoTrait;
31+
32+
public function resolve(object|string $resource, Operation $operation): string
33+
{
34+
if (\is_string($resource)) {
35+
return $resource;
36+
}
37+
38+
$objectClass = $this->getObjectClass($resource);
39+
$stateOptions = $operation->getStateOptions();
40+
41+
// Doctrine ORM: Check for entity class in stateOptions
42+
if ($stateOptions instanceof Options) {
43+
$entityClass = $stateOptions->getEntityClass();
44+
45+
// Validate object matches the backing entity class
46+
if ($entityClass && is_a($objectClass, $entityClass, true)) {
47+
return $operation->getClass();
48+
}
49+
}
50+
51+
// Fallback to core behavior
52+
return parent::resolve($resource, $operation);
53+
}
54+
}

src/Hal/Serializer/ItemNormalizer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use ApiPlatform\Serializer\AbstractItemNormalizer;
2626
use ApiPlatform\Serializer\CacheKeyTrait;
2727
use ApiPlatform\Serializer\ContextTrait;
28+
use ApiPlatform\Serializer\OperationResourceClassResolverInterface;
2829
use ApiPlatform\Serializer\TagCollectorInterface;
2930
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
3031
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
@@ -59,7 +60,7 @@ final class ItemNormalizer extends AbstractItemNormalizer
5960
private array $componentsCache = [];
6061
private array $attributesMetadataCache = [];
6162

62-
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null)
63+
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null)
6364
{
6465
$defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array {
6566
$iri = $this->iriConverter->getIriFromResource($object);
@@ -70,7 +71,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName
7071
return ['_links' => ['self' => ['href' => $iri]]];
7172
};
7273

73-
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
74+
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
7475
}
7576

7677
/**

src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use ApiPlatform\Serializer\AbstractItemNormalizer;
2828
use ApiPlatform\Serializer\CacheKeyTrait;
2929
use ApiPlatform\Serializer\ContextTrait;
30+
use ApiPlatform\Serializer\OperationResourceClassResolverInterface;
3031
use ApiPlatform\Serializer\TagCollectorInterface;
3132
use Symfony\Component\ErrorHandler\Exception\FlattenException;
3233
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -59,9 +60,9 @@ final class ItemNormalizer extends AbstractItemNormalizer
5960

6061
private array $componentsCache = [];
6162

62-
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null)
63+
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null)
6364
{
64-
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
65+
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
6566
}
6667

6768
/**

src/JsonLd/Serializer/ItemNormalizer.php

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
1919
use ApiPlatform\Metadata\HttpOperation;
2020
use ApiPlatform\Metadata\IriConverterInterface;
21+
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
2122
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2223
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2324
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
@@ -27,6 +28,7 @@
2728
use ApiPlatform\Metadata\Util\ClassInfoTrait;
2829
use ApiPlatform\Serializer\AbstractItemNormalizer;
2930
use ApiPlatform\Serializer\ContextTrait;
31+
use ApiPlatform\Serializer\OperationResourceClassResolverInterface;
3032
use ApiPlatform\Serializer\TagCollectorInterface;
3133
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
3234
use Symfony\Component\Serializer\Exception\LogicException;
@@ -70,9 +72,9 @@ final class ItemNormalizer extends AbstractItemNormalizer
7072
'@vocab',
7173
];
7274

73-
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null)
75+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null)
7476
{
75-
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector);
77+
parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver);
7678
}
7779

7880
/**
@@ -84,9 +86,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array
8486
}
8587

8688
/**
87-
* @param string|null $format
89+
* {@inheritdoc}
8890
*/
89-
public function getSupportedTypes($format): array
91+
public function getSupportedTypes(?string $format): array
9092
{
9193
return self::FORMAT === $format ? parent::getSupportedTypes($format) : [];
9294
}
@@ -96,20 +98,39 @@ public function getSupportedTypes($format): array
9698
*
9799
* @throws LogicException
98100
*/
99-
public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
101+
public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
100102
{
101-
$resourceClass = $this->getObjectClass($object);
103+
$resourceClass = $this->getObjectClass($data);
102104
$outputClass = $this->getOutputClass($context);
103105

104-
if ($outputClass && !($context['item_uri_template'] ?? null)) {
105-
return parent::normalize($object, $format, $context);
106+
if ($outputClass) {
107+
if ($context['item_uri_template'] ?? null) {
108+
// When both output and item_uri_template are present, temporarily remove
109+
// item_uri_template so the output re-dispatch produces the correct @type
110+
// from the output class (not from the item_uri_template operation).
111+
$itemUriTemplate = $context['item_uri_template'];
112+
unset($context['item_uri_template']);
113+
$originalData = $data;
114+
$data = parent::normalize($data, $format, $context);
115+
if (\is_array($data)) {
116+
try {
117+
$context['item_uri_template'] = $itemUriTemplate;
118+
$data['@id'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, null, $context);
119+
} catch (\Exception) {
120+
}
121+
}
122+
123+
return $data;
124+
}
125+
126+
return parent::normalize($data, $format, $context);
106127
}
107128

108129
// TODO: we should not remove the resource_class in the normalizeRawCollection as we would find out anyway that it's not the same as the requested one
109130
$previousResourceClass = $context['resource_class'] ?? null;
110131
$metadata = [];
111132
if ($isResourceClass = $this->resourceClassResolver->isResourceClass($resourceClass) && (null === $previousResourceClass || $this->resourceClassResolver->isResourceClass($previousResourceClass))) {
112-
$resourceClass = $this->resourceClassResolver->getResourceClass($object, $previousResourceClass);
133+
$resourceClass = $this->resourceClassResolver->getResourceClass($data, $previousResourceClass);
113134
$context = $this->initContext($resourceClass, $context);
114135
$metadata = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context);
115136
} elseif ($this->contextBuilder instanceof AnonymousContextBuilderInterface) {
@@ -119,45 +140,68 @@ public function normalize(mixed $object, ?string $format = null, array $context
119140
$context['output']['iri'] = null;
120141
}
121142

122-
if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
143+
if (isset($context['item_uri_template']) && $this->operationMetadataFactory) {
144+
$itemOp = $this->operationMetadataFactory->create($context['item_uri_template']);
145+
// Use resource-level shortName for @type, not operation-specific shortName
146+
try {
147+
$itemResourceShortName = $this->resourceMetadataCollectionFactory->create($itemOp->getClass())[0]->getShortName();
148+
$context['output']['operation'] = $itemOp->withShortName($itemResourceShortName);
149+
} catch (\Exception) {
150+
$context['output']['operation'] = $itemOp;
151+
}
152+
} elseif ($this->resourceClassResolver->isResourceClass($resourceClass)) {
123153
$context['output']['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
124154
}
125155

126156
// We should improve what's behind the context creation, its probably more complicated then it should
127-
$metadata = $this->createJsonLdContext($this->contextBuilder, $object, $context);
157+
$metadata = $this->createJsonLdContext($this->contextBuilder, $data, $context);
128158
}
129159

130160
// Special case: non-resource got serialized and contains a resource therefore we need to reset part of the context
131161
if ($previousResourceClass !== $resourceClass && $resourceClass !== $outputClass) {
132162
unset($context['operation'], $context['operation_name'], $context['output']);
133163
}
134164

135-
if (true === ($context['output']['gen_id'] ?? true) && true === ($context['force_iri_generation'] ?? true) && $iri = $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) {
165+
if (true === ($context['output']['gen_id'] ?? true) && true === ($context['force_iri_generation'] ?? true) && $iri = $this->iriConverter->getIriFromResource($data, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context)) {
136166
$context['iri'] = $iri;
137167
$metadata['@id'] = $iri;
138168
}
139169

140170
$context['api_normalize'] = true;
141171

142-
$data = parent::normalize($object, $format, $context);
143-
if (!\is_array($data)) {
144-
return $data;
172+
$normalizedData = parent::normalize($data, $format, $context);
173+
if (!\is_array($normalizedData)) {
174+
return $normalizedData;
145175
}
146176

147177
$operation = $context['operation'] ?? null;
178+
179+
if ($this->operationMetadataFactory && isset($context['item_uri_template']) && !$operation) {
180+
$operation = $this->operationMetadataFactory->create($context['item_uri_template']);
181+
}
182+
148183
if ($isResourceClass && !$operation) {
149184
$operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation();
150185
}
151186

152187
if (!isset($metadata['@type']) && $operation) {
153188
$types = $operation instanceof HttpOperation ? $operation->getTypes() : null;
154189
if (null === $types) {
155-
$types = [$operation->getShortName()];
190+
// TODO: 5.x break on this as this looks wrong, CollectionReferencingItem returns an IRI that point through
191+
// ItemReferencedInCollection but it returns a CollectionReferencingItem therefore we should use the current
192+
// object's class Type and not rely on operation ?
193+
// Use resource-level shortName to avoid operation-specific overrides
194+
$typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass);
195+
try {
196+
$types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()];
197+
} catch (\Exception) {
198+
$types = [$operation->getShortName()];
199+
}
156200
}
157201
$metadata['@type'] = 1 === \count($types) ? $types[0] : $types;
158202
}
159203

160-
return $metadata + $data;
204+
return $metadata + $normalizedData;
161205
}
162206

163207
/**
@@ -173,7 +217,7 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form
173217
*
174218
* @throws NotNormalizableValueException
175219
*/
176-
public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed
220+
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
177221
{
178222
// Avoid issues with proxies if we populated the object
179223
if (isset($data['@id']) && !isset($context[self::OBJECT_TO_POPULATE])) {
@@ -192,7 +236,7 @@ public function denormalize(mixed $data, string $class, ?string $format = null,
192236
}
193237
}
194238

195-
return parent::denormalize($data, $class, $format, $context);
239+
return parent::denormalize($data, $type, $format, $context);
196240
}
197241

198242
protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool

0 commit comments

Comments
 (0)