feature #61458 [HttpKernel] Validate typed request attribute values before calling controllers (mudassaralichouhan)

This PR was merged into the 8.1 branch.

Discussion
----------

[HttpKernel] Validate typed request attribute values before calling controllers

<!--
This file is part of the Symfony package.

(c) Fabien Potencier <fabien@symfony.com>

For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
-->

| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #61451
| License       | MIT
| Doc PR        | N/A

This PR fixes issue #61451 by introducing a new `RequestAttributeScalarValueResolver` that safely casts request attributes (route parameters) to scalar types (int, float, bool, string, or \BackedEnum).

If a value cannot be safely cast (e.g. `9223372036854775808` for an `int`), a `NotFoundHttpException` (404) is thrown before reaching the controller, avoiding a `TypeError`.

### Changes:
- Added: `RequestAttributeScalarValueResolver` (registered with high priority)
- Tests: Unit and functional tests for valid, invalid, and out-of-range int values
- Config: Functional test app with a `/{id}` route expecting `int $id`
- Docs: `CHANGELOG.md` entry under 7.4

This makes route handling safer and more predictable for typed controllers.

Commits
-------

bb0f715e833 [HttpKernel] Validate typed request attribute values before calling controllers
This commit is contained in:
Nicolas Grekas
2026-02-06 08:08:10 +01:00
5 changed files with 121 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ CHANGELOG
8.1
---
* Validate typed route parameters before calling controllers and return an HTTP error when an invalid value is provided
* Add `ControllerAttributeEvent` et al. to dispatch events named after controller attributes
* Add support for `UploadedFile` when using `MapRequestPayload`
* Add support for bundles as compiler pass

View File

@@ -35,16 +35,16 @@ final class BackedEnumValueResolver implements ValueResolverInterface
return [];
}
$name = $argument->getName();
// do not support if no value can be resolved at all
// letting the \Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver be used
// or \Symfony\Component\HttpKernel\Controller\ArgumentResolver fail with a meaningful error.
if (!$request->attributes->has($argument->getName())) {
if (!$request->attributes->has($name)) {
return [];
}
$value = $request->attributes->get($argument->getName());
if (null === $value) {
if (null === $value = $request->attributes->get($name)) {
return [null];
}
@@ -52,17 +52,17 @@ final class BackedEnumValueResolver implements ValueResolverInterface
return [$value];
}
/** @var class-string<\BackedEnum> $type */
$type = $argument->getType();
if (!\is_int($value) && !\is_string($value)) {
throw new \LogicException(\sprintf('Could not resolve the "%s $%s" controller argument: expecting an int or string, got "%s".', $argument->getType(), $argument->getName(), get_debug_type($value)));
throw new NotFoundHttpException(\sprintf('Could not resolve the "%s $%s" controller argument: expecting an int or string, got "%s".', $type, $name, get_debug_type($value)));
}
/** @var class-string<\BackedEnum> $enumType */
$enumType = $argument->getType();
try {
return [$enumType::from($value)];
return [$type::from($value)];
} catch (\ValueError|\TypeError $e) {
throw new NotFoundHttpException(\sprintf('Could not resolve the "%s $%s" controller argument: ', $argument->getType(), $argument->getName()).$e->getMessage(), $e);
throw new NotFoundHttpException(\sprintf('Could not resolve the "%s $%s" controller argument: ', $type, $name).$e->getMessage(), $e);
}
}
}

View File

@@ -14,6 +14,7 @@ namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Yields a non-variadic argument's value from the request attributes.
@@ -24,6 +25,45 @@ final class RequestAttributeValueResolver implements ValueResolverInterface
{
public function resolve(Request $request, ArgumentMetadata $argument): array
{
return !$argument->isVariadic() && $request->attributes->has($argument->getName()) ? [$request->attributes->get($argument->getName())] : [];
if ($argument->isVariadic()) {
return [];
}
$name = $argument->getName();
if (!$request->attributes->has($name)) {
return [];
}
$value = $request->attributes->get($name);
if (null === $value && $argument->isNullable()) {
return [null];
}
$type = $argument->getType();
// Skip when no type declaration or complex types; fall back to other resolvers/defaults
if (null === $type || str_contains($type, '|') || str_contains($type, '&')) {
return [$value];
}
if ('string' === $type) {
if (!\is_scalar($value) && !$value instanceof \Stringable) {
throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name));
}
$value = (string) $value;
} elseif ($filter = match ($type) {
'int' => \FILTER_VALIDATE_INT,
'float' => \FILTER_VALIDATE_FLOAT,
'bool' => \FILTER_VALIDATE_BOOL,
default => null,
}) {
if (null === $value = $request->attributes->filter($name, null, $filter, ['flags' => \FILTER_NULL_ON_FAILURE | \FILTER_REQUIRE_SCALAR])) {
throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name));
}
}
return [$value];
}
}

View File

@@ -119,7 +119,7 @@ class BackedEnumValueResolverTest extends TestCase
$request = self::createRequest(['suit' => false]);
$metadata = self::createArgumentMetadata('suit', Suit::class);
$this->expectException(\LogicException::class);
$this->expectException(NotFoundHttpException::class);
$this->expectExceptionMessage('Could not resolve the "Symfony\Component\HttpKernel\Tests\Fixtures\Suit $suit" controller argument: expecting an int or string, got "bool".');
$resolver->resolve($request, $metadata);

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class RequestAttributeValueResolverTest extends TestCase
{
public function testValidIntWithinRangeWorks()
{
$resolver = new RequestAttributeValueResolver();
$request = new Request();
$request->attributes->set('id', '123');
$metadata = new ArgumentMetadata('id', 'int', false, false, null);
$result = iterator_to_array($resolver->resolve($request, $metadata));
$this->assertSame([123], $result);
}
public function testInvalidStringBecomes404()
{
$resolver = new RequestAttributeValueResolver();
$request = new Request();
$request->attributes->set('id', 'abc');
$metadata = new ArgumentMetadata('id', 'int', false, false, null);
$this->expectException(NotFoundHttpException::class);
iterator_to_array($resolver->resolve($request, $metadata));
}
public function testOutOfRangeIntBecomes404()
{
$resolver = new RequestAttributeValueResolver();
$request = new Request();
// one more than PHP_INT_MAX on 64-bit (string input)
$request->attributes->set('id', '9223372036854775808');
$metadata = new ArgumentMetadata('id', 'int', false, false, null);
$this->expectException(NotFoundHttpException::class);
iterator_to_array($resolver->resolve($request, $metadata));
}
public function testNullableIntAllowsNull()
{
$resolver = new RequestAttributeValueResolver();
$request = new Request();
$request->attributes->set('id', null);
$metadata = new ArgumentMetadata('id', 'int', false, true, null);
$result = iterator_to_array($resolver->resolve($request, $metadata));
$this->assertSame([null], $result);
}
}