Skip to content

Commit

Permalink
Support CSRF protection token of Symfony's form component (#2132)
Browse files Browse the repository at this point in the history
* Support CSRF protection token of Symfony's form component (implements #2120)

* Made unit test of form model describer compatible to php versions prior to PHP 8.0

* Added functional test for csrf token description in form models

* Fixed code style in tests

* Handle enabled csrf_protection in form describer if form extension not loaded

* Added and improved tests for csrf form token description

* Fixed StyleCI issues in test controller

* update baseline

---------

Co-authored-by: DjordyKoert <djordy.koert@yoursurprise.com>
  • Loading branch information
stollr and DjordyKoert committed Jan 26, 2024
1 parent d7f9b80 commit b96c263
Show file tree
Hide file tree
Showing 12 changed files with 362 additions and 1 deletion.
1 change: 1 addition & 0 deletions DependencyInjection/Compiler/ConfigurationPass.php
Expand Up @@ -33,6 +33,7 @@ public function process(ContainerBuilder $container): void
->addArgument(new Reference('annotations.reader', ContainerInterface::NULL_ON_INVALID_REFERENCE))
->addArgument($container->getParameter('nelmio_api_doc.media_types'))
->addArgument($container->getParameter('nelmio_api_doc.use_validation_groups'))
->addArgument($container->getParameter('form.type_extension.csrf.enabled'))
->addTag('nelmio_api_doc.model_describer', ['priority' => 100]);
}

Expand Down
19 changes: 18 additions & 1 deletion ModelDescriber/FormModelDescriber.php
Expand Up @@ -47,12 +47,14 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry
private $doctrineReader;
private $mediaTypes;
private $useValidationGroups;
private $isFormCsrfExtensionEnabled;

public function __construct(
FormFactoryInterface $formFactory = null,
Reader $reader = null,
array $mediaTypes = null,
bool $useValidationGroups = false
bool $useValidationGroups = false,
bool $isFormCsrfExtensionEnabled = false
) {
$this->formFactory = $formFactory;
$this->doctrineReader = $reader;
Expand All @@ -64,6 +66,7 @@ public function __construct(
}
$this->mediaTypes = $mediaTypes;
$this->useValidationGroups = $useValidationGroups;
$this->isFormCsrfExtensionEnabled = $isFormCsrfExtensionEnabled;
}

public function describe(Model $model, OA\Schema $schema)
Expand Down Expand Up @@ -136,6 +139,20 @@ private function parseForm(OA\Schema $schema, FormInterface $form)

$this->findFormType($config, $property);
}

if ($this->isFormCsrfExtensionEnabled && $form->getConfig()->getOption('csrf_protection', false)) {
$tokenFieldName = $form->getConfig()->getOption('csrf_field_name');

$property = Util::getProperty($schema, $tokenFieldName);
$property->type = 'string';
$property->description = 'CSRF token';

if (Generator::isDefault($schema->required)) {
$schema->required = [];
}

$schema->required[] = $tokenFieldName;
}
}

/**
Expand Down
36 changes: 36 additions & 0 deletions Tests/Functional/Controller/ApiController80.php
Expand Up @@ -33,6 +33,8 @@
use Nelmio\ApiDocBundle\Tests\Functional\EntityExcluded\SerializedNameEnt;
use Nelmio\ApiDocBundle\Tests\Functional\Form\DummyType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithAlternateSchemaType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithCsrfProtectionDisabledType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithCsrfProtectionEnabledType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithModel;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithRefType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\UserType;
Expand Down Expand Up @@ -444,6 +446,40 @@ public function formWithRefSchemaType()
{
}

/**
* @Route("/form-with-csrf-protection-enabled-type", methods={"POST"})
*
* @OA\Response(
* response="204",
* description="Operation automatically detected",
* ),
*
* @OA\RequestBody(
*
* @Model(type=FormWithCsrfProtectionEnabledType::class)
* )
*/
public function formWithCsrfProtectionEnabledType()
{
}

/**
* @Route("/form-with-csrf-protection-disabled-type", methods={"POST"})
*
* @OA\Response(
* response="204",
* description="Operation automatically detected",
* ),
*
* @OA\RequestBody(
*
* @Model(type=FormWithCsrfProtectionDisabledType::class)
* )
*/
public function formWithCsrfProtectionDisabledType()
{
}

