Skip to content

Commit f0fcc9f

Browse files
feature symfony#52471 [HttpKernel] Add ControllerResolver::allowControllers() to define which callables are legit controllers when the _check_controller_is_allowed request attribute is set (nicolas-grekas)
This PR was merged into the 6.4 branch. Discussion ---------- [HttpKernel] Add `ControllerResolver::allowControllers()` to define which callables are legit controllers when the `_check_controller_is_allowed` request attribute is set | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT Right now, when one doesn't configure properly their APP_SECRET, this can too easily lead to an RCE. This PR proposes to harden security by rejecting any not-allowed controllers when the `_check_controller_is_allowed` request attribute is set. We leverage this in FragmentListener to close the RCE gap. In order to allow a controller, one should call `ControllerResolver::allowControllers()` during instantiation to tell which types or attributes should be accepted. #[AsController] is always allowed, and FrameworkBundle also allows instances of `AbstractController`. Third-party bundles that provide controllers meant to be used as fragments should ensure their controllers are allowed by adding the method call to the `controller_resolver` service definition. I propose this as a late 6.4 feature so that we can provide this hardening right away in 7.0. In 6.4, this would be only a deprecation. Commits ------- 893aba9 [HttpKernel] Add `ControllerResolver::allowControllers()` to define which callables are legit controllers when the `_check_controller_is_allowed` request attribute is set
2 parents 2196b67 + 893aba9 commit f0fcc9f

File tree

7 files changed

+156
-7
lines changed

7 files changed

+156
-7
lines changed

src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

14+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
1415
use Symfony\Bundle\FrameworkBundle\Controller\ControllerResolver;
1516
use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
1617
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\BackedEnumValueResolver;
@@ -40,6 +41,7 @@
4041
service('service_container'),
4142
service('logger')->ignoreOnInvalid(),
4243
])
44+
->call('allowControllers', [[AbstractController::class]])
4345
->tag('monolog.logger', ['channel' => 'request'])
4446

4547
->set('argument_metadata_factory', ArgumentMetadataFactory::class)

src/Symfony/Component/HttpKernel/Attribute/AsController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* This enables injecting services as method arguments in addition
1919
* to other conventional dependency injection strategies.
2020
*/
21-
#[\Attribute(\Attribute::TARGET_CLASS)]
21+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_FUNCTION)]
2222
class AsController
2323
{
2424
public function __construct()

src/Symfony/Component/HttpKernel/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ CHANGELOG
1818
* Deprecate `FileLinkFormatter`, use `FileLinkFormatter` from the ErrorHandler component instead
1919
* Add argument `$buildDir` to `WarmableInterface`
2020
* Add argument `$filter` to `Profiler::find()` and `FileProfilerStorage::find()`
21+
* Add `ControllerResolver::allowControllers()` to define which callables are legit controllers when the `_check_controller_is_allowed` request attribute is set
2122

2223
6.3
2324
---

src/Symfony/Component/HttpKernel/Controller/ControllerResolver.php

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
namespace Symfony\Component\HttpKernel\Controller;
1313

1414
use Psr\Log\LoggerInterface;
15+
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
1516
use Symfony\Component\HttpFoundation\Request;
17+
use Symfony\Component\HttpKernel\Attribute\AsController;
1618

1719
/**
1820
* This implementation uses the '_controller' request attribute to determine
@@ -24,12 +26,32 @@
2426
class ControllerResolver implements ControllerResolverInterface
2527
{
2628
private ?LoggerInterface $logger;
29+
private array $allowedControllerTypes = [];
30+
private array $allowedControllerAttributes = [AsController::class => AsController::class];
2731

2832
public function __construct(LoggerInterface $logger = null)
2933
{
3034
$this->logger = $logger;
3135
}
3236

37+
/**
38+
* @param array<class-string> $types
39+
* @param array<class-string> $attributes
40+
*/
41+
public function allowControllers(array $types = [], array $attributes = []): void
42+
{
43+
foreach ($types as $type) {
44+
$this->allowedControllerTypes[$type] = $type;
45+
}
46+
47+
foreach ($attributes as $attribute) {
48+
$this->allowedControllerAttributes[$attribute] = $attribute;
49+
}
50+
}
51+
52+
/**
53+
* @throws BadRequestException when the request has attribute "_check_controller_is_allowed" set to true and the controller is not allowed
54+
*/
3355
public function getController(Request $request): callable|false
3456
{
3557
if (!$controller = $request->attributes->get('_controller')) {
@@ -44,7 +66,7 @@ public function getController(Request $request): callable|false
4466
$controller[0] = $this->instantiateController($controller[0]);
4567
} catch (\Error|\LogicException $e) {
4668
if (\is_callable($controller)) {
47-
return $controller;
69+
return $this->checkController($request, $controller);
4870
}
4971

5072
throw $e;
@@ -55,19 +77,19 @@ public function getController(Request $request): callable|false
5577
throw new \InvalidArgumentException(sprintf('The controller for URI "%s" is not callable: ', $request->getPathInfo()).$this->getControllerError($controller));
5678
}
5779

58-
return $controller;
80+
return $this->checkController($request, $controller);
5981
}
6082

6183
if (\is_object($controller)) {
6284
if (!\is_callable($controller)) {
6385
throw new \InvalidArgumentException(sprintf('The controller for URI "%s" is not callable: ', $request->getPathInfo()).$this->getControllerError($controller));
6486
}
6587

66-
return $controller;
88+
return $this->checkController($request, $controller);
6789
}
6890

6991
if (\function_exists($controller)) {
70-
return $controller;
92+
return $this->checkController($request, $controller);
7193
}
7294

7395
try {
@@ -80,7 +102,7 @@ public function getController(Request $request): callable|false
80102
throw new \InvalidArgumentException(sprintf('The controller for URI "%s" is not callable: ', $request->getPathInfo()).$this->getControllerError($callable));
81103
}
82104

83-
return $callable;
105+
return $this->checkController($request, $callable);
84106
}
85107

