diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/RequestAttributeScalarValueResolverFunctionalTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/RequestAttributeScalarValueResolverFunctionalTest.php new file mode 100644 index 0000000000000..96d7e028f6e51 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/RequestAttributeScalarValueResolverFunctionalTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +class RequestAttributeScalarValueResolverFunctionalTest extends AbstractWebTestCase +{ + public function testValidIntReturns200() + { + $client = $this->createClient(['test_case' => 'RequestAttributeScalarValueResolver']); + + $client->request('GET', '/123'); + $response = $client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('123', $response->getContent()); + } + + public function testInvalidStringReturns404() + { + $client = $this->createClient(['test_case' => 'RequestAttributeScalarValueResolver']); + + $client->request('GET', '/abc'); + $response = $client->getResponse(); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testOutOfRangeIntReturns404() + { + $client = $this->createClient(['test_case' => 'RequestAttributeScalarValueResolver']); + + $client->request('GET', '/9223372036854775808'); + $response = $client->getResponse(); + + $this->assertSame(404, $response->getStatusCode()); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/RequestAttributeScalarValueResolverTestController.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/RequestAttributeScalarValueResolverTestController.php new file mode 100644 index 0000000000000..1665bb51435bb --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/RequestAttributeScalarValueResolverTestController.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +class RequestAttributeScalarValueResolverTestController +{ + #[Route(path: '/{id}', name: 'test_scalar_id')] + public function __invoke(int $id): Response + { + return new Response((string) $id); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/bundles.php new file mode 100644 index 0000000000000..13ab9fddee4a6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/bundles.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; + +return [ + new FrameworkBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/config.yml new file mode 100644 index 0000000000000..435512a01d340 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/config.yml @@ -0,0 +1,4 @@ +imports: + - { resource: ../config/default.yml } + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/routing.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/routing.yml new file mode 100644 index 0000000000000..61310b2e4d6b2 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/RequestAttributeScalarValueResolver/routing.yml @@ -0,0 +1,5 @@ +controllers: + resource: Symfony\Bundle\FrameworkBundle\Tests\Functional\RequestAttributeScalarValueResolverTestController + type: attribute + + diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeScalarValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeScalarValueResolver.php new file mode 100644 index 0000000000000..dc2105eff4244 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeScalarValueResolver.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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; + +/** + * Safely resolves typed scalar and BackedEnum arguments from request attributes (path parameters). + * + * If a value cannot be converted to the expected type (e.g. invalid int or out-of-range), + * a 404 Not Found is thrown instead of letting execution reach the controller and erroring. + * + * Types handled: string, int, float, bool, \BackedEnum + */ +final class RequestAttributeScalarValueResolver implements ValueResolverInterface +{ + public function resolve(Request $request, ArgumentMetadata $argument): array + { + if ($argument->isVariadic()) { + return []; + } + + $name = $argument->getName(); + if (!$request->attributes->has($name)) { + return []; + } + + $type = $argument->getType(); + if (null === $type) { + // Untyped; let the default attribute resolver handle it. + return []; + } + + // Skip union or intersection types; fall back to default behavior + if (str_contains($type, '|') || str_contains($type, '&')) { + return []; + } + + $value = $request->attributes->get($name); + + // Handle Backed Enum typed arguments + if (is_subclass_of($type, \BackedEnum::class, true)) { + if (!\is_string($value) && !\is_int($value)) { + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + } + try { + return [$type::from($value)]; + } catch (\ValueError) { + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + } + } + + if ($value instanceof \Stringable) { + $value = (string) $value; + } + + switch ($type) { + case 'string': + if (\is_scalar($value) || (null === $value && $argument->isNullable())) { + return [(string) $value]; + } + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + case 'int': + if (\is_int($value)) { + return [$value]; + } + $filtered = filter_var($value, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE); + if (null === $filtered) { + // Includes out-of-range values + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + } + + return [$filtered]; + + case 'float': + if (\is_float($value)) { + return [$value]; + } + $filtered = filter_var($value, \FILTER_VALIDATE_FLOAT, \FILTER_NULL_ON_FAILURE); + if (null === $filtered) { + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + } + + return [$filtered]; + + case 'bool': + if (\is_bool($value)) { + return [$value]; + } + $filtered = filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE); + if (null === $filtered) { + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + } + + return [$filtered]; + } + + // Unknown type; let other resolvers handle it (e.g., Request, Session, custom types) + return []; + } +} diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php index 2a8d48ee30174..047a85b2b6570 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php @@ -14,6 +14,7 @@ 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,61 @@ 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 []; + } + + $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]; + } + + // Let the dedicated resolver handle backed enums + if (is_subclass_of($type, \BackedEnum::class, true)) { + return []; + } + + // Only enforce safe casting for int/float/bool here using ParameterBag::filter() + switch ($type) { + case 'int': + $filtered = $request->attributes->filter($name, null, \FILTER_VALIDATE_INT, [ + 'flags' => \FILTER_NULL_ON_FAILURE | \FILTER_REQUIRE_SCALAR, + ]); + if (null === $filtered) { + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + } + + return [$filtered]; + + case 'float': + $filtered = $request->attributes->filter($name, null, \FILTER_VALIDATE_FLOAT, [ + 'flags' => \FILTER_NULL_ON_FAILURE | \FILTER_REQUIRE_SCALAR, + ]); + if (null === $filtered) { + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + } + + return [$filtered]; + + case 'bool': + $filtered = $request->attributes->filter($name, null, \FILTER_VALIDATE_BOOL, [ + 'flags' => \FILTER_NULL_ON_FAILURE | \FILTER_REQUIRE_SCALAR, + ]); + if (null === $filtered) { + throw new NotFoundHttpException(\sprintf('The value for the "%s" route parameter is invalid.', $name)); + } + + return [$filtered]; + } + + // Strings and any other types: keep default behavior + return [$request->attributes->get($name)]; } } diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestAttributeScalarValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestAttributeScalarValueResolverTest.php new file mode 100644 index 0000000000000..de0b55d2d99d5 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestAttributeScalarValueResolverTest.php @@ -0,0 +1,56 @@ + + * + * 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 RequestAttributeScalarValueResolverTest 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)); + } +} diff --git a/src/Symfony/Component/Routing/CHANGELOG.md b/src/Symfony/Component/Routing/CHANGELOG.md index 4ef96d53232fe..28b1bff0a83c6 100644 --- a/src/Symfony/Component/Routing/CHANGELOG.md +++ b/src/Symfony/Component/Routing/CHANGELOG.md @@ -5,6 +5,9 @@ CHANGELOG --- * Allow query-specific parameters in `UrlGenerator` using `_query` + * Validate typed path parameters before controller invocation: when a route parameter is bound to + a typed controller argument (int, float, bool, string, or \BackedEnum), invalid or out-of-range + values now result in a 404 Not Found instead of triggering a TypeError in the controller. 7.3 ---