/**
* @Route("/entity-with-nullable-property-set", methods={"GET"})
*
Expand Down
26 changes: 26 additions & 0 deletions Tests/Functional/Controller/ApiController81.php
Expand Up @@ -38,6 +38,8 @@
use Nelmio\ApiDocBundle\Tests\Functional\EntityExcluded\Symfony7\SerializedNameEntity;
use Nelmio\ApiDocBundle\Tests\Functional\Form\DummyType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithAlternateSchemaType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithCsrfProtectionDisabledType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithCsrfProtectionEnabledType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithModel;
use Nelmio\ApiDocBundle\Tests\Functional\Form\FormWithRefType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\UserType;
Expand Down Expand Up @@ -364,6 +366,30 @@ public function formWithRefSchemaType()
{
}

#[Route('/form-with-csrf-protection-enabled-type', methods: ['POST'])]
#[OA\Response(
response: 204,
description: 'Operation automatically detected',
)]
#[OA\RequestBody(
content: new Model(type: FormWithCsrfProtectionEnabledType::class),
)]
public function formWithCsrfProtectionEnabledType()
{
}

#[Route('/form-with-csrf-protection-disabled-type', methods: ['POST'])]
#[OA\Response(
response: 204,
description: 'Operation automatically detected',
)]
#[OA\RequestBody(
content: new Model(type: FormWithCsrfProtectionDisabledType::class),
)]
public function formWithCsrfProtectionDisabledType()
{
}

#[Route('/entity-with-nullable-property-set', methods: ['GET'])]
#[OA\Response(
response: 201,
Expand Down
73 changes: 73 additions & 0 deletions Tests/Functional/CsrfProtectionFunctionalTest.php
@@ -0,0 +1,73 @@
<?php

namespace Nelmio\ApiDocBundle\Tests\Functional;

use Symfony\Component\HttpKernel\KernelInterface;

class CsrfProtectionFunctionalTest extends WebTestCase
{
protected function setUp(): void
{
parent::setUp();
static::bootKernel();
}

protected static function createKernel(array $options = []): KernelInterface
{
return new TestKernel(TestKernel::USE_FORM_CSRF);
}

public function testTokenPropertyExistsPerDefaultIfEnabledPerFrameworkConfig(): void
{
// Make sure that test precondition is correct.
$isCsrfFormExtensionEnabled = self::getContainer()->getParameter('form.type_extension.csrf.enabled');
$this->assertTrue($isCsrfFormExtensionEnabled, 'The test needs the csrf form extension to be enabled.');

$this->assertEquals([
'type' => 'object',
'properties' => [
'quz' => [
'$ref' => '#/components/schemas/User',
],
'_token' => [
'description' => 'CSRF token',
'type' => 'string',
],
],
'required' => ['quz', '_token'],
'schema' => 'FormWithModel',
], json_decode($this->getModel('FormWithModel')->toJson(), true));
}

public function testTokenPropertyExistsIfCsrfProtectionIsEnabled(): void
{
$this->assertEquals([
'type' => 'object',
'properties' => [
'name' => [
'type' => 'string',
],
'_token' => [
'description' => 'CSRF token',
'type' => 'string',
],
],
'required' => ['name', '_token'],
'schema' => 'FormWithCsrfProtectionEnabledType',
], json_decode($this->getModel('FormWithCsrfProtectionEnabledType')->toJson(), true));
}

public function testTokenPropertyNotExistsIfCsrfProtectionIsDisabled(): void
{
$this->assertEquals([
'type' => 'object',
'properties' => [
'name' => [
'type' => 'string',
],
],
'required' => ['name'],
'schema' => 'FormWithCsrfProtectionDisabledType',
], json_decode($this->getModel('FormWithCsrfProtectionDisabledType')->toJson(), true));
}
}
21 changes: 21 additions & 0 deletions Tests/Functional/Form/FormWithCsrfProtectionDisabledType.php
@@ -0,0 +1,21 @@
<?php

namespace Nelmio\ApiDocBundle\Tests\Functional\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class FormWithCsrfProtectionDisabledType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', TextType::class);
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('csrf_protection', false);
}
}
21 changes: 21 additions & 0 deletions Tests/Functional/Form/FormWithCsrfProtectionEnabledType.php
@@ -0,0 +1,21 @@
<?php

namespace Nelmio\ApiDocBundle\Tests\Functional\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class FormWithCsrfProtectionEnabledType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', TextType::class);
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('csrf_protection', true);
}
}
13 changes: 13 additions & 0 deletions Tests/Functional/FunctionalTest.php
Expand Up @@ -1056,6 +1056,19 @@ public function testFormWithRefInSchemaDoNoReadOtherProperties()
$this->assertSame(Generator::UNDEFINED, $model->properties);
}

public function testFormCsrfIsOnlyDetectedIfCsrfExtensionIsEnabled(): void
{
// Make sure that test precondition is correct.
$isCsrfFormExtensionEnabled = self::getContainer()->getParameter('form.type_extension.csrf.enabled');
$this->assertFalse($isCsrfFormExtensionEnabled, 'The test needs the csrf form extension to be disabled.');

$model = $this->getModel('FormWithCsrfProtectionEnabledType');

// Make sure that no token property was added
$this->assertCount(1, $model->properties);
$this->assertHasProperty('name', $model);
}

public function testEntityWithNullableSchemaSet()
{
$model = $this->getModel('EntityWithNullableSchemaSet');
Expand Down
7 changes: 7 additions & 0 deletions Tests/Functional/TestKernel.php
Expand Up @@ -47,6 +47,7 @@ class TestKernel extends Kernel
const USE_BAZINGA = 2;
const USE_FOSREST = 3;
const USE_VALIDATION_GROUPS = 8;
const USE_FORM_CSRF = 16;

private $flags;

Expand Down Expand Up @@ -145,6 +146,12 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
'property_access' => true,
];

if ($this->flags & self::USE_FORM_CSRF) {
$framework['csrf_protection']['enabled'] = true;
$framework['session']['storage_factory_id'] = 'session.storage.factory.mock_file';
$framework['form'] = ['csrf_protection' => true];
}

// Support symfony/framework-bundle < 5.4
if (method_exists(CachePoolClearCommand::class, 'complete')) {
$framework += [
Expand Down
84 changes: 84 additions & 0 deletions Tests/ModelDescriber/FormModelDescriberTest.php
@@ -0,0 +1,84 @@
<?php

namespace Nelmio\ApiDocBundle\Tests\ModelDescriber;

use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Model\Model;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
use Nelmio\ApiDocBundle\ModelDescriber\FormModelDescriber;
use OpenApi\Annotations\Property;
use OpenApi\Attributes\OpenApi;
use OpenApi\Generator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormConfigInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\PropertyInfo\Type;

class FormModelDescriberTest extends TestCase
{
/**
* @dataProvider provideCsrfProtectionOptions
*/
public function testDescribeCreatesTokenPropertyDependingOnOptions(bool $csrfProtectionEnabled, string $tokenName, bool $expectProperty): void
{
$formConfigMock = $this->createMock(FormConfigInterface::class);
$formConfigMock->expects($this->exactly($csrfProtectionEnabled ? 2 : 1))
->method('getOption')
->willReturnMap([
['csrf_protection', false, $csrfProtectionEnabled],
['csrf_field_name', null, $tokenName],
]);

$formMock = $this->createMock(FormInterface::class);
$formMock->expects($this->exactly($csrfProtectionEnabled ? 2 : 1))
->method('getConfig')
->willReturn($formConfigMock);

$formFactoryMock = $this->createMock(FormFactoryInterface::class);
$formFactoryMock->expects($this->once())
->method('create')
->willReturn($formMock);

$annotationReader = $this->createMock(Reader::class);

$api = new OpenApi();
$model = new Model(new Type(Type::BUILTIN_TYPE_OBJECT, false, FormType::class));
$schema = $this->initSchema();
$modelRegistry = new ModelRegistry([], $api);

$describer = new FormModelDescriber($formFactoryMock, $annotationReader, [], false, true);
$describer->setModelRegistry($modelRegistry);

$describer->describe($model, $schema);

if ($expectProperty) {
$filteredProperties = array_filter($schema->properties, function (Property $property) use ($tokenName) {
return $property->property === $tokenName;
});

$this->assertCount(1, $filteredProperties);
} else {
$this->assertSame(Generator::UNDEFINED, $schema->properties);
}
}

public function provideCsrfProtectionOptions(): array
{
return [
[true, '_token', true],
[true, '_another_token', true],
[false, '_token', false],
];
}

private function initSchema(): \OpenApi\Annotations\Schema
{
if (PHP_VERSION_ID < 80000) {
return new \OpenApi\Annotations\Schema([]);
}

return new \OpenApi\Attributes\Schema(); // union types, used in schema attribute require PHP >= 8.0.0
}
}

0 comments on commit b96c263

Please sign in to comment.