Skip to content

Commit f63cb59

Browse files
committed
feature #1218 Live component force post requests (hepisec)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- Live component force post requests | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | Fix #1208 | License | MIT When your component model may contain sensitive data, you probably don't want your data being transferred via GET requests, because then the data might leak to browser history, server logs etc. With this PR you can add `forcePost: true` to `#[AsLiveComponent]` and your component will always use POST requests to talk to the backend. ```php #[AsLiveComponent(forcePost: true)] class MySensitiveComponent { // ... } ``` Commits ------- 4310601 Live component force post requests
2 parents a2418ef + 4310601 commit f63cb59

28 files changed

+332
-80
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
- Add support for URL binding in `LiveProp`
66
- Allow multiple `LiveListener` attributes on a single method.
7+
- Requests to LiveComponent are sent as POST by default
8+
- Add method prop to AsLiveComponent to still allow GET requests, usage: `#[AsLiveComponent(method: 'get')]`
79

810
## 2.13.2
911

src/LiveComponent/assets/dist/Backend/Backend.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface BackendAction {
2020
}
2121
export default class implements BackendInterface {
2222
private readonly requestBuilder;
23-
constructor(url: string, csrfToken?: string | null);
23+
constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
2424
makeRequest(props: any, actions: BackendAction[], updated: {
2525
[key: string]: any;
2626
}, children: ChildrenFingerprints, updatedPropsFromParent: {

src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { BackendAction, ChildrenFingerprints } from './Backend';
22
export default class {
33
private url;
4+
private method;
45
private readonly csrfToken;
5-
constructor(url: string, csrfToken?: string | null);
6+
constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
67
buildRequest(props: any, actions: BackendAction[], updated: {
78
[key: string]: any;
89
}, children: ChildrenFingerprints, updatedPropsFromParent: {

src/LiveComponent/assets/dist/live_controller.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
3232
type: StringConstructor;
3333
default: string;
3434
};
35+
requestMethod: {
36+
type: StringConstructor;
37+
default: string;
38+
};
3539
queryMapping: {
3640
type: ObjectConstructor;
3741
default: {};
@@ -48,6 +52,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4852
readonly hasDebounceValue: boolean;
4953
readonly debounceValue: number;
5054
readonly fingerprintValue: string;
55+
readonly requestMethodValue: 'get' | 'post';
5156
readonly queryMappingValue: {
5257
[p: string]: {
5358
name: string;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2169,8 +2169,9 @@ class BackendRequest {
21692169
}
21702170

21712171
class RequestBuilder {
2172-
constructor(url, csrfToken = null) {
2172+
constructor(url, method = 'post', csrfToken = null) {
21732173
this.url = url;
2174+
this.method = method;
21742175
this.csrfToken = csrfToken;
21752176
}
21762177
buildRequest(props, actions, updated, children, updatedPropsFromParent, files) {
@@ -2187,6 +2188,7 @@ class RequestBuilder {
21872188
const hasFingerprints = Object.keys(children).length > 0;
21882189
if (actions.length === 0 &&
21892190
totalFiles === 0 &&
2191+
this.method === 'get' &&
21902192
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) {
21912193
params.set('props', JSON.stringify(props));
21922194
params.set('updated', JSON.stringify(updated));
@@ -2244,8 +2246,8 @@ class RequestBuilder {
22442246
}
22452247

22462248
class Backend {
2247-
constructor(url, csrfToken = null) {
2248-
this.requestBuilder = new RequestBuilder(url, csrfToken);
2249+
constructor(url, method = 'post', csrfToken = null) {
2250+
this.requestBuilder = new RequestBuilder(url, method, csrfToken);
22492251
}
22502252
makeRequest(props, actions, updated, children, updatedPropsFromParent, files) {
22512253
const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files);
@@ -2840,7 +2842,7 @@ class LiveControllerDefault extends Controller {
28402842
initialize() {
28412843
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
28422844
const id = this.element.dataset.liveId || null;
2843-
this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, (currentComponent, onlyParents, onlyMatchName) => LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName), this.fingerprintValue, id, new Backend(this.urlValue, this.csrfValue), new StandardElementDriver());
2845+
this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, (currentComponent, onlyParents, onlyMatchName) => LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName), this.fingerprintValue, id, new Backend(this.urlValue, this.requestMethodValue, this.csrfValue), new StandardElementDriver());
28442846
this.proxiedComponent = proxifyComponent(this.component);
28452847
this.element.__component = this.proxiedComponent;
28462848
if (this.hasDebounceValue) {
@@ -3073,6 +3075,7 @@ LiveControllerDefault.values = {
30733075
debounce: { type: Number, default: 150 },
30743076
id: String,
30753077
fingerprint: { type: String, default: '' },
3078+
requestMethod: { type: String, default: 'post' },
30763079
queryMapping: { type: Object, default: {} },
30773080
};
30783081
LiveControllerDefault.componentRegistry = new ComponentRegistry();

src/LiveComponent/assets/src/Backend/Backend.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export interface BackendAction {
2525
export default class implements BackendInterface {
2626
private readonly requestBuilder: RequestBuilder;
2727

28-
constructor(url: string, csrfToken: string | null = null) {
29-
this.requestBuilder = new RequestBuilder(url, csrfToken);
28+
constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
29+
this.requestBuilder = new RequestBuilder(url, method, csrfToken);
3030
}
3131

3232
makeRequest(

src/LiveComponent/assets/src/Backend/RequestBuilder.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { BackendAction, ChildrenFingerprints } from './Backend';
22

33
export default class {
44
private url: string;
5+
private method: 'get' | 'post';
56
private readonly csrfToken: string | null;
67

7-
constructor(url: string, csrfToken: string | null = null) {
8+
constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
89
this.url = url;
10+
this.method = method;
911
this.csrfToken = csrfToken;
1012
}
1113

@@ -37,6 +39,7 @@ export default class {
3739
if (
3840
actions.length === 0 &&
3941
totalFiles === 0 &&
42+
this.method === 'get' &&
4043
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))
4144
) {
4245
params.set('props', JSON.stringify(props));

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4545
debounce: { type: Number, default: 150 },
4646
id: String,
4747
fingerprint: { type: String, default: '' },
48+
requestMethod: { type: String, default: 'post' },
4849
queryMapping: { type: Object, default: {} },
4950
};
5051

@@ -56,6 +57,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
5657
declare readonly hasDebounceValue: boolean;
5758
declare readonly debounceValue: number;
5859
declare readonly fingerprintValue: string;
60+
declare readonly requestMethodValue: 'get' | 'post';
5961
declare readonly queryMappingValue: { [p: string]: { name: string } };
6062

6163
/** The component, wrapped in the convenience Proxy */
@@ -87,7 +89,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
8789
LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName),
8890
this.fingerprintValue,
8991
id,
90-
new Backend(this.urlValue, this.csrfValue),
92+
new Backend(this.urlValue, this.requestMethodValue, this.csrfValue),
9193
new StandardElementDriver()
9294
);
9395
this.proxiedComponent = proxifyComponent(this.component);

src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import RequestBuilder from '../../src/Backend/RequestBuilder';
22

33
describe('buildRequest', () => {
44
it('sets basic data on GET request', () => {
5-
const builder = new RequestBuilder('/_components?existing_param=1', '_the_csrf_token');
5+
const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token');
66
const { url, fetchOptions } = builder.buildRequest(
77
{ firstName: 'Ryan' },
88
[],
@@ -21,7 +21,7 @@ describe('buildRequest', () => {
2121
});
2222

2323
it('sets basic data on POST request', () => {
24-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
24+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
2525
const { url, fetchOptions } = builder.buildRequest(
2626
{ firstName: 'Ryan' },
2727
[{
@@ -52,7 +52,7 @@ describe('buildRequest', () => {
5252
});
5353

5454
it('sets basic data on POST request with batch actions', () => {
55-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
55+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
5656
const { url, fetchOptions } = builder.buildRequest(
5757
{ firstName: 'Ryan' },
5858
[{
@@ -87,7 +87,7 @@ describe('buildRequest', () => {
8787

8888
// when data is too long it makes a post request
8989
it('makes a POST request when data is too long', () => {
90-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
90+
const builder = new RequestBuilder('/_components', 'get', '_the_csrf_token');
9191
const { url, fetchOptions } = builder.buildRequest(
9292
{ firstName: 'Ryan'.repeat(1000) },
9393
[],
@@ -112,8 +112,38 @@ describe('buildRequest', () => {
112112
}));
113113
});
114114

115+
it('makes a POST request when method is post', () => {
116+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
117+
const { url, fetchOptions } = builder.buildRequest(
118+
{
119+
firstName: 'Ryan'
120+
},
121+
[],
122+
{ firstName: 'Kevin' },
123+
{},
124+
{},
125+
{}
126+
);
127+
128+
expect(url).toEqual('/_components');
129+
expect(fetchOptions.method).toEqual('POST');
130+
expect(fetchOptions.headers).toEqual({
131+
// no token
132+
Accept: 'application/vnd.live-component+html',
133+
'X-Requested-With': 'XMLHttpRequest',
134+
});
135+
const body = <FormData>fetchOptions.body;
136+
expect(body).toBeInstanceOf(FormData);
137+
expect(body.get('data')).toEqual(JSON.stringify({
138+
props: {
139+
firstName: 'Ryan'
140+
},
141+
updated: { firstName: 'Kevin' },
142+
}));
143+
});
144+
115145
it('sends propsFromParent when specified', () => {
116-
const builder = new RequestBuilder('/_components?existing_param=1', '_the_csrf_token');
146+
const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token');
117147
const { url } = builder.buildRequest(
118148
{ firstName: 'Ryan' },
119149
[],
@@ -167,7 +197,7 @@ describe('buildRequest', () => {
167197
};
168198

169199
it('Sends file with request', () => {
170-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
200+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
171201

172202
const { url, fetchOptions } = builder.buildRequest(
173203
{ firstName: 'Ryan' },
@@ -192,7 +222,7 @@ describe('buildRequest', () => {
192222
});
193223

194224
it('Sends multiple files with request', () => {
195-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
225+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
196226

197227
const { url, fetchOptions } = builder.buildRequest(
198228
{ firstName: 'Ryan' },

src/LiveComponent/src/Attribute/AsLiveComponent.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,15 @@ public function __construct(
4242
string $attributesVar = 'attributes',
4343
public bool $csrf = true,
4444
public string $route = 'ux_live_component',
45+
public string $method = 'post',
4546
) {
4647
parent::__construct($name, $template, $exposePublicProps, $attributesVar);
48+
49+
$this->method = strtolower($this->method);
50+
51+
if (!\in_array($this->method, ['get', 'post'])) {
52+
throw new \UnexpectedValueException('$method must be either \'get\' or \'post\'');
53+
}
4754
}
4855

4956
/**
@@ -56,6 +63,7 @@ public function serviceConfig(): array
5663
'live' => true,
5764
'csrf' => $this->csrf,
5865
'route' => $this->route,
66+
'method' => $this->method,
5967
]);
6068
}
6169

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,17 @@ public function onKernelController(ControllerEvent $event): void
164164
throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, $component::class));
165165
}
166166

167+
$componentName = $request->attributes->get('_component_name') ?? $request->attributes->get('_mounted_component')->getName();
168+
$requestMethod = $this->container->get(ComponentFactory::class)->metadataFor($componentName)?->get('method') ?? 'post';
169+
170+
/**
171+
* $requestMethod 'post' allows POST requests only
172+
* $requestMethod 'get' allows GET and POST requests.
173+
*/
174+
if ($request->isMethod('get') && 'post' === $requestMethod) {
175+
throw new MethodNotAllowedHttpException([strtoupper($requestMethod)]);
176+
}
177+
167178
/*
168179
* Either we:
169180
* A) We do NOT have a _mounted_component, so hydrate $component

src/LiveComponent/src/Util/LiveAttributesCollection.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ public function setBrowserEventsToDispatch(array $browserEventsToDispatch): void
9898
$this->attributes['data-live-browser-dispatch'] = $browserEventsToDispatch;
9999
}
100100

101+
public function setRequestMethod(string $requestMethod): void
102+
{
103+
$this->attributes['data-live-request-method-value'] = $requestMethod;
104+
}
105+
101106
public function setQueryUrlMapping(array $queryUrlMapping): void
102107
{
103108
$this->attributes['data-live-query-mapping-value'] = $queryUrlMapping;

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
9999
}
100100

101101
$liveMetadata = $this->metadataFactory->getMetadata($mounted->getName());
102+
$requestMethod = $liveMetadata->getComponentMetadata()?->get('method') ?? 'post';
103+
$attributesCollection->setRequestMethod($requestMethod);
102104

103105
if ($liveMetadata->hasQueryStringBindings()) {
104106
$queryMapping = [];

src/LiveComponent/tests/Fixtures/Component/Component1.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
/**
2121
* @author Kevin Bond <kevinbond@gmail.com>
2222
*/
23-
#[AsLiveComponent('component1')]
23+
#[AsLiveComponent('component1', method: 'get')]
2424
final class Component1
2525
{
2626
use DefaultActionTrait;

src/LiveComponent/tests/Fixtures/Component/Component2.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
/**
2626
* @author Kevin Bond <kevinbond@gmail.com>
2727
*/
28-
#[AsLiveComponent('component2', defaultAction: 'defaultAction()')]
28+
#[AsLiveComponent('component2', defaultAction: 'defaultAction()', method: 'get')]
2929
final class Component2
3030
{
3131
#[LiveProp]

src/LiveComponent/tests/Fixtures/Component/Component6.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
/**
2121
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
2222
*/
23-
#[AsLiveComponent('component6')]
23+
#[AsLiveComponent('component6', method: 'get')]
2424
class Component6
2525
{
2626
use DefaultActionTrait;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
4+
5+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
6+
use Symfony\UX\LiveComponent\DefaultActionTrait;
7+
8+
#[AsLiveComponent('with_method_post', method: 'post')]
9+
final class ComponentWithMethodPost
10+
{
11+
use DefaultActionTrait;
12+
}

src/LiveComponent/tests/Fixtures/Component/ComponentWithWritableProps.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,11 @@
1111

1212
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
1313

14-
use Symfony\Component\HttpFoundation\RedirectResponse;
15-
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
1614
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
17-
use Symfony\UX\LiveComponent\Attribute\PreReRender;
18-
use Symfony\UX\LiveComponent\Attribute\LiveAction;
1915
use Symfony\UX\LiveComponent\Attribute\LiveProp;
20-
use Symfony\UX\LiveComponent\Attribute\PostHydrate;
21-
use Symfony\UX\LiveComponent\Attribute\PreDehydrate;
2216
use Symfony\UX\LiveComponent\DefaultActionTrait;
2317

24-
#[AsLiveComponent('component_with_writable_props')]
18+
#[AsLiveComponent('component_with_writable_props', method: 'get')]
2519
final class ComponentWithWritableProps
2620
{
2721
use DefaultActionTrait;

src/LiveComponent/tests/Fixtures/Component/DeferredComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
88
use Symfony\UX\LiveComponent\DefaultActionTrait;
99

10-
#[AsLiveComponent('deferred_component')]
10+
#[AsLiveComponent('deferred_component', method: 'get')]
1111
final class DeferredComponent
1212
{
1313
use DefaultActionTrait;

src/LiveComponent/tests/Fixtures/Component/TodoListComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1616
use Symfony\UX\LiveComponent\DefaultActionTrait;
1717

18-
#[AsLiveComponent('todo_list')]
18+
#[AsLiveComponent('todo_list', method: 'get')]
1919
final class TodoListComponent
2020
{
2121
#[LiveProp(writable: true)]

src/LiveComponent/tests/Fixtures/Component/TodoListWithKeysComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1616
use Symfony\UX\LiveComponent\DefaultActionTrait;
1717

18-
#[AsLiveComponent('todo_list_with_keys')]
18+
#[AsLiveComponent('todo_list_with_keys', method: 'get')]
1919
final class TodoListWithKeysComponent
2020
{
2121
use DefaultActionTrait;

0 commit comments

Comments
 (0)