Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[FrameworkBundle][Serializer] Add an ArgumentResolver to deserialize & validate user input #45628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
Closed
13 changes: 13 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer;
use Symfony\Component\ErrorHandler\ErrorRenderer\SerializerErrorRenderer;
use Symfony\Component\PropertyInfo\Extractor\SerializerExtractor;
use Symfony\Component\Serializer\ArgumentResolver\UserInputResolver;
use Symfony\Component\Serializer\Encoder\CsvEncoder;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Encoder\EncoderInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Encoder\YamlEncoder;
use Symfony\Component\Serializer\EventListener\InputValidationFailedExceptionListener;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
use Symfony\Component\Serializer\Mapping\Factory\CacheClassMetadataFactory;
Expand Down Expand Up @@ -68,6 +70,17 @@

->alias('serializer.property_accessor', 'property_accessor')

// Argument Resolvers
->set(UserInputResolver::class)
->args([service('serializer')])
->tag('controller.argument_value_resolver')

// Event Listeners
->set(InputValidationFailedExceptionListener::class)
->args([service('serializer'), service('logger')])
// Must run before Symfony\Component\HttpKernel\EventListener\ErrorListener::onKernelException()
->tag('kernel.event_listener', ['event' => 'kernel.exception', 'priority' => 10])

// Discriminator Map
->set('serializer.mapping.class_discriminator_resolver', ClassDiscriminatorFromClassMetadata::class)
->args([service('serializer.mapping.class_metadata_factory')])
Expand Down
29 changes: 29 additions & 0 deletions src/Symfony/Component/Serializer/Annotation/RequestBody.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Serializer\Annotation;

/**
* Indicates that this argument should be deserialized from request body.
*
* @author Gary PEGEOT <[email protected]>
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
class RequestBody
{
/**
* @param string|null $format Will be guessed from request if empty, and default to JSON.
* @param array $context The serialization context (Useful to set groups / ignore fields).
*/
public function __construct(public readonly ?string $format = null, public readonly array $context = [])
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Serializer\ArgumentResolver;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Annotation\RequestBody;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;

/**
* Deserialize request body if Symfony\Component\Serializer\Annotation\RequestBody attribute is present on an argument.
*
* @author Gary PEGEOT <[email protected]>
*/
class UserInputResolver implements ArgumentValueResolverInterface
{
public function __construct(private SerializerInterface $serializer)
{
}

/**
* {@inheritDoc}
*/
public function supports(Request $request, ArgumentMetadata $argument): bool
{
return null !== $this->getAttribute($argument);
}

/**
* {@inheritDoc}
*/
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
$attribute = $this->getAttribute($argument);
$context = array_merge($attribute->context, [
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about adding AbstractObjectNormalizer::ALLOW_EXTRA_ATTRIBUTES => false?

]);
$format = $attribute->format ?? $request->getContentType() ?? 'json';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For security reasons, I suggest to throw an exception if the format isn't provided in the Content-Type header and if the excepted format (the format explicitly passed as parameter by the user) doesn't match the value of Content-Type.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just use $request->toArray()? Or it may be too restrictive in terms of format?


yield $this->serializer->deserialize($request->getContent(), $argument->getType(), $format, $context);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to resolve input not only from request content but from query/request parameters

Something like this:

if ($attribute->isFromQueryString()) {
    $input = $this->denormalizer->denormalize($request->query->all(), $argument->getType());
} else {
    if ($request->getContentType() === 'form') {
        $input = $this->denormalizer->denormalize($request->request->all(), $argument->getType());
    } else {
        $input = $this->serializer->deserialize($request->getContent(), $type, $format);
    }
}

}

