Skip to content

Commit fbc5e9a

Browse files
committed
[LiveComponent] Reject malicious child component tags
Validate the `$childTag` value used to compose the placeholder element in `ChildComponentPartialRenderer::createHtml()` against a strict HTML tag name regex. The value originates from the client-controlled `children[id].tag` payload parsed by `LiveComponentSubscriber` and was interpolated unescaped into the rendered HTML, allowing arbitrary HTML injection (including `<script>`) on any Live Component re-render that contains at least one child component. Defense-in-depth: in a default install the request is gated by the bundle's Accept-header check, so this fix is for cases where that check is bypassed (CORS misconfig, custom kernels) or where future callers reach the sink through another path.
1 parent a6e9bf8 commit fbc5e9a

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

src/LiveComponent/src/Util/ChildComponentPartialRenderer.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\UX\LiveComponent\Util;
1313

1414
use Psr\Container\ContainerInterface;
15+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
1516
use Symfony\Contracts\Service\ServiceSubscriberInterface;
1617
use Symfony\UX\LiveComponent\LiveComponentHydrator;
1718
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
@@ -27,6 +28,8 @@
2728
*/
2829
class ChildComponentPartialRenderer implements ServiceSubscriberInterface
2930
{
31+
private const VALID_TAG = '/\A[a-zA-Z][a-zA-Z0-9-]*+\z/';
32+
3033
public function __construct(
3134
private FingerprintCalculator $fingerprintCalculator,
3235
private TwigAttributeHelperFactory $attributeHelperFactory,
@@ -86,6 +89,10 @@ public function renderChildComponent(string $deterministicId, string $currentPro
8689
*/
8790
private function createHtml(array $attributes, string $childTag): string
8891
{
92+
if (!preg_match(self::VALID_TAG, $childTag)) {
93+
throw new BadRequestHttpException('Invalid child tag.');
94+
}
95+
8996
$attributes['data-live-preserve'] = true;
9097
$attributes = new ComponentAttributes($attributes, $this->twig->getRuntime(EscaperRuntime::class));
9198

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener;
1313

14+
use PHPUnit\Framework\Attributes\DataProvider;
1415
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
1516
use Symfony\Component\BrowserKit\AbstractBrowser;
1617
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
@@ -138,6 +139,106 @@ public function testItUsesKeysToRenderChildrenLiveIds()
138139
;
139140
}
140141

142+
/**
143+
* @dataProvider provideInvalidChildTags
144+
*/
145+
#[DataProvider('provideInvalidChildTags')]
146+
public function testItRejectsInvalidChildTag(string $tag): void
147+
{
148+
$component = $this->mountComponent('todo_list', [
149+
'items' => [
150+
['text' => 'wake up'],
151+
['text' => 'high five a friend'],
152+
['text' => 'take a nap'],
153+
],
154+
'includeDataLiveId' => false,
155+
]);
156+
157+
$dehydratedProps = $this->dehydrateComponent($component);
158+
159+
$children = [
160+
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX.'0' => [
161+
'fingerprint' => 'anything',
162+
'tag' => $tag,
163+
],
164+
];
165+
166+
$url = \sprintf('/_components/todo_list?%s', http_build_query([
167+
'props' => json_encode($dehydratedProps->getProps()),
168+
'children' => json_encode($children),
169+
]));
170+
171+
$this->browser()
172+
->visit($url)
173+
->use(function (AbstractBrowser $browser) use ($tag) {
174+
$response = $browser->getResponse();
175+
$this->assertGreaterThanOrEqual(400, $response->getStatusCode(), \sprintf('Invalid child tag %s must be rejected.', json_encode($tag)));
176+
})
177+
;
178+
}
179+
180+
public static function provideInvalidChildTags(): iterable
181+
{
182+
// XSS payloads
183+
yield 'script injection via closing angle' => ['li><script>window.__pwned=1</script><li'];
184+
yield 'open angle bracket' => ['<div'];
185+
yield 'close angle bracket' => ['div>'];
186+
yield 'embedded angle bracket' => ['di>v'];
187+
yield 'attribute injection via quote' => ['li onload="x"'];
188+
yield 'attribute injection via space' => ['li onclick=x'];
189+
190+
// empty / whitespace
191+
yield 'empty string' => [''];
192+
yield 'single space' => [' '];
193+
yield 'whitespace only' => [' '];
194+
yield 'leading space' => [' div'];
195+
yield 'trailing space' => ['div '];
196+
yield 'internal space' => ['di v'];
197+
yield 'tab' => ["di\tv"];
198+
yield 'newline' => ["di\nv"];
199+
yield 'carriage return' => ["di\rv"];
200+
yield 'form feed' => ["di\fv"];
201+
202+
// NUL byte
203+
yield 'leading NUL' => ["\0div"];
204+
yield 'embedded NUL' => ["di\0v"];
205+
yield 'trailing NUL' => ["div\0"];
206+
yield 'NUL only' => ["\0"];
207+
208+
// quotes
209+
yield 'wrapped in double quotes' => ['"div"'];
210+
yield 'wrapped in single quotes' => ["'div'"];
211+
yield 'trailing double quote' => ['div"'];
212+
yield 'trailing single quote' => ["div'"];
213+
yield 'backtick' => ['`div`'];
214+
215+
// invalid start character
216+
yield 'leading digit' => ['1div'];
217+
yield 'digits only' => ['123'];
218+
yield 'leading dash' => ['-div'];
219+
yield 'leading underscore' => ['_div'];
220+
221+
// disallowed inner characters
222+
yield 'underscore inside' => ['div_x'];
223+
yield 'dot inside' => ['my.div'];
224+
yield 'colon (namespace)' => ['svg:rect'];
225+
yield 'slash' => ['div/'];
226+
yield 'leading slash' => ['/div'];
227+
yield 'backslash' => ['di\\v'];
228+
yield 'equals sign' => ['div=x'];
229+
yield 'plus sign' => ['di+v'];
230+
yield 'parenthesis' => ['div()'];
231+
232+
// unicode (not allowed by [a-zA-Z0-9-])
233+
yield 'unicode letter (accented)' => ['divé'];
234+
yield 'unicode latin extended' => ['divñ'];
235+
yield 'unicode math symbol' => ['div×'];
236+
yield 'unicode greek' => ['αβγ'];
237+
yield 'cjk' => ['日本'];
238+
yield 'emoji' => ['🚀'];
239+
yield 'unicode non-breaking space' => ["di\xc2\xa0v"];
240+
}
241+
141242
private function buildUrlForTodoListComponent(array $childrenFingerprints, bool $includeLiveId = false): string
142243
{
143244
return $this->doBuildUrlForComponent('todo_list', $childrenFingerprints, [

0 commit comments

Comments
 (0)