86108
/**
@@ -199,4 +221,59 @@ private function getClassMethodsWithoutMagicMethods($classOrObject): array
199221

200222
return array_filter($methods, fn (string $method) => 0 !== strncmp($method, '__', 2));
201223
}
224+
225+
private function checkController(Request $request, callable $controller): callable
226+
{
227+
if (!$request->attributes->get('_check_controller_is_allowed', false)) {
228+
return $controller;
229+
}
230+
231+
$r = null;
232+
233+
if (\is_array($controller)) {
234+
[$class, $name] = $controller;
235+
$name = (\is_string($class) ? $class : $class::class).'::'.$name;
236+
} elseif (\is_object($controller) && !$controller instanceof \Closure) {
237+
$class = $controller;
238+
$name = $class::class.'::__invoke';
239+
} else {
240+
$r = new \ReflectionFunction($controller);
241+
$name = $r->name;
242+
243+
if (str_contains($name, '{closure}')) {
244+
$name = $class = \Closure::class;
245+
} elseif ($class = \PHP_VERSION_ID >= 80111 ? $r->getClosureCalledClass() : $r->getClosureScopeClass()) {
246+
$class = $class->name;
247+
$name = $class.'::'.$name;
248+
}
249+
}
250+
251+
if ($class) {
252+
foreach ($this->allowedControllerTypes as $type) {
253+
if (is_a($class, $type, true)) {
254+
return $controller;
255+
}
256+
}
257+
}
258+
259+
$r ??= new \ReflectionClass($class);
260+
261+
foreach ($r->getAttributes() as $attribute) {
262+
if (isset($this->allowedControllerAttributes[$attribute->getName()])) {
263+
return $controller;
264+
}
265+
}
266+
267+
if (str_contains($name, '@anonymous')) {
268+
$name = preg_replace_callback('/[a-zA-Z_\x7f-\xff][\\\\a-zA-Z0-9_\x7f-\xff]*+@anonymous\x00.*?\.php(?:0x?|:[0-9]++\$)[0-9a-fA-F]++/', fn ($m) => class_exists($m[0], false) ? (get_parent_class($m[0]) ?: key(class_implements($m[0])) ?: 'class').'@anonymous' : $m[0], $name);
269+
}
270+
271+
if (-1 === $request->attributes->get('_check_controller_is_allowed')) {
272+
trigger_deprecation('symfony/http-kernel', '6.4', 'Callable "%s()" is not allowed as a controller. Did you miss tagging it with "#[AsController]" or registering its type with "%s::allowControllers()"?', $name, self::class);
273+
274+
return $controller;
275+
}
276+
277+
throw new BadRequestException(sprintf('Callable "%s()" is not allowed as a controller. Did you miss tagging it with "#[AsController]" or registering its type with "%s::allowControllers()"?', $name, self::class));
278+
}
202279
}

src/Symfony/Component/HttpKernel/EventListener/FragmentListener.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ public function onKernelRequest(RequestEvent $event): void
7070
}
7171

7272
parse_str($request->query->get('_path', ''), $attributes);
73+
$attributes['_check_controller_is_allowed'] = -1; // @deprecated, switch to true in Symfony 7
7374
$request->attributes->add($attributes);
7475
$request->attributes->set('_route_params', array_replace($request->attributes->get('_route_params', []), $attributes));
7576
$request->query->remove('_path');

src/Symfony/Component/HttpKernel/Tests/Controller/ControllerResolverTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Psr\Log\LoggerInterface;
16+
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
1617
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpKernel\Attribute\AsController;
1719
use Symfony\Component\HttpKernel\Controller\ControllerResolver;
1820

1921
class ControllerResolverTest extends TestCase
@@ -185,12 +187,77 @@ public static function getUndefinedControllers()
185187
];
186188
}
187189

190+
public function testAllowedControllerTypes()
191+
{
192+
$resolver = $this->createControllerResolver();
193+
194+
$request = Request::create('/');
195+
$controller = new ControllerTest();
196+
$request->attributes->set('_controller', [$controller, 'publicAction']);
197+
$request->attributes->set('_check_controller_is_allowed', true);
198+
199+
try {
200+
$resolver->getController($request);
201+
$this->expectException(BadRequestException::class);
202+
} catch (BadRequestException) {
203+
// expected
204+
}
205+
206+
$resolver->allowControllers(types: [ControllerTest::class]);
207+
208+
$this->assertSame([$controller, 'publicAction'], $resolver->getController($request));
209+
210+
$request->attributes->set('_controller', $action = $controller->publicAction(...));
211+
$this->assertSame($action, $resolver->getController($request));
212+
}
213+
214+
public function testAllowedControllerAttributes()
215+
{
216+
$resolver = $this->createControllerResolver();
217+
218+
$request = Request::create('/');
219+
$controller = some_controller_function(...);
220+
$request->attributes->set('_controller', $controller);
221+
$request->attributes->set('_check_controller_is_allowed', true);
222+
223+
try {
224+
$resolver->getController($request);
225+
$this->expectException(BadRequestException::class);
226+
} catch (BadRequestException) {
227+
// expected
228+
}
229+
230+
$resolver->allowControllers(attributes: [DummyController::class]);
231+
232+
$this->assertSame($controller, $resolver->getController($request));
233+
234+
$controller = some_controller_function::class;
235+
$request->attributes->set('_controller', $controller);
236+
$this->assertSame($controller, $resolver->getController($request));
237+
}
238+
239+
public function testAllowedAsControllerAttribute()
240+
{
241+
$resolver = $this->createControllerResolver();
242+
243+
$request = Request::create('/');
244+
$controller = new InvokableController();
245+
$request->attributes->set('_controller', [$controller, '__invoke']);
246+
$request->attributes->set('_check_controller_is_allowed', true);
247+
248+
$this->assertSame([$controller, '__invoke'], $resolver->getController($request));
249+
250+
$request->attributes->set('_controller', $controller);
251+
$this->assertSame($controller, $resolver->getController($request));
252+
}
253+
188254
protected function createControllerResolver(LoggerInterface $logger = null)
189255
{
190256
return new ControllerResolver($logger);
191257
}
192258
}
193259

260+
#[DummyController]
194261
function some_controller_function($foo, $foobar)
195262
{
196263
}
@@ -223,6 +290,7 @@ public static function staticAction()
223290
}
224291
}
225292

293+
#[AsController]
226294
class InvokableController
227295
{
228296
public function __invoke($foo, $bar = null)

src/Symfony/Component/HttpKernel/Tests/EventListener/FragmentListenerTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public function testWithSignature()
8383

8484
$listener->onKernelRequest($event);
8585

86-
$this->assertEquals(['foo' => 'bar', '_controller' => 'foo'], $request->attributes->get('_route_params'));
86+
$this->assertEquals(['foo' => 'bar', '_controller' => 'foo', '_check_controller_is_allowed' => -1], $request->attributes->get('_route_params'));
8787
$this->assertFalse($request->query->has('_path'));
8888
}
8989

0 commit comments

Comments
 (0)