private function getAttribute(ArgumentMetadata $argument): ?RequestBody
{
return $argument->getAttributes(RequestBody::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null;
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ CHANGELOG
* Deprecate `ContextAwareDecoderInterface`, use `DecoderInterface` instead
* Deprecate supporting denormalization for `AbstractUid` in `UidNormalizer`, use one of `AbstractUid` child class instead
* Deprecate denormalizing to an abstract class in `UidNormalizer`
* Add an ArgumentResolver to deserialize arguments with `Symfony\Component\Serializer\Annotation\RequestBody` attribute

6.0
---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Serializer\EventListener;

use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Exception\InputValidationFailedException;

/**
* Works in duo with Symfony\Bundle\FrameworkBundle\ArgumentResolver\UserInputResolver.
*
* @author Gary PEGEOT <[email protected]>
*/
class InputValidationFailedExceptionListener
Copy link
Member

@yceruto yceruto Mar 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this listener is really necessary to return the proper response (content + status code). Instead, you can go through the ErrorRenderer system by directly adding a custom Serializer normalizer. The Framework already knows how to handle exceptions according to the request format (see SearializerErrorRenderer).

{
public function __construct(private SerializerInterface $serializer, private LoggerInterface $logger)
{
}

public function __invoke(ExceptionEvent $event): void
{
$throwable = $event->getThrowable();
$format = $event->getRequest()->attributes->get('_format', 'json');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better use getPreferredFormat that handles the content negotiation part:

Suggested change
$format = $event->getRequest()->attributes->get('_format', 'json');
$format = $event->getPreferredFormat('json');


if (!$throwable instanceof InputValidationFailedException) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who throw this exception?

return;
}

$response = new Response($this->serializer->serialize($throwable->getViolations(), $format), Response::HTTP_UNPROCESSABLE_ENTITY);
$this->logger->info('Invalid input rejected: "{reason}"', ['reason' => (string) $throwable->getViolations()]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not super sure what to log here, as violations might contain sensitive information?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO, the reasons are not really useful in the log


$event->setResponse($response);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about stopping propagation as this listener is only here to create the HTTP response?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace Symfony\Component\Serializer\Tests\ArgumentResolver;

use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Annotation\RequestBody;
use Symfony\Component\Serializer\ArgumentResolver\UserInputResolver;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Tests\Fixtures\DummyDto;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validation;

class UserInputResolverTest extends TestCase
{
private UserInputResolver $resolver;

protected function setUp(): void
{
$encoders = [new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];

$this->resolver = new UserInputResolver(new Serializer($normalizers, $encoders));
}

public function testSupports()
{
$this->assertTrue($this->resolver->supports(new Request(), $this->createMetadata()), 'Should be supported');

$this->assertFalse($this->resolver->supports(new Request(), $this->createMetadata([])), 'Should not be supported');
}

public function testResolveWithValidValue()
{
$json = '{"randomText": "Lorem ipsum"}';
$request = new Request(content: $json);

$resolved = iterator_to_array($this->resolver->resolve($request, $this->createMetadata()));

$this->assertCount(1, $resolved, 'Should resolve one argument');
$this->assertInstanceOf(DummyDto::class, $resolved[0]);
$this->assertSame('Lorem ipsum', $resolved[0]->randomText);
}

public function testResolveWithInvalidValue()
{
$this->expectException(PartialDenormalizationException::class);
$request = new Request(content: '{"randomText": ["Did", "You", "Expect", "That?"]}');

iterator_to_array($this->resolver->resolve($request, $this->createMetadata()));
}

private function createMetadata(?array $attributes = [new RequestBody()]): ArgumentMetadata
{
$arguments = [
'name' => 'foo',
'isVariadic' => false,
'hasDefaultValue' => false,
'defaultValue' => null,
'type' => DummyDto::class,
'attributes' => $attributes,
];

return new ArgumentMetadata(...$arguments);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Symfony\Component\Serializer\Tests\EventListener;

use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\EventListener\InputValidationFailedExceptionListener;
use Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Tests\Fixtures\DummyDto;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Exception\InputValidationFailedException;

class InputValidationFailedExceptionListenerTest extends TestCase
{
private Serializer $serializer;

protected function setUp(): void
{
$encoders = [new JsonEncoder()];
$normalizers = [new ConstraintViolationListNormalizer(), new ObjectNormalizer()];
$this->serializer = new Serializer($normalizers, $encoders);
}

/**
* @dataProvider provideExceptions
*/
public function testExceptionHandling(\Throwable $e, ?string $expected)
{
$listener = new InputValidationFailedExceptionListener($this->serializer, new NullLogger());
$event = new ExceptionEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $e);

$listener($event);

if (null === $expected) {
$this->assertFalse($event->hasResponse(), 'Unexpected response');
} else {
$this->assertTrue($event->hasResponse(), 'Expected a response');
$this->assertStringContainsString($expected, $event->getResponse()->getContent());
}
}

public function provideExceptions(): \Generator
{
yield 'Unrelated exception' => [new \Exception('Nothing to see here'), null];
yield 'Validation exception' => [new InputValidationFailedException(new DummyDto(), ConstraintViolationList::createFromMessage('This value should not be blank')), 'This value should not be blank'];
}
}
17 changes: 17 additions & 0 deletions src/Symfony/Component/Serializer/Tests/Fixtures/DummyDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Symfony\Component\Serializer\Tests\Fixtures;

use Symfony\Component\Validator\Constraints as Assert;

class DummyDto
{
#[Assert\NotBlank(groups: ['Foo'])]
public ?string $propWithValidationGroups = null;

#[Assert\NotBlank()]
public ?string $randomText = null;

#[Assert\IsTrue()]
public bool $itMustBeTrue = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Validator\Exception;

/**
* @author Gary PEGEOT <[email protected]>
*/
class InputValidationFailedException extends ValidationFailedException
{
}