Skip to content

Commit 5bdcb17

Browse files
committed
feature #321 [LiveComponent] Allow to disable CSRF per component (norkunas)
This PR was merged into the 2.x branch. Discussion ---------- [LiveComponent] Allow to disable CSRF per component | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | Fix #320 | License | MIT This allows to configure `#[AsLiveComponent('..', csrf: false)` to disable CSRF protection per component like we can do in Symfony Forms. Commits ------- 7eda046 [LiveComponent] Allow to disable CSRF per component
2 parents 3129eb0 + 7eda046 commit 5bdcb17

File tree

10 files changed

+94
-5
lines changed

10 files changed

+94
-5
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## 2.2.0
4+
5+
- Allow to disable CSRF per component
6+
37
## 2.1.0
48

59
- Your component's live "data" is now send over Ajax as a JSON string.

src/LiveComponent/src/Attribute/AsLiveComponent.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ public function __construct(
2626
?string $template = null,
2727
private ?string $defaultAction = null,
2828
bool $exposePublicProps = true,
29-
string $attributesVar = 'attributes'
29+
string $attributesVar = 'attributes',
30+
public bool $csrf = true,
3031
) {
3132
parent::__construct($name, $template, $exposePublicProps, $attributesVar);
3233
}
@@ -39,6 +40,7 @@ public function serviceConfig(): array
3940
return array_merge(parent::serviceConfig(), [
4041
'default_action' => $this->defaultAction,
4142
'live' => true,
43+
'csrf' => $this->csrf,
4244
]);
4345
}
4446

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Symfony\Contracts\Service\ServiceSubscriberInterface;
1010
use Symfony\UX\LiveComponent\LiveComponentHydrator;
1111
use Symfony\UX\TwigComponent\ComponentAttributes;
12+
use Symfony\UX\TwigComponent\ComponentMetadata;
1213
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
1314
use Symfony\UX\TwigComponent\MountedComponent;
1415
use Twig\Environment;
@@ -33,9 +34,10 @@ public function onPreRender(PreRenderEvent $event): void
3334
return;
3435
}
3536

36-
$attributes = $this->getLiveAttributes($event->getMountedComponent());
37+
$metadata = $event->getMetadata();
38+
$attributes = $this->getLiveAttributes($event->getMountedComponent(), $metadata);
3739
$variables = $event->getVariables();
38-
$attributesKey = $event->getMetadata()->getAttributesVar();
40+
$attributesKey = $metadata->getAttributesVar();
3941

4042
if (isset($variables[$attributesKey]) && $variables[$attributesKey] instanceof ComponentAttributes) {
4143
// merge with existing attributes if available
@@ -62,7 +64,7 @@ public static function getSubscribedServices(): array
6264
];
6365
}
6466

65-
private function getLiveAttributes(MountedComponent $mounted): ComponentAttributes
67+
private function getLiveAttributes(MountedComponent $mounted, ComponentMetadata $metadata): ComponentAttributes
6668
{
6769
$name = $mounted->getName();
6870
$url = $this->container->get(UrlGeneratorInterface::class)->generate('live_component', ['component' => $name]);
@@ -75,7 +77,7 @@ private function getLiveAttributes(MountedComponent $mounted): ComponentAttribut
7577
'data-live-data-value' => twig_escape_filter($twig, json_encode($data, \JSON_THROW_ON_ERROR), 'html_attr'),
7678
];
7779

78-
if ($this->container->has(CsrfTokenManagerInterface::class)) {
80+
if ($this->container->has(CsrfTokenManagerInterface::class) && $metadata->get('csrf')) {
7981
$attributes['data-live-csrf-value'] = $this->container->get(CsrfTokenManagerInterface::class)
8082
->getToken($name)->getValue()
8183
;

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

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

108108
if (
109109
$this->container->has(CsrfTokenManagerInterface::class) &&
110+
$metadata->get('csrf') &&
110111
!$this->container->get(CsrfTokenManagerInterface::class)->isTokenValid(new CsrfToken($componentName, $request->headers->get('X-CSRF-TOKEN')))) {
111112
throw new BadRequestHttpException('Invalid CSRF token.');
112113
}

src/LiveComponent/src/Resources/doc/index.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,20 @@ Your only job is to make sure that the CSRF component is installed:
629629
630630
$ composer require symfony/security-csrf
631631
632+
If you want to disable CSRF for a single component you can set
633+
``csrf`` option to ``false``::
634+
635+
namespace App\Twig\Components;
636+
637+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
638+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
639+
640+
#[AsLiveComponent('my_live_component', csrf: false)]
641+
class MyLiveComponent
642+
{
643+
// ...
644+
}
645+
632646
Actions, Redirecting and AbstractController
633647
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
634648

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
4+
5+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
6+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
7+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
8+
9+
/**
10+
* @author Tomas Norkūnas <[email protected]>
11+
*/
12+
#[AsLiveComponent('disabled_csrf', defaultAction: 'defaultAction()', csrf: false)]
13+
final class DisabledCsrf
14+
{
15+
#[LiveProp]
16+
public int $count = 1;
17+
18+
#[LiveAction]
19+
public function increase(): void
20+
{
21+
++$this->count;
22+
}
23+
24+
public function defaultAction(): void
25+
{
26+
}
27+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<div{{ attributes }}>
2+
Count: {{ this.count }}
3+
</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{ component('disabled_csrf') }}

src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,20 @@ public function testCanUseCustomAttributes(): void
5858
$this->assertNotNull($div->attr('data-live-csrf-value'));
5959
$this->assertArrayHasKey('_checksum', $data);
6060
}
61+
62+
public function testCanDisableCsrf(): void
63+
{
64+
$response = $this->browser()
65+
->visit('/render-template/csrf')
66+
->assertSuccessful()
67+
->response()
68+
->assertHtml()
69+
;
70+
71+
$div = $response->crawler()->filter('div');
72+
73+
$this->assertSame('live', $div->attr('data-controller'));
74+
$this->assertSame('/_components/disabled_csrf', $div->attr('data-live-url-value'));
75+
$this->assertNull($div->attr('data-live-csrf-value'));
76+
}
6177
}

src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ public function testInvalidCsrfTokenForComponentActionFails(): void
133133
$this->fail('Expected exception not thrown.');
134134
}
135135

136+
public function testDisabledCsrfTokenForComponentDoesNotFail(): void
137+
{
138+
$dehydrated = $this->dehydrateComponent($this->mountComponent('disabled_csrf'));
139+
140+
$this->browser()
141+
->throwExceptions()
142+
->get('/_components/disabled_csrf?data='.urlencode(json_encode($dehydrated)))
143+
->assertSuccessful()
144+
->assertHeaderContains('Content-Type', 'html')
145+
->assertContains('Count: 1')
146+
->post('/_components/disabled_csrf/increase', [
147+
'body' => json_encode($dehydrated),
148+
])
149+
->assertSuccessful()
150+
->assertHeaderContains('Content-Type', 'html')
151+
->assertContains('Count: 2')
152+
;
153+
}
154+
136155
public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void
137156
{
138157
$dehydrated = $this->dehydrateComponent($this->mountComponent('component2'));

0 commit comments

Comments
 (0)