Skip to content
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
9 changes: 9 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,12 @@ parameters:
identifier: return.type
count: 1
path: src/Component/src/Metadata/Resource/Factory/CachedResourceClassListFactory.php

# ResourceMappingDriverChain intentionally extends @final MappingDriverChain
# Required for compatibility with Gedmo/DoctrineExtensions which calls instanceof MappingDriverChain
# See: src/Bundle/Doctrine/ResourceMappingDriverChain.php class docblock
# See: \Gedmo\Mapping\ExtensionMetadataFactory::getDriver()
- message: '#^Class Sylius\\Bundle\\ResourceBundle\\Doctrine\\ResourceMappingDriverChain extends @final class Doctrine\\Persistence\\Mapping\\Driver\\MappingDriverChain\.$#'
identifier: class.extendsFinalByPhpDoc
count: 1
path: src/Bundle/Doctrine/ResourceMappingDriverChain.php
7 changes: 4 additions & 3 deletions src/Bundle/Controller/ResourceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public function __construct(
?StateMachineInterface $stateMachine,
ResourceUpdateHandlerInterface $resourceUpdateHandler,
ResourceDeleteHandlerInterface $resourceDeleteHandler,
protected readonly string $csrfParameter = '_csrf_token',
) {
$this->metadata = $metadata;
$this->requestConfigurationFactory = $requestConfigurationFactory;
Expand Down Expand Up @@ -333,7 +334,7 @@ public function deleteAction(Request $request): Response
$this->isGrantedOr403($configuration, ResourceActions::DELETE);
$resource = $this->findOr404($configuration);

if ($configuration->isCsrfProtectionEnabled() && !$this->isCsrfTokenValid((string) $resource->getId(), (string) $request->request->get('_csrf_token'))) {
if ($configuration->isCsrfProtectionEnabled() && !$this->isCsrfTokenValid((string) $resource->getId(), (string) $request->request->get($this->csrfParameter))) {
throw new HttpException(Response::HTTP_FORBIDDEN, 'Invalid csrf token.');
}

Expand Down Expand Up @@ -392,7 +393,7 @@ public function bulkDeleteAction(Request $request): Response

if (
$configuration->isCsrfProtectionEnabled() &&
!$this->isCsrfTokenValid(ResourceActions::BULK_DELETE, (string) $request->request->get('_csrf_token'))
!$this->isCsrfTokenValid(ResourceActions::BULK_DELETE, (string) $request->request->get($this->csrfParameter))
) {
throw new HttpException(Response::HTTP_FORBIDDEN, 'Invalid csrf token.');
}
Expand Down Expand Up @@ -455,7 +456,7 @@ public function applyStateMachineTransitionAction(Request $request): Response
$this->isGrantedOr403($configuration, ResourceActions::UPDATE);
$resource = $this->findOr404($configuration);

if ($configuration->isCsrfProtectionEnabled() && !$this->isCsrfTokenValid((string) $resource->getId(), $this->getFromRequest($request, '_csrf_token'))) {
if ($configuration->isCsrfProtectionEnabled() && !$this->isCsrfTokenValid((string) $resource->getId(), $this->getFromRequest($request, $this->csrfParameter))) {
throw new HttpException(Response::HTTP_FORBIDDEN, 'Invalid CSRF token.');
}

Expand Down
2 changes: 2 additions & 0 deletions src/Bundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ private function addSettingsSection(ArrayNodeDefinition $node): void
->booleanNode('filterable')->defaultFalse()->end()
->variableNode('criteria')->defaultNull()->end()
->scalarNode('state_machine_component')->defaultNull()->end()
->scalarNode('csrf_parameter')->defaultValue('_csrf_token')->cannotBeEmpty()
->end()
->end()
->end()
->end()
Expand Down
1 change: 1 addition & 0 deletions src/Bundle/DependencyInjection/Driver/AbstractDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ protected function addController(ContainerBuilder $container, MetadataInterface
new Reference($metadata->getServiceId('controller_state_machine'), ContainerInterface::NULL_ON_INVALID_REFERENCE),
new Reference('sylius.resource_controller.resource_update_handler'),
new Reference('sylius.resource_controller.resource_delete_handler'),
'%sylius.resource.csrf_parameter%',
])
->addMethodCall('setContainer', [new Reference('service_container')])
->addTag('controller.service_arguments')
Expand Down
1 change: 1 addition & 0 deletions src/Bundle/DependencyInjection/SyliusResourceExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public function load(array $configs, ContainerBuilder $container): void

$container->setParameter('sylius.resource.mapping', $config['mapping']);
$container->setParameter('sylius.resource.settings', $config['settings']);
$container->setParameter('sylius.resource.csrf_parameter', $config['settings']['csrf_parameter']);

$routingPathBcLayer = $config['routing_path_bc_layer'] ?? null;

Expand Down
5 changes: 5 additions & 0 deletions src/Bundle/Resources/config/services/twig.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use Sylius\Bundle\ResourceBundle\Twig\Context\LegacyContextFactory;
use Sylius\Bundle\ResourceBundle\Twig\CsrfParameterExtension;
use Sylius\Resource\Twig\Context\Factory\ContextFactory;
use Sylius\Resource\Twig\Context\Factory\ContextFactoryInterface;
use Sylius\Resource\Twig\Context\Factory\DefaultContextFactory;
Expand All @@ -22,6 +23,10 @@
return static function (ContainerConfigurator $container) {
$services = $container->services();

$services->set('sylius.twig.extension.csrf_parameter', CsrfParameterExtension::class)
->args(['%sylius.resource.csrf_parameter%'])
->tag('twig.extension');

$services->set('sylius.twig.context.factory', ContextFactory::class)
->args([tagged_locator('sylius.twig_context_factory')]);

Expand Down
36 changes: 36 additions & 0 deletions src/Bundle/Twig/CsrfParameterExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Sylius\Bundle\ResourceBundle\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

final class CsrfParameterExtension extends AbstractExtension
{
public function __construct(private readonly string $csrfParameter)
{
}

public function getFunctions(): array
{
return [
new TwigFunction('sylius_csrf_parameter', $this->getCsrfParameter(...)),
];
}

public function getCsrfParameter(): string
{
return $this->csrfParameter;
}
}
170 changes: 170 additions & 0 deletions tests/Bundle/Controller/ResourceControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2898,6 +2898,176 @@ public function testDoesNotApplyStateMachineTransitionResourceAndThrowsHttpExcep
$this->resourceController->applyStateMachineTransitionAction($request);
}

public function testValidatesCsrfWithDefaultParameterName(): void
{
/** @var RequestConfiguration|MockObject $configurationMock */
$configurationMock = $this->createMock(RequestConfiguration::class);
/** @var ResourceInterface|MockObject $resourceMock */
$resourceMock = $this->createMock(ResourceInterface::class);
/** @var CsrfTokenManagerInterface|MockObject $csrfTokenManagerMock */
$csrfTokenManagerMock = $this->createMock(CsrfTokenManagerInterface::class);
/** @var ResourceControllerEvent|MockObject $eventMock */
$eventMock = $this->createMock(ResourceControllerEvent::class);
/** @var ResourceControllerEvent|MockObject $postEventMock */
$postEventMock = $this->createMock(ResourceControllerEvent::class);
/** @var Response|MockObject $redirectResponseMock */
$redirectResponseMock = $this->createMock(Response::class);

$request = new Request(request: ['_csrf_token' => 'valid-token-here']);

$this->requestConfigurationFactoryMock->expects($this->once())->method('create')->with($this->metadataMock, $request)->willReturn($configurationMock);

$configurationMock->expects($this->once())->method('hasPermission')->willReturn(true);
$configurationMock->expects($this->once())->method('getPermission')->with(ResourceActions::DELETE)->willReturn('sylius.product.delete');
$configurationMock->method('isHtmlRequest')->willReturn(true);
$configurationMock->expects($this->once())->method('isCsrfProtectionEnabled')->willReturn(true);

$this->containerMock->expects($this->once())->method('has')->with('security.csrf.token_manager')->willReturn(true);
$this->containerMock->expects($this->once())->method('get')->with('security.csrf.token_manager')->willReturn($csrfTokenManagerMock);

$csrfTokenManagerMock->expects($this->once())->method('isTokenValid')->with(new CsrfToken('1', 'valid-token-here'))->willReturn(true);

$this->authorizationCheckerMock->expects($this->once())->method('isGranted')->with($configurationMock, 'sylius.product.delete')->willReturn(true);
$this->singleResourceProviderMock->expects($this->once())->method('get')->with($configurationMock, $this->repositoryMock)->willReturn($resourceMock);

$resourceMock->expects($this->once())->method('getId')->willReturn(1);

$this->eventDispatcherMock->expects($this->once())->method('dispatchPreEvent')->with(ResourceActions::DELETE, $configurationMock, $resourceMock)->willReturn($eventMock);
$eventMock->method('isStopped')->willReturn(false);

$this->resourceDeleteHandlerMock->expects($this->once())->method('handle')->with($resourceMock, $this->repositoryMock);
$this->eventDispatcherMock->expects($this->once())->method('dispatchPostEvent')->with(ResourceActions::DELETE, $configurationMock, $resourceMock)->willReturn($postEventMock);

$postEventMock->expects($this->once())->method('getResponse')->willReturn(null);

$this->flashHelperMock->expects($this->once())->method('addSuccessFlash')->with($configurationMock, ResourceActions::DELETE, $resourceMock);
$this->redirectHandlerMock->expects($this->once())->method('redirectToIndex')->with($configurationMock, $resourceMock)->willReturn($redirectResponseMock);

$this->assertSame($redirectResponseMock, $this->resourceController->deleteAction($request));
}

public function testValidatesCsrfWithCustomParameterName(): void
{
$controller = $this->buildControllerWithCsrfParameter('_my_csrf_field');

/** @var RequestConfiguration|MockObject $configurationMock */
$configurationMock = $this->createMock(RequestConfiguration::class);
/** @var ResourceInterface|MockObject $resourceMock */
$resourceMock = $this->createMock(ResourceInterface::class);
/** @var CsrfTokenManagerInterface|MockObject $csrfTokenManagerMock */
$csrfTokenManagerMock = $this->createMock(CsrfTokenManagerInterface::class);
/** @var ResourceControllerEvent|MockObject $eventMock */
$eventMock = $this->createMock(ResourceControllerEvent::class);
/** @var ResourceControllerEvent|MockObject $postEventMock */
$postEventMock = $this->createMock(ResourceControllerEvent::class);
/** @var Response|MockObject $redirectResponseMock */
$redirectResponseMock = $this->createMock(Response::class);

$request = new Request(request: ['_my_csrf_field' => 'valid-token-here']);

$this->requestConfigurationFactoryMock->expects($this->once())->method('create')->with($this->metadataMock, $request)->willReturn($configurationMock);

$configurationMock->expects($this->once())->method('hasPermission')->willReturn(true);
$configurationMock->expects($this->once())->method('getPermission')->with(ResourceActions::DELETE)->willReturn('sylius.product.delete');
$configurationMock->method('isHtmlRequest')->willReturn(true);
$configurationMock->expects($this->once())->method('isCsrfProtectionEnabled')->willReturn(true);

$this->containerMock->expects($this->once())->method('has')->with('security.csrf.token_manager')->willReturn(true);
$this->containerMock->expects($this->once())->method('get')->with('security.csrf.token_manager')->willReturn($csrfTokenManagerMock);

$csrfTokenManagerMock->expects($this->once())->method('isTokenValid')->with(new CsrfToken('1', 'valid-token-here'))->willReturn(true);

$this->authorizationCheckerMock->expects($this->once())->method('isGranted')->with($configurationMock, 'sylius.product.delete')->willReturn(true);
$this->singleResourceProviderMock->expects($this->once())->method('get')->with($configurationMock, $this->repositoryMock)->willReturn($resourceMock);

$resourceMock->expects($this->once())->method('getId')->willReturn(1);

$this->eventDispatcherMock->expects($this->once())->method('dispatchPreEvent')->with(ResourceActions::DELETE, $configurationMock, $resourceMock)->willReturn($eventMock);
$eventMock->method('isStopped')->willReturn(false);

$this->resourceDeleteHandlerMock->expects($this->once())->method('handle')->with($resourceMock, $this->repositoryMock);
$this->eventDispatcherMock->expects($this->once())->method('dispatchPostEvent')->with(ResourceActions::DELETE, $configurationMock, $resourceMock)->willReturn($postEventMock);

$postEventMock->expects($this->once())->method('getResponse')->willReturn(null);

$this->flashHelperMock->expects($this->once())->method('addSuccessFlash')->with($configurationMock, ResourceActions::DELETE, $resourceMock);
$this->redirectHandlerMock->expects($this->once())->method('redirectToIndex')->with($configurationMock, $resourceMock)->willReturn($redirectResponseMock);

$this->assertSame($redirectResponseMock, $controller->deleteAction($request));
}

public function testRejectsWhenCsrfTokenIsInWrongField(): void
{
$controller = $this->buildControllerWithCsrfParameter('_my_csrf_field');

/** @var RequestConfiguration|MockObject $configurationMock */
$configurationMock = $this->createMock(RequestConfiguration::class);
/** @var ResourceInterface|MockObject $resourceMock */
$resourceMock = $this->createMock(ResourceInterface::class);
/** @var CsrfTokenManagerInterface|MockObject $csrfTokenManagerMock */
$csrfTokenManagerMock = $this->createMock(CsrfTokenManagerInterface::class);

$request = new Request(request: ['_csrf_token' => 'valid-token-here']);

$this->requestConfigurationFactoryMock->expects($this->once())->method('create')->with($this->metadataMock, $request)->willReturn($configurationMock);

$configurationMock->expects($this->once())->method('hasPermission')->willReturn(true);
$configurationMock->expects($this->once())->method('getPermission')->with(ResourceActions::DELETE)->willReturn('sylius.product.delete');
$configurationMock->expects($this->once())->method('isCsrfProtectionEnabled')->willReturn(true);

$this->containerMock->expects($this->once())->method('has')->with('security.csrf.token_manager')->willReturn(true);
$this->containerMock->expects($this->once())->method('get')->with('security.csrf.token_manager')->willReturn($csrfTokenManagerMock);

$csrfTokenManagerMock->expects($this->once())->method('isTokenValid')->with(new CsrfToken('1', ''))->willReturn(false);

$this->authorizationCheckerMock->expects($this->once())->method('isGranted')->with($configurationMock, 'sylius.product.delete')->willReturn(true);
$this->singleResourceProviderMock->expects($this->once())->method('get')->with($configurationMock, $this->repositoryMock)->willReturn($resourceMock);

$resourceMock->expects($this->once())->method('getId')->willReturn(1);

$this->resourceDeleteHandlerMock->expects($this->never())->method('handle');
$this->eventDispatcherMock->expects($this->never())->method('dispatchPostEvent');
$this->flashHelperMock->expects($this->never())->method('addSuccessFlash');

$this->expectException(HttpException::class);
$this->expectExceptionMessage('Invalid csrf token.');

try {
$controller->deleteAction($request);
} catch (HttpException $exception) {
$this->assertSame(Response::HTTP_FORBIDDEN, $exception->getStatusCode());

throw $exception;
}
}

private function buildControllerWithCsrfParameter(string $csrfParameter): ResourceController
{
$controller = new ResourceController(
$this->metadataMock,
$this->requestConfigurationFactoryMock,
$this->viewHandlerMock,
$this->repositoryMock,
$this->factoryMock,
$this->newResourceFactoryMock,
$this->managerMock,
$this->singleResourceProviderMock,
$this->resourcesCollectionProviderMock,
$this->resourceFormFactoryMock,
$this->redirectHandlerMock,
$this->flashHelperMock,
$this->authorizationCheckerMock,
$this->eventDispatcherMock,
$this->stateMachineMock,
$this->resourceUpdateHandlerMock,
$this->resourceDeleteHandlerMock,
$csrfParameter,
);
$controller->setContainer($this->containerMock);

return $controller;
}

private function markAsSkippedIfFosRestBundleIsNotAvailable(): void
{
if (!class_exists(FOSRestBundle::class)) {
Expand Down
18 changes: 18 additions & 0 deletions tests/Bundle/DependencyInjection/SyliusResourceExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,24 @@ public function testItRegistersCustomPathSegmentNameGenerator(): void
$this->assertSame('sylius.metadata.path_segment_name_generator.underscore', $this->container->getAlias('sylius.path_segment_name_generator')->__toString());
}

public function testItRegistersDefaultCsrfParameter(): void
{
$this->load();

$this->assertContainerBuilderHasParameter('sylius.resource.csrf_parameter', '_csrf_token');
}

public function testItRegistersCustomCsrfParameter(): void
{
$this->load([
'settings' => [
'csrf_parameter' => '_custom_csrf_token',
],
]);

$this->assertContainerBuilderHasParameter('sylius.resource.csrf_parameter', '_custom_csrf_token');
}

protected function getContainerExtensions(): array
{
$this->setParameter('kernel.bundles', []);
Expand Down
Loading