From e3c0ef5eadc459e80d454feda1ba1faa8f047c2d Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 9 Oct 2024 12:33:17 +0200 Subject: [PATCH] [LiveComponent] Remove CSRF tokens - rely on same-origin/CORS instead --- src/LiveComponent/CHANGELOG.md | 6 + .../assets/dist/Backend/Backend.d.ts | 4 +- .../assets/dist/Backend/RequestBuilder.d.ts | 4 +- .../assets/dist/live_controller.d.ts | 2 - .../assets/dist/live_controller.js | 22 +--- .../assets/src/Backend/Backend.ts | 9 +- .../assets/src/Backend/RequestBuilder.ts | 12 +- .../assets/src/Component/index.ts | 5 - .../assets/src/live_controller.ts | 4 +- .../test/Backend/RequestBuilder.test.ts | 19 ++- .../assets/test/controller/render.test.ts | 19 --- src/LiveComponent/assets/test/tools.ts | 7 -- src/LiveComponent/composer.json | 1 + src/LiveComponent/doc/index.rst | 26 +--- .../src/Attribute/AsLiveComponent.php | 61 ++++++--- .../Compiler/OptionalDependencyPass.php | 5 + .../LiveComponentExtension.php | 2 - .../EventListener/LiveComponentSubscriber.php | 35 +++--- .../src/Test/TestLiveComponent.php | 14 --- .../Util/ChildComponentPartialRenderer.php | 1 - .../src/Util/LiveAttributesCollection.php | 5 - .../Util/LiveControllerAttributesCreator.php | 13 -- .../Fixtures/Component/ComponentWithEmit.php | 2 +- .../tests/Fixtures/Component/DisabledCsrf.php | 27 ---- .../FormWithCollectionTypeComponent.php | 6 +- .../Component/ValidatingComponent.php | 2 +- .../Form/BlogPostFormLiveCollectionType.php | 1 - .../tests/Fixtures/Form/BlogPostFormType.php | 1 - .../tests/Fixtures/Form/CommentFormType.php | 1 - .../Form/FormWithManyDifferentFieldsType.php | 7 -- src/LiveComponent/tests/Fixtures/Kernel.php | 1 + .../tests/Fixtures/templates/csrf.html.twig | 1 - ...render_form_with_collection_type.html.twig | 4 +- .../Controller/BatchActionControllerTest.php | 20 --- .../AddLiveAttributesSubscriberTest.php | 23 +--- .../LiveComponentSubscriberTest.php | 116 ------------------ .../Functional/Form/ComponentWithFormTest.php | 17 +-- .../DataModelPropsSubscriberTest.php | 14 --- .../Util/LiveAttributesCollectionTest.php | 2 - 39 files changed, 107 insertions(+), 414 deletions(-) delete mode 100644 src/LiveComponent/tests/Fixtures/Component/DisabledCsrf.php delete mode 100644 src/LiveComponent/tests/Fixtures/templates/csrf.html.twig diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 112a1f9ef90..5674d75e0f0 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.22.0 + +- Remove CSRF tokens - rely on same-origin/CORS instead + +## 2.19.0 + - Add `submitForm()` to `TestLiveComponent`. - Add `live_action` Twig function diff --git a/src/LiveComponent/assets/dist/Backend/Backend.d.ts b/src/LiveComponent/assets/dist/Backend/Backend.d.ts index c664be92d5b..bf93480875c 100644 --- a/src/LiveComponent/assets/dist/Backend/Backend.d.ts +++ b/src/LiveComponent/assets/dist/Backend/Backend.d.ts @@ -13,7 +13,6 @@ export interface BackendInterface { }, files: { [key: string]: FileList; }): BackendRequest; - updateCsrfToken(csrfToken: string): void; } export interface BackendAction { name: string; @@ -21,7 +20,7 @@ export interface BackendAction { } export default class implements BackendInterface { private readonly requestBuilder; - constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null); + constructor(url: string, method?: 'get' | 'post'); makeRequest(props: any, actions: BackendAction[], updated: { [key: string]: any; }, children: ChildrenFingerprints, updatedPropsFromParent: { @@ -29,5 +28,4 @@ export default class implements BackendInterface { }, files: { [key: string]: FileList; }): BackendRequest; - updateCsrfToken(csrfToken: string): void; } diff --git a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts index 8a29af3b516..45821c56b5f 100644 --- a/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts +++ b/src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts @@ -2,8 +2,7 @@ import type { BackendAction, ChildrenFingerprints } from './Backend'; export default class { private url; private method; - private csrfToken; - constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null); + constructor(url: string, method?: 'get' | 'post'); buildRequest(props: any, actions: BackendAction[], updated: { [key: string]: any; }, children: ChildrenFingerprints, updatedPropsFromParent: { @@ -15,5 +14,4 @@ export default class { fetchOptions: RequestInit; }; private willDataFitInUrl; - updateCsrfToken(csrfToken: string): void; } diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 9b365643fa0..b25a5d06531 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -25,7 +25,6 @@ export default class LiveControllerDefault extends Controller imple type: ObjectConstructor; default: {}; }; - csrf: StringConstructor; listeners: { type: ArrayConstructor; default: never[]; @@ -59,7 +58,6 @@ export default class LiveControllerDefault extends Controller imple readonly urlValue: string; readonly propsValue: any; propsUpdatedFromParentValue: any; - readonly csrfValue: string; readonly listenersValue: Array<{ event: string; action: string; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 22b6b68f43a..94c9f8dd0ca 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -2052,9 +2052,6 @@ class Component { return response; } this.processRerender(html, backendResponse); - if (this.element.dataset.liveCsrfValue) { - this.backend.updateCsrfToken(this.element.dataset.liveCsrfValue); - } this.backendRequest = null; thisPromiseResolve(backendResponse); if (this.isRequestPending) { @@ -2255,10 +2252,9 @@ class BackendRequest { } class RequestBuilder { - constructor(url, method = 'post', csrfToken = null) { + constructor(url, method = 'post') { this.url = url; this.method = method; - this.csrfToken = csrfToken; } buildRequest(props, actions, updated, children, updatedPropsFromParent, files) { const splitUrl = this.url.split('?'); @@ -2295,9 +2291,6 @@ class RequestBuilder { if (hasFingerprints) { requestData.children = children; } - if (this.csrfToken && (actions.length || totalFiles)) { - fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken; - } if (actions.length > 0) { if (actions.length === 1) { requestData.args = actions[0].args; @@ -2328,22 +2321,16 @@ class RequestBuilder { const urlEncodedJsonData = new URLSearchParams(propsJson + updatedJson + childrenJson + propsFromParentJson).toString(); return (urlEncodedJsonData + params.toString()).length < 1500; } - updateCsrfToken(csrfToken) { - this.csrfToken = csrfToken; - } } class Backend { - constructor(url, method = 'post', csrfToken = null) { - this.requestBuilder = new RequestBuilder(url, method, csrfToken); + constructor(url, method = 'post') { + this.requestBuilder = new RequestBuilder(url, method); } makeRequest(props, actions, updated, children, updatedPropsFromParent, files) { const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files); return new BackendRequest(fetch(url, fetchOptions), actions.map((backendAction) => backendAction.name), Object.keys(updated)); } - updateCsrfToken(csrfToken) { - this.requestBuilder.updateCsrfToken(csrfToken); - } } class StimulusElementDriver { @@ -3203,7 +3190,6 @@ LiveControllerDefault.values = { url: String, props: { type: Object, default: {} }, propsUpdatedFromParent: { type: Object, default: {} }, - csrf: String, listeners: { type: Array, default: [] }, eventsToEmit: { type: Array, default: [] }, eventsToDispatch: { type: Array, default: [] }, @@ -3212,6 +3198,6 @@ LiveControllerDefault.values = { requestMethod: { type: String, default: 'post' }, queryMapping: { type: Object, default: {} }, }; -LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue, controller.csrfValue); +LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue); export { Component, LiveControllerDefault as default, getComponent }; diff --git a/src/LiveComponent/assets/src/Backend/Backend.ts b/src/LiveComponent/assets/src/Backend/Backend.ts index b3d019a4be4..1f456906d45 100644 --- a/src/LiveComponent/assets/src/Backend/Backend.ts +++ b/src/LiveComponent/assets/src/Backend/Backend.ts @@ -15,7 +15,6 @@ export interface BackendInterface { updatedPropsFromParent: { [key: string]: any }, files: { [key: string]: FileList } ): BackendRequest; - updateCsrfToken(csrfToken: string): void; } export interface BackendAction { @@ -26,8 +25,8 @@ export interface BackendAction { export default class implements BackendInterface { private readonly requestBuilder: RequestBuilder; - constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) { - this.requestBuilder = new RequestBuilder(url, method, csrfToken); + constructor(url: string, method: 'get' | 'post' = 'post') { + this.requestBuilder = new RequestBuilder(url, method); } makeRequest( @@ -53,8 +52,4 @@ export default class implements BackendInterface { Object.keys(updated) ); } - - updateCsrfToken(csrfToken: string) { - this.requestBuilder.updateCsrfToken(csrfToken); - } } diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index b9bd2fc13dc..533e34fece9 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -3,12 +3,10 @@ import type { BackendAction, ChildrenFingerprints } from './Backend'; export default class { private url: string; private method: 'get' | 'post'; - private csrfToken: string | null; - constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) { + constructor(url: string, method: 'get' | 'post' = 'post') { this.url = url; this.method = method; - this.csrfToken = csrfToken; } buildRequest( @@ -64,10 +62,6 @@ export default class { requestData.children = children; } - if (this.csrfToken && (actions.length || totalFiles)) { - fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken; - } - if (actions.length > 0) { // one or more ACTIONs @@ -117,8 +111,4 @@ export default class { // if the URL gets remotely close to 2000 chars, it may not fit return (urlEncodedJsonData + params.toString()).length < 1500; } - - updateCsrfToken(csrfToken: string) { - this.csrfToken = csrfToken; - } } diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 18c6a5dea81..28807c57884 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -329,11 +329,6 @@ export default class Component { this.processRerender(html, backendResponse); - // Store updated csrf token - if (this.element.dataset.liveCsrfValue) { - this.backend.updateCsrfToken(this.element.dataset.liveCsrfValue); - } - // finally resolve this promise this.backendRequest = null; thisPromiseResolve(backendResponse); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index aca5b1954b6..3902d56f8ef 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -36,7 +36,6 @@ export default class LiveControllerDefault extends Controller imple url: String, props: { type: Object, default: {} }, propsUpdatedFromParent: { type: Object, default: {} }, - csrf: String, listeners: { type: Array, default: [] }, eventsToEmit: { type: Array, default: [] }, eventsToDispatch: { type: Array, default: [] }, @@ -50,7 +49,6 @@ export default class LiveControllerDefault extends Controller imple declare readonly urlValue: string; declare readonly propsValue: any; declare propsUpdatedFromParentValue: any; - declare readonly csrfValue: string; declare readonly listenersValue: Array<{ event: string; action: string }>; declare readonly eventsToEmitValue: Array<{ event: string; @@ -79,7 +77,7 @@ export default class LiveControllerDefault extends Controller imple private pendingFiles: { [key: string]: HTMLInputElement } = {}; static backendFactory: (controller: LiveControllerDefault) => BackendInterface = (controller) => - new Backend(controller.urlValue, controller.requestMethodValue, controller.csrfValue); + new Backend(controller.urlValue, controller.requestMethodValue); initialize() { this.mutationObserver = new MutationObserver(this.onMutations.bind(this)); diff --git a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts index 22f0e0498c4..44521271e80 100644 --- a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts +++ b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts @@ -2,7 +2,7 @@ import RequestBuilder from '../../src/Backend/RequestBuilder'; describe('buildRequest', () => { it('sets basic data on GET request', () => { - const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token'); + const builder = new RequestBuilder('/_components?existing_param=1', 'get'); const { url, fetchOptions } = builder.buildRequest( { firstName: 'Ryan' }, [], @@ -23,7 +23,7 @@ describe('buildRequest', () => { }); it('sets basic data on POST request', () => { - const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token'); + const builder = new RequestBuilder('/_components', 'post'); const { url, fetchOptions } = builder.buildRequest( { firstName: 'Ryan' }, [ @@ -42,7 +42,6 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', - 'X-CSRF-TOKEN': '_the_csrf_token', 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -58,7 +57,7 @@ describe('buildRequest', () => { }); it('sets basic data on POST request with batch actions', () => { - const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token'); + const builder = new RequestBuilder('/_components', 'post'); const { url, fetchOptions } = builder.buildRequest( { firstName: 'Ryan' }, [ @@ -101,7 +100,7 @@ describe('buildRequest', () => { // when data is too long it makes a post request it('makes a POST request when data is too long', () => { - const builder = new RequestBuilder('/_components', 'get', '_the_csrf_token'); + const builder = new RequestBuilder('/_components', 'get'); const { url, fetchOptions } = builder.buildRequest( { firstName: 'Ryan'.repeat(1000) }, [], @@ -129,7 +128,7 @@ describe('buildRequest', () => { }); it('makes a POST request when method is post', () => { - const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token'); + const builder = new RequestBuilder('/_components', 'post'); const { url, fetchOptions } = builder.buildRequest( { firstName: 'Ryan', @@ -161,7 +160,7 @@ describe('buildRequest', () => { }); it('sends propsFromParent when specified', () => { - const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token'); + const builder = new RequestBuilder('/_components?existing_param=1', 'get'); const { url } = builder.buildRequest({ firstName: 'Ryan' }, [], { firstName: 'Kevin' }, {}, { count: 5 }, {}); expect(url).toEqual( @@ -216,7 +215,7 @@ describe('buildRequest', () => { }; it('Sends file with request', () => { - const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token'); + const builder = new RequestBuilder('/_components', 'post'); const { url, fetchOptions } = builder.buildRequest( { firstName: 'Ryan' }, @@ -231,7 +230,6 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', - 'X-CSRF-TOKEN': '_the_csrf_token', 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -241,7 +239,7 @@ describe('buildRequest', () => { }); it('Sends multiple files with request', () => { - const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token'); + const builder = new RequestBuilder('/_components', 'post'); const { url, fetchOptions } = builder.buildRequest( { firstName: 'Ryan' }, @@ -256,7 +254,6 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', - 'X-CSRF-TOKEN': '_the_csrf_token', 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index 0e328a61517..efa875c0ba9 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -630,23 +630,4 @@ describe('LiveController rendering Tests', () => { // verify the selectedIndex of the select option 2 is 0 expect(selectOption2.selectedIndex).toBe(0); }); - - it('backend will have a new csrf token', async () => { - const test = await createTest( - {}, - (data: any) => ` -
-
- ` - ); - - test.expectsAjaxCall().serverWillChangeProps((data: any) => { - // change csrf token - data.csrf = 'Hello'; - }); - - await test.component.render(); - - expect(test.mockedBackend.csrfToken).toEqual('Hello'); - }); }); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 958fdcc8b53..791d2fe299b 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -98,8 +98,6 @@ class FunctionalTest { class MockedBackend implements BackendInterface { private expectedMockedAjaxCalls: Array = []; - public csrfToken: string | null = null; - addMockedAjaxCall(mock: MockedAjaxCall) { this.expectedMockedAjaxCalls.push(mock); } @@ -141,10 +139,6 @@ class MockedBackend implements BackendInterface { return matchedMock.createBackendRequest(); } - updateCsrfToken(csrfToken: string) { - this.csrfToken = csrfToken; - } - getExpectedMockedAjaxCalls(): Array { return this.expectedMockedAjaxCalls; } @@ -469,7 +463,6 @@ export function initComponent(props: any = {}, controllerValues: any = {}) { data-live-url-value="http://localhost/components/_test_component_${Math.round(Math.random() * 1000)}" data-live-props-value="${dataToJsonAttribute(props)}" ${controllerValues.debounce ? `data-live-debounce-value="${controllerValues.debounce}"` : ''} - ${controllerValues.csrf ? `data-live-csrf-value="${controllerValues.csrf}"` : ''} ${controllerValues.id ? `id="${controllerValues.id}"` : ''} ${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''} ${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''} diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index 4647b49437a..7359a8b6eef 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -27,6 +27,7 @@ }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/property-access": "^5.4.5|^6.0|^7.0", "symfony/stimulus-bundle": "^2.9", "symfony/ux-twig-component": "^2.8", diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 02bf4465807..641430965f3 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -1200,28 +1200,14 @@ Actions and CSRF Protection ~~~~~~~~~~~~~~~~~~~~~~~~~~~ When you trigger an action, a POST request is sent that contains a -``X-CSRF-TOKEN`` header. This header is automatically populated and -validated. In other words… you get CSRF protection without any work. +custom ``Accept`` header. This header is automatically populated and +validated. In other words… you get CSRF protection without any work +thanks to same-origin / CORS policies implemented by browsers. -Your only job is to make sure that the CSRF component is installed: +If you want this built-in CSRF protection to be effective, mind your +CORS headers (e.g. *DO NOT* use `Access-Control-Allow-Origin: *`). -.. code-block:: terminal - - $ composer require symfony/security-csrf - -If you want to disable CSRF for a single component you can set -``csrf`` option to ``false``:: - - namespace App\Twig\Components; - - use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; - use Symfony\UX\LiveComponent\Attribute\LiveProp; - - #[AsLiveComponent(csrf: false)] - class MyLiveComponent - { - // ... - } +(In test-mode, the CSRF protection is disabled to make testing easier.) Actions, Redirecting and AbstractController ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/LiveComponent/src/Attribute/AsLiveComponent.php b/src/LiveComponent/src/Attribute/AsLiveComponent.php index 5f03e0b9b12..497eed31f8f 100644 --- a/src/LiveComponent/src/Attribute/AsLiveComponent.php +++ b/src/LiveComponent/src/Attribute/AsLiveComponent.php @@ -24,33 +24,62 @@ #[\Attribute(\Attribute::TARGET_CLASS)] final class AsLiveComponent extends AsTwigComponent { + public string $route; + public string $method; + public int $urlReferenceType; + + private ?string $defaultAction; + /** - * @param string|null $name The component name (ie: TodoList) - * @param string|null $template The template path of the component (ie: components/TodoList.html.twig). - * @param string|null $defaultAction The default action to call when the component is mounted (ie: __invoke) - * @param bool $exposePublicProps Whether to expose every public property as a Twig variable - * @param string $attributesVar The name of the special "attributes" variable in the template - * @param bool $csrf Whether to enable CSRF protection (default: true) - * @param string $route The route used to render the component & handle actions (default: ux_live_component) - * @param int $urlReferenceType Which type of URL should be generated for the given route. Use the constants from UrlGeneratorInterface (default: absolute path, e.g. "/dir/file"). + * @param string|null $name The component name (ie: TodoList) + * @param string|null $template The template path of the component (ie: components/TodoList.html.twig). + * @param string|null $defaultAction The default action to call when the component is mounted (ie: __invoke) + * @param bool $exposePublicProps Whether to expose every public property as a Twig variable + * @param string $attributesVar The name of the special "attributes" variable in the template + * @param string $route The route used to render the component & handle actions + * @param string $method The HTTP method to use + * @param UrlGeneratorInterface::* $urlReferenceType Which type of URL should be generated for the given route */ public function __construct( ?string $name = null, ?string $template = null, - private ?string $defaultAction = null, + ?string $defaultAction = null, bool $exposePublicProps = true, string $attributesVar = 'attributes', - public bool $csrf = true, - public string $route = 'ux_live_component', - public string $method = 'post', - public int $urlReferenceType = UrlGeneratorInterface::ABSOLUTE_PATH, + string|bool $route = 'ux_live_component', + string $method = 'post', + int|string $urlReferenceType = UrlGeneratorInterface::ABSOLUTE_PATH, + public bool|int $csrf = true, // @deprecated ) { + if (8 < \func_num_args() || \is_bool($route)) { + trigger_deprecation('symfony/ux-live-component', '2.21', 'Argument "$csrf" of "#[%s]" has no effect anymore and is deprecated.', static::class); + } + if (\is_bool($route)) { + $this->csrf = $route; + $route = $method; + $method = $urlReferenceType; + $urlReferenceType = $csrf; + + switch (\func_num_args()) { + case 6: $route = 'ux_live_component'; + // no break + case 7: $method = 'post'; + // no break + case 8: $urlReferenceType = UrlGeneratorInterface::ABSOLUTE_PATH; + // no break + default: + } + } + parent::__construct($name, $template, $exposePublicProps, $attributesVar); - $this->method = strtolower($this->method); + $this->defaultAction = $defaultAction; + $this->route = $route; + $this->method = strtolower($method); + $this->urlReferenceType = $urlReferenceType; - if (!\in_array($this->method, ['get', 'post'])) { - throw new \UnexpectedValueException('$method must be either \'get\' or \'post\''); + if (!\in_array($method, ['get', 'post'])) { + throw new \UnexpectedValueException('$method must be either \'get\' or \'post\'.'); } } diff --git a/src/LiveComponent/src/DependencyInjection/Compiler/OptionalDependencyPass.php b/src/LiveComponent/src/DependencyInjection/Compiler/OptionalDependencyPass.php index 95180356285..14bcc18cce5 100644 --- a/src/LiveComponent/src/DependencyInjection/Compiler/OptionalDependencyPass.php +++ b/src/LiveComponent/src/DependencyInjection/Compiler/OptionalDependencyPass.php @@ -33,5 +33,10 @@ public function process(ContainerBuilder $container): void ->addTag(LiveComponentBundle::HYDRATION_EXTENSION_TAG) ; } + + if (!$container->hasDefinition('test.client')) { + $container->getDefinition('ux.live_component.event_subscriber') + ->setArgument(1, false); + } } } diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index c45b840915e..28979e134a7 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -127,7 +127,6 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer']) ->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator']) ->addTag('container.service_subscriber', ['key' => LiveComponentMetadataFactory::class, 'id' => 'ux.live_component.metadata_factory']) - ->addTag('container.service_subscriber') // csrf ; $container->register('ux.live_component.live_responder', LiveResponder::class); @@ -204,7 +203,6 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { new Reference('ux.live_component.fingerprint_calculator'), new Reference('router'), new Reference('ux.live_component.live_responder'), - new Reference('security.csrf.token_manager', ContainerInterface::NULL_ON_INVALID_REFERENCE), new Reference('ux.live_component.twig.template_mapper'), ]) ; diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 5d2480f4fd9..58b5df6d111 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -21,18 +21,14 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\Event\ViewEvent; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; -use Symfony\Component\Security\Csrf\CsrfToken; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveArg; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; -use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentMetadata; use Symfony\UX\TwigComponent\ComponentRenderer; @@ -49,8 +45,10 @@ class LiveComponentSubscriber implements EventSubscriberInterface, ServiceSubscr private const HTML_CONTENT_TYPE = 'application/vnd.live-component+html'; private const REDIRECT_HEADER = 'X-Live-Redirect'; - public function __construct(private ContainerInterface $container) - { + public function __construct( + private ContainerInterface $container, + private bool $testMode = true, + ) { } public static function getSubscribedServices(): array @@ -60,7 +58,6 @@ public static function getSubscribedServices(): array ComponentFactory::class, LiveComponentHydrator::class, LiveComponentMetadataFactory::class, - '?'.CsrfTokenManagerInterface::class, ]; } @@ -107,13 +104,6 @@ public function onKernelRequest(RequestEvent $event): void throw new MethodNotAllowedHttpException(['POST']); } - if ( - $this->container->has(CsrfTokenManagerInterface::class) - && $metadata->get('csrf') - && !$this->container->get(CsrfTokenManagerInterface::class)->isTokenValid(new CsrfToken(LiveControllerAttributesCreator::getCsrfTokeName($componentName), $request->headers->get('X-CSRF-TOKEN')))) { - throw new BadRequestHttpException('Invalid CSRF token.'); - } - if ('_batch' === $action) { // use batch controller $data = $this->parseDataFor($request); @@ -299,11 +289,12 @@ public function onKernelResponse(ResponseEvent $event): void return; } - if (!\in_array(self::HTML_CONTENT_TYPE, $request->getAcceptableContentTypes(), true)) { + if (!$response->isRedirection()) { return; } - if (!$response->isRedirection()) { + if ($this->testMode && !\in_array(self::HTML_CONTENT_TYPE, $request->getAcceptableContentTypes(), true)) { + // Make testing redirections easier return; } @@ -344,7 +335,17 @@ private function createResponse(MountedComponent $mounted): Response private function isLiveComponentRequest(Request $request): bool { - return $request->attributes->has('_live_component'); + if (!$request->attributes->has('_live_component')) { + return false; + } + + if ($this->testMode) { + return true; + } + + // Except when testing, require the correct content-type in the Accept header. + // This also acts as a CSRF protection since this can only be set in accordance with same-origin/CORS policies. + return \in_array(self::HTML_CONTENT_TYPE, $request->getAcceptableContentTypes(), true); } private function hydrateComponent(object $component, string $componentName, Request $request): MountedComponent diff --git a/src/LiveComponent/src/Test/TestLiveComponent.php b/src/LiveComponent/src/Test/TestLiveComponent.php index 98def7aa7de..0e587a38256 100644 --- a/src/LiveComponent/src/Test/TestLiveComponent.php +++ b/src/LiveComponent/src/Test/TestLiveComponent.php @@ -147,8 +147,6 @@ public function setRouteLocale(string $locale): self private function request(array $content = [], ?string $action = null, array $files = []): self { - $csrfToken = $this->csrfToken(); - $this->client()->request( 'POST', $this->router->generate( @@ -161,7 +159,6 @@ private function request(array $content = [], ?string $action = null, array $fil ), parameters: ['data' => json_encode(array_merge($content, ['props' => $this->props()]))], files: $files, - server: $csrfToken ? ['HTTP_X_CSRF_TOKEN' => $csrfToken] : [], ); return $this; @@ -178,17 +175,6 @@ private function props(): array return json_decode($node->attr('data-live-props-value'), true, flags: \JSON_THROW_ON_ERROR); } - private function csrfToken(): ?string - { - $crawler = $this->client()->getCrawler(); - - if (!\count($node = $crawler->filter('[data-live-csrf-value]'))) { - return null; - } - - return $node->attr('data-live-csrf-value'); - } - private function client(): KernelBrowser { if ($this->performedInitialRequest) { diff --git a/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php b/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php index 7920747be50..79e9c6eb0a9 100644 --- a/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php +++ b/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php @@ -72,7 +72,6 @@ public function renderChildComponent(string $deterministicId, string $currentPro // optional, but these just aren't needed by the frontend at this point unset($attributes['data-controller']); unset($attributes['data-live-url-value']); - unset($attributes['data-live-csrf-value']); unset($attributes['data-live-props-value']); return $this->createHtml($attributes, $childTag); diff --git a/src/LiveComponent/src/Util/LiveAttributesCollection.php b/src/LiveComponent/src/Util/LiveAttributesCollection.php index 64d6e7342a6..71449419e62 100644 --- a/src/LiveComponent/src/Util/LiveAttributesCollection.php +++ b/src/LiveComponent/src/Util/LiveAttributesCollection.php @@ -83,11 +83,6 @@ public function setUrl(string $url): void $this->attributes['data-live-url-value'] = $url; } - public function setCsrf(string $csrf): void - { - $this->attributes['data-live-csrf-value'] = $csrf; - } - public function setListeners(array $listeners): void { $this->attributes['data-live-listeners-value'] = $listeners; diff --git a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php index cb1d8167788..64d7866eb4f 100644 --- a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php +++ b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php @@ -12,7 +12,6 @@ namespace Symfony\UX\LiveComponent\Util; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\LiveComponent\LiveResponder; @@ -45,7 +44,6 @@ public function __construct( private FingerprintCalculator $fingerprintCalculator, private UrlGeneratorInterface $urlGenerator, private LiveResponder $liveResponder, - private ?CsrfTokenManagerInterface $csrfTokenManager, private TemplateMap $templateMap, ) { } @@ -131,20 +129,9 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad ); $attributesCollection->setProps($dehydratedProps->getProps()); - if ($this->csrfTokenManager && $metadata->get('csrf')) { - $attributesCollection->setCsrf( - $this->csrfTokenManager->getToken(self::getCsrfTokeName($mounted->getName()))->getValue(), - ); - } - return $attributesCollection; } - public static function getCsrfTokeName(string $componentName): string - { - return 'live_component_'.$componentName; - } - private function dehydrateComponent(string $name, object $component, ComponentAttributes $attributes): DehydratedProps { $liveMetadata = $this->metadataFactory->getMetadata($name); diff --git a/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php index 0c765ce2cf5..2b8325c81c4 100644 --- a/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php +++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php @@ -18,7 +18,7 @@ use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; -#[AsLiveComponent('component_with_emit', csrf: false)] +#[AsLiveComponent('component_with_emit')] final class ComponentWithEmit { use DefaultActionTrait; diff --git a/src/LiveComponent/tests/Fixtures/Component/DisabledCsrf.php b/src/LiveComponent/tests/Fixtures/Component/DisabledCsrf.php deleted file mode 100644 index d3c7098fc77..00000000000 --- a/src/LiveComponent/tests/Fixtures/Component/DisabledCsrf.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ -#[AsLiveComponent('disabled_csrf', defaultAction: 'defaultAction()', csrf: false)] -final class DisabledCsrf -{ - #[LiveProp] - public int $count = 1; - - #[LiveAction] - public function increase(): void - { - ++$this->count; - } - - public function defaultAction(): void - { - } -} diff --git a/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php b/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php index 7ca5576ca30..1686b9a9987 100644 --- a/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php +++ b/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php @@ -25,8 +25,6 @@ #[AsLiveComponent('form_with_collection_type')] class FormWithCollectionTypeComponent extends AbstractController { - public bool $enableCsrf = false; - use ComponentWithFormTrait; use DefaultActionTrait; @@ -41,9 +39,7 @@ public function __construct() protected function instantiateForm(): FormInterface { - return $this->createForm(BlogPostFormType::class, $this->post, [ - 'csrf_protection' => $this->enableCsrf, - ]); + return $this->createForm(BlogPostFormType::class, $this->post); } #[LiveAction] diff --git a/src/LiveComponent/tests/Fixtures/Component/ValidatingComponent.php b/src/LiveComponent/tests/Fixtures/Component/ValidatingComponent.php index bbf90549a48..899c2d152fb 100644 --- a/src/LiveComponent/tests/Fixtures/Component/ValidatingComponent.php +++ b/src/LiveComponent/tests/Fixtures/Component/ValidatingComponent.php @@ -10,7 +10,7 @@ use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\ValidatableComponentTrait; -#[AsLiveComponent('validating_component', csrf: false)] +#[AsLiveComponent('validating_component')] final class ValidatingComponent { use DefaultActionTrait; diff --git a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormLiveCollectionType.php b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormLiveCollectionType.php index f0404323270..e7898ee6f3a 100644 --- a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormLiveCollectionType.php +++ b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormLiveCollectionType.php @@ -37,7 +37,6 @@ public function buildForm(FormBuilderInterface $builder, array $options) public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ - 'csrf_protection' => false, 'data_class' => BlogPost::class, ]); } diff --git a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php index b99f789b2ca..584bcdbc823 100644 --- a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php +++ b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php @@ -38,7 +38,6 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => BlogPost::class, - 'csrf_protection' => false, ]); } } diff --git a/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php b/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php index bca009e18e5..e7b5e5e5dfb 100644 --- a/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php +++ b/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php @@ -29,7 +29,6 @@ public function buildForm(FormBuilderInterface $builder, array $options) public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ - 'csrf_protection' => false, 'data_class' => Comment::class, ]); } diff --git a/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php b/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php index 7362cab2bc9..ea69b1386e7 100644 --- a/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php +++ b/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php @@ -71,11 +71,4 @@ public function buildForm(FormBuilderInterface $builder, array $options) ->add('complexType', ComplexFieldType::class) ; } - - public function configureOptions(OptionsResolver $resolver) - { - $resolver->setDefaults([ - 'csrf_protection' => false, - ]); - } } diff --git a/src/LiveComponent/tests/Fixtures/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php index 4e6b412f5a1..508dd24274b 100644 --- a/src/LiveComponent/tests/Fixtures/Kernel.php +++ b/src/LiveComponent/tests/Fixtures/Kernel.php @@ -91,6 +91,7 @@ public function process(ContainerBuilder $container): void protected function configureContainer(ContainerConfigurator $c): void { $frameworkConfig = [ + 'csrf_protection' => ['enabled' => false], 'secret' => 'S3CRET', 'test' => true, 'router' => ['utf8' => true], diff --git a/src/LiveComponent/tests/Fixtures/templates/csrf.html.twig b/src/LiveComponent/tests/Fixtures/templates/csrf.html.twig deleted file mode 100644 index 314377bccbc..00000000000 --- a/src/LiveComponent/tests/Fixtures/templates/csrf.html.twig +++ /dev/null @@ -1 +0,0 @@ -{{ component('disabled_csrf') }} diff --git a/src/LiveComponent/tests/Fixtures/templates/render_form_with_collection_type.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_form_with_collection_type.html.twig index 5ce0cc1aec1..9c8eb4fc223 100644 --- a/src/LiveComponent/tests/Fixtures/templates/render_form_with_collection_type.html.twig +++ b/src/LiveComponent/tests/Fixtures/templates/render_form_with_collection_type.html.twig @@ -1,3 +1 @@ -{{ component('form_with_collection_type', { - enableCsrf: true, -}) }} +{{ component('form_with_collection_type') }} diff --git a/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php b/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php index 0de3b8688c1..6151cff63d3 100644 --- a/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php +++ b/src/LiveComponent/tests/Functional/Controller/BatchActionControllerTest.php @@ -52,7 +52,6 @@ public function testCanBatchActions(): void 'args' => ['what' => 'first'], ]), ], - 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); }) ->assertSee('initial') @@ -72,7 +71,6 @@ public function testCanBatchActions(): void ], ]), ], - 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); }) ->assertSee('initial') @@ -113,7 +111,6 @@ public function testCanBatchActionsWithAlternateRoute(): void ], ]), ], - 'headers' => ['X-CSRF-TOKEN' => $rootElement->attr('data-live-csrf-value')], ]); }) ->assertOn('/alt/alternate_route/_batch') @@ -122,19 +119,6 @@ public function testCanBatchActionsWithAlternateRoute(): void ; } - public function testCsrfTokenIsChecked(): void - { - $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); - - $this->browser() - ->post('/_components/with_actions/_batch', ['json' => [ - 'props' => $dehydrated->getProps(), - 'actions' => [], - ]]) - ->assertStatus(400) - ; - } - public function testRedirect(): void { $dehydrated = $this->dehydrateComponent($this->mountComponent('with_actions')); @@ -165,7 +149,6 @@ public function testRedirect(): void ], ]), ], - 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); }) ->assertRedirectedTo('/') @@ -203,7 +186,6 @@ public function testRedirectWithAcceptHeader(): void ], 'headers' => [ 'Accept' => ['application/vnd.live-component+html'], - 'X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value'), ], ]); }) @@ -241,7 +223,6 @@ public function testException(): void ], ]), ], - 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); }) ; @@ -276,7 +257,6 @@ public function testCannotBatchWithNonLiveAction(): void ], ]), ], - 'headers' => ['X-CSRF-TOKEN' => $crawler->filter('ul')->first()->attr('data-live-csrf-value')], ]); }) ; diff --git a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php index 564a83c6773..74d5f975c90 100644 --- a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php @@ -44,7 +44,6 @@ public function testInitLiveComponent(): void $this->assertSame('live', $div->attr('data-controller')); $this->assertSame('/_components/component_with_writable_props', $div->attr('data-live-url-value')); - $this->assertNotNull($div->attr('data-live-csrf-value')); $this->assertCount(4, $props); $this->assertSame(5, $props['max']); $this->assertSame(1, $props['count']); @@ -66,24 +65,9 @@ public function testCanUseCustomAttributesVariableName(): void $this->assertSame('live', $div->attr('data-controller')); $this->assertSame('/_components/custom_attributes', $div->attr('data-live-url-value')); - $this->assertNotNull($div->attr('data-live-csrf-value')); $this->assertArrayHasKey('@checksum', $props); } - public function testCanDisableCsrf(): void - { - $div = $this->browser() - ->visit('/render-template/csrf') - ->assertSuccessful() - ->crawler() - ->filter('div') - ; - - $this->assertSame('live', $div->attr('data-controller')); - $this->assertSame('/_components/disabled_csrf', $div->attr('data-live-url-value')); - $this->assertNull($div->attr('data-live-csrf-value')); - } - public function testItAddsIdAndFingerprintToChildComponent(): void { $templateName = 'components/todo_list.html.twig'; @@ -174,7 +158,6 @@ public function testAbsoluteUrl(): void $this->assertSame('live', $div->attr('data-controller')); $this->assertSame('http://localhost/_components/with_absolute_url', $div->attr('data-live-url-value')); - $this->assertNotNull($div->attr('data-live-csrf-value')); $this->assertCount(3, $props); $this->assertArrayHasKey('@checksum', $props); $this->assertArrayHasKey('@attributes', $props); @@ -185,19 +168,16 @@ public function testAbsoluteUrl(): void public function testAbsoluteUrlWithLiveQueryProp() { - $token = null; $props = []; $div = $this->browser() ->get('/render-template/render_with_absolute_url?count=1') ->assertSuccessful() ->assertContains('Count: 1') - ->use(function (Crawler $crawler) use (&$token, &$props) { + ->use(function (Crawler $crawler) use (&$props) { $div = $crawler->filter('div')->first(); - $token = $div->attr('data-live-csrf-value'); $props = json_decode($div->attr('data-live-props-value'), true); }) ->post('http://localhost/_components/with_absolute_url/increase', [ - 'headers' => ['X-CSRF-TOKEN' => $token], 'body' => ['data' => json_encode(['props' => $props])], ]) ->assertContains('Count: 2') @@ -209,7 +189,6 @@ public function testAbsoluteUrlWithLiveQueryProp() $this->assertSame('live', $div->attr('data-controller')); $this->assertSame('http://localhost/_components/with_absolute_url', $div->attr('data-live-url-value')); - $this->assertNotNull($div->attr('data-live-csrf-value')); $this->assertCount(3, $props); $this->assertArrayHasKey('@checksum', $props); $this->assertArrayHasKey('@attributes', $props); diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index e4a877341c4..9ac59da9123 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -108,7 +108,6 @@ public function testCanExecuteComponentActionNormalRoute(): void ] ) ); - $token = null; $this->browser() ->throwExceptions() @@ -122,12 +121,7 @@ public function testCanExecuteComponentActionNormalRoute(): void ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Count: 1') - ->use(function (Crawler $crawler) use (&$token) { - // get a valid token to use for actions - $token = $crawler->filter('div')->first()->attr('data-live-csrf-value'); - }) ->post('/_components/component2/increase', [ - 'headers' => ['X-CSRF-TOKEN' => $token], 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() @@ -140,7 +134,6 @@ public function testCanExecuteComponentActionNormalRoute(): void public function testCanExecuteComponentActionWithAlternateRoute(): void { $dehydrated = $this->dehydrateComponent($this->mountComponent('alternate_route')); - $token = null; $this->browser() ->throwExceptions() @@ -153,12 +146,7 @@ public function testCanExecuteComponentActionWithAlternateRoute(): void ]) ->assertSuccessful() ->assertContains('count: 0') - ->use(function (Crawler $crawler) use (&$token) { - // get a valid token to use for actions - $token = $crawler->filter('div')->first()->attr('data-live-csrf-value'); - }) ->post('/alt/alternate_route/increase', [ - 'headers' => ['X-CSRF-TOKEN' => $token], 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() @@ -183,77 +171,6 @@ public function testCannotExecuteComponentDefaultActionForGetRequestWhenMethodIs ; } - public function testMissingCsrfTokenForComponentActionFails(): void - { - $this->browser() - ->post('/_components/component2/increase') - ->assertStatus(400) - ; - - try { - $this->browser() - ->throwExceptions() - ->post('/_components/component2/increase') - ; - } catch (BadRequestHttpException $e) { - $this->assertSame('Invalid CSRF token.', $e->getMessage()); - - return; - } - - $this->fail('Expected exception not thrown.'); - } - - public function testInvalidCsrfTokenForComponentActionFails(): void - { - $this->browser() - ->post('/_components/component2/increase', [ - 'headers' => ['X-CSRF-TOKEN' => 'invalid'], - ]) - ->assertStatus(400) - ; - - try { - $this->browser() - ->throwExceptions() - ->post('/_components/component2/increase', [ - 'headers' => ['X-CSRF-TOKEN' => 'invalid'], - ]) - ; - } catch (BadRequestHttpException $e) { - $this->assertSame('Invalid CSRF token.', $e->getMessage()); - - return; - } - - $this->fail('Expected exception not thrown.'); - } - - public function testDisabledCsrfTokenForComponentDoesNotFail(): void - { - $dehydrated = $this->dehydrateComponent($this->mountComponent('disabled_csrf')); - - $this->browser() - ->throwExceptions() - ->post('/_components/disabled_csrf', [ - 'body' => [ - 'data' => json_encode([ - 'props' => $dehydrated->getProps(), - ]), - ], - ]) - ->assertSuccessful() - ->assertHeaderContains('Content-Type', 'html') - ->assertContains('Count: 1') - ->post('/_components/disabled_csrf/increase', [ - 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], - ]) - ->assertSuccessful() - ->assertHeaderContains('Content-Type', 'html') - ->assertContains('Count: 2') - ; - } - public function testPreReRenderHookOnlyExecutedDuringAjax(): void { $dehydrated = $this->dehydrateComponent($this->mountComponent('component2')); @@ -341,20 +258,13 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponents(): voi ) ); - $token = null; - $this->browser() ->visit('/render-template/render_multiple_embedded_with_blocks') ->assertSuccessful() ->assertSeeIn('#component1', 'Overridden content from component 1') ->assertSeeIn('#component2', 'Overridden content from component 2 on same line - count: 1') ->assertSeeIn('#component3', 'PreReRenderCalled: No') - ->use(function (Crawler $crawler) use (&$token) { - // get a valid token to use for actions - $token = $crawler->filter('div')->eq(1)->attr('data-live-csrf-value'); - }) ->post('/_components/component2/increase', [ - 'headers' => ['X-CSRF-TOKEN' => $token], 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() @@ -380,20 +290,13 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponentsWithNam ) ); - $token = null; - $this->browser() ->visit('/render-namespaced-template/render_multiple_embedded_with_blocks') ->assertSuccessful() ->assertSeeIn('#component1', 'Overridden content from component 1') ->assertSeeIn('#component2', 'Overridden content from component 2 on same line - count: 1') ->assertSeeIn('#component3', 'PreReRenderCalled: No') - ->use(function (Crawler $crawler) use (&$token) { - // get a valid token to use for actions - $token = $crawler->filter('div')->eq(1)->attr('data-live-csrf-value'); - }) ->post('/_components/component2/increase', [ - 'headers' => ['X-CSRF-TOKEN' => $token], 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertSuccessful() @@ -405,7 +308,6 @@ public function testItUseBlocksFromEmbeddedContextUsingMultipleComponentsWithNam public function testCanRedirectFromComponentAction(): void { $dehydrated = $this->dehydrateComponent($this->mountComponent('component2')); - $token = null; $this->browser() ->throwExceptions() @@ -417,14 +319,9 @@ public function testCanRedirectFromComponentAction(): void ], ]) ->assertSuccessful() - ->use(function (Crawler $crawler) use (&$token) { - // get a valid token to use for actions - $token = $crawler->filter('div')->first()->attr('data-live-csrf-value'); - }) ->interceptRedirects() // with no custom header, it redirects like a normal browser ->post('/_components/component2/redirect', [ - 'headers' => ['X-CSRF-TOKEN' => $token], 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) ->assertRedirectedTo('/') @@ -433,7 +330,6 @@ public function testCanRedirectFromComponentAction(): void ->post('/_components/component2/redirect', [ 'headers' => [ 'Accept' => 'application/vnd.live-component+html', - 'X-CSRF-TOKEN' => $token, ], 'body' => ['data' => json_encode(['props' => $dehydrated->getProps()])], ]) @@ -447,7 +343,6 @@ public function testCanRedirectFromComponentAction(): void public function testInjectsLiveArgs(): void { $dehydrated = $this->dehydrateComponent($this->mountComponent('component6')); - $token = null; $arguments = ['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']; $this->browser() @@ -464,12 +359,7 @@ public function testInjectsLiveArgs(): void ->assertContains('Arg1: not provided') ->assertContains('Arg2: not provided') ->assertContains('Arg3: not provided') - ->use(function (Crawler $crawler) use (&$token) { - // get a valid token to use for actions - $token = $crawler->filter('div')->first()->attr('data-live-csrf-value'); - }) ->post('/_components/component6/inject', [ - 'headers' => ['X-CSRF-TOKEN' => $token], 'body' => [ 'data' => json_encode([ 'props' => $dehydrated->getProps(), @@ -524,7 +414,6 @@ public function testCanHaveControllerAttributes(): void public function testCanInjectSecurityUserIntoAction(): void { $dehydrated = $this->dehydrateComponent($this->mountComponent('with_security')); - $token = null; $this->browser() ->actingAs(new InMemoryUser('kevin', 'pass', ['ROLE_USER'])) @@ -537,13 +426,8 @@ public function testCanInjectSecurityUserIntoAction(): void ]) ->assertSuccessful() ->assertNotSee('username: kevin') - ->use(function (Crawler $crawler) use (&$token) { - // get a valid token to use for actions - $token = $crawler->filter('div')->first()->attr('data-live-csrf-value'); - }) ->throwExceptions() ->post('/_components/with_security/setUsername', [ - 'headers' => ['X-CSRF-TOKEN' => $token], 'body' => [ 'data' => json_encode([ 'props' => $dehydrated->getProps(), diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php index 59f886fdfb6..ca0bb583a4b 100644 --- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -46,13 +46,11 @@ public function testFormValuesRebuildAfterFormChanges(): void 'blog_post_form.content' => 'changed description by user', 'validatedFields' => ['blog_post_form.content'], ]; - $token = $crawler->filter('div')->first()->attr('data-live-csrf-value'); $crawler = $browser // post to action, which will add a new embedded comment ->post('/_components/form_with_collection_type/addComment', [ 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps])], - 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) // look for original embedded form @@ -82,13 +80,11 @@ public function testFormValuesRebuildAfterFormChanges(): void // fake that this field was being validated $updatedProps = ['validatedFields' => $dehydratedProps['validatedFields']]; $updatedProps['validatedFields'][] = 'blog_post_form.comments.0.content'; - $token = $div->attr('data-live-csrf-value'); $crawler = $browser // post to action, which will remove the original embedded comment ->post('/_components/form_with_collection_type/removeComment', [ 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['index' => '0']])], - 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) // the original embedded form should be gone @@ -110,7 +106,6 @@ public function testFormValuesRebuildAfterFormChanges(): void // empty the collection ->post('/_components/form_with_collection_type/removeComment', [ 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'args' => ['index' => '1']])], - 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) ->assertNotContains('') @@ -346,7 +339,6 @@ public function testResetForm(): void $browser ->post('/_components/form_with_many_different_fields_type/resetFormWithoutSubmitting', [ 'body' => ['data' => json_encode(['props' => $dehydratedProps])], - 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(200) ->assertNotContains('textarea is too long') @@ -358,7 +350,6 @@ public function testLiveCollectionTypeFieldsAddedAndRemoved(): void { $dehydratedProps = $this->dehydrateComponent($this->mountComponent('form_with_live_collection_type'))->getProps(); $updatedProps = []; - $token = null; $this->browser() ->post('/_components/form_with_live_collection_type', [ @@ -368,18 +359,16 @@ public function testLiveCollectionTypeFieldsAddedAndRemoved(): void ]), ], ]) - ->use(function (Crawler $crawler) use (&$dehydratedProps, &$token, &$updatedProps) { + ->use(function (Crawler $crawler) use (&$updatedProps) { // mimic user typing $updatedProps = [ 'blog_post_form.content' => 'changed description by user', 'validatedFields' => ['blog_post_form.content'], ]; - $token = $crawler->filter('div')->first()->attr('data-live-csrf-value'); }) // post to action, which will add a new embedded comment ->post('/_components/form_with_live_collection_type/addCollectionItem', [ 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['name' => 'blog_post_form[comments]']])], - 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) // look for original embedded form @@ -392,7 +381,7 @@ public function testLiveCollectionTypeFieldsAddedAndRemoved(): void ->assertContains('The content field is too short') // make sure the title field did not suddenly become validated ->assertNotContains('The title field should not be blank') - ->use(function (Crawler $crawler) use (&$dehydratedProps, &$token, $updatedProps) { + ->use(function (Crawler $crawler) use (&$dehydratedProps) { $div = $crawler->filter('[data-controller="live"]'); $dehydratedProps = json_decode($div->attr('data-live-props-value'), true); // make sure the 2nd collection type was initialized, that it didn't @@ -410,13 +399,11 @@ public function testLiveCollectionTypeFieldsAddedAndRemoved(): void $dehydratedProps['validatedFields'], ['blog_post_form.0.comments.content'] )]; - $token = $div->attr('data-live-csrf-value'); }) // post to action, which will remove the original embedded comment ->post('/_components/form_with_live_collection_type/removeCollectionItem', [ 'body' => ['data' => json_encode(['props' => $dehydratedProps, 'updated' => $updatedProps, 'args' => ['name' => 'blog_post_form[comments]', 'index' => '0']])], - 'headers' => ['X-CSRF-TOKEN' => $token], ]) ->assertStatus(422) // the original embedded form should be gone diff --git a/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php b/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php index 34b1326a0bb..1d04d03d6f9 100644 --- a/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php +++ b/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php @@ -22,8 +22,6 @@ final class DataModelPropsSubscriberTest extends KernelTestCase public function testDataModelPropsAreSharedToChild(): void { - $this->fakeSession(); - /** @var ComponentRenderer $renderer */ $renderer = self::getContainer()->get('ux.twig_component.component_renderer'); @@ -43,8 +41,6 @@ public function testDataModelPropsAreSharedToChild(): void public function testDataModelPropsAreAvailableInEmbeddedComponents(): void { - $this->fakeSession(); - $templateName = 'components/parent_component_data_model.html.twig'; $obscuredName = '684c45bf85d3461dbe587407892e59d8'; $this->addTemplateMap($obscuredName, $templateName); @@ -59,14 +55,4 @@ public function testDataModelPropsAreAvailableInEmbeddedComponents(): void $this->assertStringContainsString('', $html); $this->assertStringContainsString('', $html); } - - private function fakeSession(): void - { - // work around so that a session is available so CSRF doesn't fail - $session = self::getContainer()->get('session.factory')->createSession(); - $request = Request::create('/'); - $request->setSession($session); - $requestStack = self::getContainer()->get('request_stack'); - $requestStack->push($request); - } } diff --git a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php index ba933cf9aa8..a8f9cbfa230 100644 --- a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php +++ b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php @@ -27,7 +27,6 @@ public function testToEscapedArray(): void $collection->setFingerprint('the-fingerprint'); $collection->setProps(['the' => 'props']); $collection->setUrl('the-live-url'); - $collection->setCsrf('the-csrf-token'); $collection->setListeners(['event_name' => 'theActionName']); $collection->setEventsToEmit([ [ @@ -55,7 +54,6 @@ public function testToEscapedArray(): void 'data-live-fingerprint-value' => 'the-fingerprint', 'data-live-props-value' => '{"the":"props"}', 'data-live-url-value' => 'the-live-url', - 'data-live-csrf-value' => 'the-csrf-token', 'data-live-listeners-value' => '{"event_name":"theActionName"}', 'data-live-events-to-emit-value' => '[{"event":"event_name1","data":{"the":"data"},"target":"up","componentName":"the-component"},{"event":"event_name2","data":{"the":"data"},"target":null,"componentName":null}]', 'data-live-query-mapping-value' => '{"foo":{"name":"foo"},"bar":{"name":"bar"}}',