Skip to content

[HttpKernel] Prevent TypeError for out-of-range route parameters #61458

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

Open
wants to merge 4 commits into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?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\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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?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\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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?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.
*/

use Symfony\Bundle\FrameworkBundle\FrameworkBundle;

return [
new FrameworkBundle(),
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
imports:
- { resource: ../config/default.yml }


Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
controllers:
resource: Symfony\Bundle\FrameworkBundle\Tests\Functional\RequestAttributeScalarValueResolverTestController
type: attribute


Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?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\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 [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,6 +25,61 @@
{
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];

Check failure on line 41 in src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedVariable

src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php:41:21: UndefinedVariable: Cannot find referenced variable $value (see https://psalm.dev/024)

Check failure on line 41 in src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedVariable

src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestAttributeValueResolver.php:41:21: UndefinedVariable: Cannot find referenced variable $value (see https://psalm.dev/024)
}

// 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)];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?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 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));
}
}
3 changes: 3 additions & 0 deletions src/Symfony/Component/Routing/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
Loading