Skip to content

Commit b5d1c85

Browse files
security #cve-2025-47946 [TwigComponent] Add HtmlAttributeEscaper to fix component attributes escaping in HTML syntax (smnandre)
This PR was merged into the ux-2.x branch.
2 parents 27780a1 + 8ef6b05 commit b5d1c85

29 files changed

+510
-96
lines changed

src/LiveComponent/composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
"symfony/property-access": "^5.4.5|^6.0|^7.0",
3232
"symfony/property-info": "^5.4|^6.0|^7.0",
3333
"symfony/stimulus-bundle": "^2.9",
34-
"symfony/ux-twig-component": "^2.8",
35-
"twig/twig": "^3.8.0"
34+
"symfony/ux-twig-component": "^2.25",
35+
"twig/twig": "^3.10.3"
3636
},
3737
"require-dev": {
3838
"doctrine/annotations": "^1.0",

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
1616
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
1717
use Symfony\Component\Config\Definition\ConfigurationInterface;
18+
use Symfony\Component\DependencyInjection\Argument\AbstractArgument;
1819
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
1920
use Symfony\Component\DependencyInjection\ChildDefinition;
2021
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -109,6 +110,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
109110
new Reference('ux.live_component.metadata_factory'),
110111
new Reference('serializer', ContainerInterface::NULL_ON_INVALID_REFERENCE),
111112
$config['secret'], // defaults to '%kernel.secret%'
113+
new Reference('ux.twig_component.component_attributes_factory'),
112114
])
113115
;
114116

@@ -156,6 +158,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
156158
->setArguments([
157159
new Reference('ux.live_component.fingerprint_calculator'),
158160
new Reference('ux.live_component.attribute_helper_factory'),
161+
new Reference('ux.twig_component.component_attributes_factory'),
159162
])
160163
->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory'])
161164
->addTag('container.service_subscriber', ['key' => LiveComponentMetadataFactory::class, 'id' => 'ux.live_component.metadata_factory'])
@@ -197,8 +200,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
197200
->addTag('container.service_subscriber', ['key' => 'validator', 'id' => 'validator'])
198201
;
199202

200-
$container->register('ux.live_component.attribute_helper_factory', TwigAttributeHelperFactory::class)
201-
->setArguments([new Reference('twig')]);
203+
$container->register('ux.live_component.attribute_helper_factory', TwigAttributeHelperFactory::class);
202204

203205
$container->register('ux.live_component.live_controller_attributes_creator', LiveControllerAttributesCreator::class)
204206
->setArguments([
@@ -217,6 +219,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
217219
->setArguments([
218220
new Reference('ux.twig_component.component_stack'),
219221
new Reference('ux.live_component.twig.template_mapper'),
222+
new Reference('ux.twig_component.component_attributes_factory'),
220223
])
221224
->addTag('kernel.event_subscriber')
222225
->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator'])

src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\UX\LiveComponent\Twig\TemplateMap;
1818
use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
1919
use Symfony\UX\TwigComponent\ComponentAttributes;
20+
use Symfony\UX\TwigComponent\ComponentAttributesFactory;
2021
use Symfony\UX\TwigComponent\ComponentMetadata;
2122
use Symfony\UX\TwigComponent\ComponentStack;
2223
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
@@ -36,6 +37,7 @@ final class AddLiveAttributesSubscriber implements EventSubscriberInterface, Ser
3637
public function __construct(
3738
private ComponentStack $componentStack,
3839
private TemplateMap $templateMap,
40+
private readonly ComponentAttributesFactory $componentAttributesFactory,
3941
private ContainerInterface $container,
4042
) {
4143
}
@@ -105,6 +107,6 @@ private function getLiveAttributes(MountedComponent $mounted, ComponentMetadata
105107
$this->componentStack->hasParentComponent()
106108
);
107109

108-
return new ComponentAttributes($attributesCollection->toEscapedArray());
110+
return $this->componentAttributesFactory->create($attributesCollection->toArray());
109111
}
110112
}

src/LiveComponent/src/LiveComponentHydrator.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use Symfony\UX\LiveComponent\Metadata\LivePropMetadata;
3333
use Symfony\UX\LiveComponent\Util\DehydratedProps;
3434
use Symfony\UX\TwigComponent\ComponentAttributes;
35+
use Symfony\UX\TwigComponent\ComponentAttributesFactory;
3536

3637
/**
3738
* @author Kevin Bond <[email protected]>
@@ -52,6 +53,7 @@ public function __construct(
5253
private LiveComponentMetadataFactory $liveComponentMetadataFactory,
5354
private NormalizerInterface|DenormalizerInterface|null $serializer,
5455
#[\SensitiveParameter] private string $secret,
56+
private readonly ComponentAttributesFactory $componentAttributesFactory,
5557
) {
5658
if (!$secret) {
5759
throw new \InvalidArgumentException('A non-empty secret is required.');
@@ -144,7 +146,7 @@ public function hydrate(object $component, array $props, array $updatedProps, Li
144146
$dehydratedOriginalProps = $this->combineAndValidateProps($props, $updatedPropsFromParent);
145147
$dehydratedUpdatedProps = DehydratedProps::createFromUpdatedArray($updatedProps);
146148

147-
$attributes = new ComponentAttributes($dehydratedOriginalProps->getPropValue(self::ATTRIBUTES_KEY, []));
149+
$attributes = $this->componentAttributesFactory->create($dehydratedOriginalProps->getPropValue(self::ATTRIBUTES_KEY, []));
148150
$dehydratedOriginalProps->removePropValue(self::ATTRIBUTES_KEY);
149151

150152
$needProcessOnUpdatedHooks = [];

src/LiveComponent/src/Util/ChildComponentPartialRenderer.php

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Contracts\Service\ServiceSubscriberInterface;
1616
use Symfony\UX\LiveComponent\LiveComponentHydrator;
1717
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
18+
use Symfony\UX\TwigComponent\ComponentAttributesFactory;
1819
use Symfony\UX\TwigComponent\ComponentFactory;
1920

2021
/**
@@ -27,6 +28,7 @@ class ChildComponentPartialRenderer implements ServiceSubscriberInterface
2728
public function __construct(
2829
private FingerprintCalculator $fingerprintCalculator,
2930
private TwigAttributeHelperFactory $attributeHelperFactory,
31+
private ComponentAttributesFactory $componentAttributesFactory,
3032
private ContainerInterface $container,
3133
) {
3234
}
@@ -43,7 +45,7 @@ public function renderChildComponent(string $deterministicId, string $currentPro
4345
$attributesCollection = $this->attributeHelperFactory->create();
4446
$attributesCollection->setLiveId($deterministicId);
4547

46-
return $this->createHtml($attributesCollection->toEscapedArray(), $childTag);
48+
return $this->createHtml($attributesCollection->toArray(), $childTag);
4749
}
4850

4951
/*
@@ -68,7 +70,7 @@ public function renderChildComponent(string $deterministicId, string $currentPro
6870
$readonlyDehydratedProps = $this->getLiveComponentHydrator()->addChecksumToData($readonlyDehydratedProps);
6971

7072
$attributesCollection->setPropsUpdatedFromParent($readonlyDehydratedProps);
71-
$attributes = $attributesCollection->toEscapedArray();
73+
$attributes = $attributesCollection->toArray();
7274
// optional, but these just aren't needed by the frontend at this point
7375
unset($attributes['data-controller']);
7476
unset($attributes['data-live-url-value']);
@@ -82,13 +84,10 @@ public function renderChildComponent(string $deterministicId, string $currentPro
8284
*/
8385
private function createHtml(array $attributes, string $childTag): string
8486
{
85-
// transform attributes into an array of key="value" strings
86-
$attributes = array_map(function ($key, $value) {
87-
return \sprintf('%s="%s"', $key, $value);
88-
}, array_keys($attributes), $attributes);
89-
$attributes[] = 'data-live-preserve="true"';
87+
$attributes['data-live-preserve'] = true;
88+
$attributes = $this->componentAttributesFactory->create($attributes);
9089

91-
return \sprintf('<%s %s></%s>', $childTag, implode(' ', $attributes), $childTag);
90+
return \sprintf('<%s%s></%s>', $childTag, $attributes, $childTag);
9291
}
9392

9493
public static function getSubscribedServices(): array

src/LiveComponent/src/Util/LiveAttributesCollection.php

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,26 @@
1616
use Twig\Runtime\EscaperRuntime;
1717

1818
/**
19-
* An array of attributes that can eventually be returned as an escaped array.
19+
* A collection of HTML attributes useful for LiveComponent.
2020
*
2121
* @internal
2222
*/
2323
final class LiveAttributesCollection
2424
{
2525
private array $attributes = [];
2626

27-
public function __construct(private Environment $twig)
27+
public function toArray(): array
2828
{
29-
}
30-
31-
public function toEscapedArray(): array
32-
{
33-
$escaped = [];
29+
$result = [];
3430
foreach ($this->attributes as $key => $value) {
3531
if (\is_array($value)) {
3632
$value = JsonUtil::encodeObject($value);
3733
}
3834

39-
$escaped[$key] = $this->escapeAttribute($value);
35+
$result[$key] = $value;
4036
}
4137

42-
return $escaped;
38+
return $result;
4339
}
4440

4541
public function setLiveController(string $componentName): void
@@ -107,18 +103,4 @@ public function setQueryUrlMapping(array $queryUrlMapping): void
107103
{
108104
$this->attributes['data-live-query-mapping-value'] = $queryUrlMapping;
109105
}
110-
111-
private function escapeAttribute(string $value): string
112-
{
113-
if (class_exists(EscaperRuntime::class)) {
114-
return $this->twig->getRuntime(EscaperRuntime::class)->escape($value, 'html_attr');
115-
}
116-
117-
if (method_exists(EscaperExtension::class, 'escape')) {
118-
return EscaperExtension::escape($this->twig, $value, 'html_attr');
119-
}
120-
121-
// since twig/twig 3.9.0: Using the internal "twig_escape_filter" function is deprecated.
122-
return (string) twig_escape_filter($this->twig, $value, 'html_attr');
123-
}
124106
}

src/LiveComponent/src/Util/TwigAttributeHelperFactory.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020
*/
2121
final class TwigAttributeHelperFactory
2222
{
23-
public function __construct(private Environment $twig)
23+
public function __construct()
2424
{
2525
}
2626

2727
public function create(): LiveAttributesCollection
2828
{
29-
return new LiveAttributesCollection($this->twig);
29+
return new LiveAttributesCollection();
3030
}
3131
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,16 @@ public function testItRendersNewPropWhenFingerprintDoesNotMatch(): void
8989
// 1st renders empty
9090
// fingerprint changed in 2nd & 3rd, so it renders new fingerprint + props
9191
$this->assertStringContainsString(\sprintf(
92-
'<li id="%s0" data-live-preserve="true"></li>',
92+
'<li id="%s0" data-live-preserve></li>',
9393
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX
9494
), $content);
9595
// new props are JUST the "textLength" + a checksum for it specifically
9696
$this->assertStringContainsString(\sprintf(
97-
'<li data-live-name-value="todo_item" id="%s0" data-live-fingerprint-value="sMvvf7q68tz&#x2F;Cuk&#x2B;vDeisDiq&#x2B;7YPWzT&#x2B;WZFzI37dGHY&#x3D;" data-live-props-updated-from-parent-value="&#x7B;&quot;textLength&quot;&#x3A;18,&quot;&#x40;checksum&quot;&#x3A;&quot;LGxXa9fMKrJ6PelkUPfqmdwnfkk&#x2B;LORgoJHXyPpS3Pw&#x3D;&quot;&#x7D;" data-live-preserve="true"></li>',
97+
'<li data-live-name-value="todo_item" id="%s0" data-live-fingerprint-value="sMvvf7q68tz/Cuk+vDeisDiq+7YPWzT+WZFzI37dGHY=" data-live-props-updated-from-parent-value="{&quot;textLength&quot;:18,&quot;@checksum&quot;:&quot;LGxXa9fMKrJ6PelkUPfqmdwnfkk+LORgoJHXyPpS3Pw=&quot;}" data-live-preserve></li>',
9898
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX_EMBEDDED
9999
), $content);
100100
$this->assertStringContainsString(\sprintf(
101-
'<li data-live-name-value="todo_item" id="%s1" data-live-fingerprint-value="8AooEz36WYQyxj54BCaDm&#x2F;jKbcdDdPDLaNO4&#x2F;49bcQk&#x3D;" data-live-props-updated-from-parent-value="&#x7B;&quot;textLength&quot;&#x3A;10,&quot;&#x40;checksum&quot;&#x3A;&quot;BXUk7q6LI&#x5C;&#x2F;6Qx3c62Xiui6287YndmoK3QmVq6e5mcGk&#x3D;&quot;&#x7D;" data-live-preserve="true"></li>',
101+
'<li data-live-name-value="todo_item" id="%s1" data-live-fingerprint-value="8AooEz36WYQyxj54BCaDm/jKbcdDdPDLaNO4/49bcQk=" data-live-props-updated-from-parent-value="{&quot;textLength&quot;:10,&quot;@checksum&quot;:&quot;BXUk7q6LI\/6Qx3c62Xiui6287YndmoK3QmVq6e5mcGk=&quot;}" data-live-preserve></li>',
102102
AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX
103103
), $content);
104104
});
@@ -132,7 +132,7 @@ public function testItUsesKeysToRenderChildrenLiveIds(): void
132132
->assertContains('id="live-521026374-the-key0"')
133133
->assertNotContains('key="the-key0"')
134134
->visit($urlWithChangedFingerprints)
135-
->assertContains('<li id="live-521026374-the-key0" data-live-preserve="true"></li>')
135+
->assertContains('<li id="live-521026374-the-key0" data-live-preserve></li>')
136136
// this one is changed, so it renders a full element
137137
->assertContains('<li data-live-name-value="todo_item" id="live-4172682817-the-key1"')
138138
;

src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\ZeroIntEnum;
4444
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
4545
use Symfony\UX\TwigComponent\ComponentAttributes;
46+
use Symfony\UX\TwigComponent\ComponentAttributesFactory;
4647
use Symfony\UX\TwigComponent\ComponentMetadata;
4748
use Zenstruck\Foundry\Test\Factories;
4849
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -75,6 +76,9 @@ private function executeHydrationTestCase(callable $testFactory, ?int $minPhpVer
7576
$metadataFactory = self::getContainer()->get('ux.live_component.metadata_factory');
7677
\assert($metadataFactory instanceof LiveComponentMetadataFactory);
7778
$testCase = $testBuilder->getTest($metadataFactory);
79+
80+
$componentAttributesFactory = self::getContainer()->get('ux.twig_component.component_attributes_factory');
81+
\assert($componentAttributesFactory instanceof ComponentAttributesFactory);
7882

7983
// keep a copy of the original, empty component object for hydration later
8084
$originalComponentWithData = clone $testCase->component;
@@ -90,7 +94,7 @@ private function executeHydrationTestCase(callable $testFactory, ?int $minPhpVer
9094

9195
$dehydratedProps = $this->hydrator()->dehydrate(
9296
$originalComponentWithData,
93-
new ComponentAttributes([]), // not worried about testing these here
97+
$componentAttributesFactory->create([]), // not worried about testing these here
9498
$liveMetadata,
9599
);
96100

@@ -132,7 +136,7 @@ private function executeHydrationTestCase(callable $testFactory, ?int $minPhpVer
132136

133137
$dehydratedProps2 = $this->hydrator()->dehydrate(
134138
$componentAfterHydration,
135-
new ComponentAttributes([]), // not worried about testing these here
139+
$componentAttributesFactory->create(),
136140
$liveMetadata,
137141
);
138142
$this->hydrator()->hydrate(
@@ -1449,7 +1453,7 @@ public function __construct()
14491453

14501454
$dehydratedProps = $this->hydrator()->dehydrate(
14511455
$component,
1452-
new ComponentAttributes([]),
1456+
$this->createComponentAttributes(),
14531457
$this->createLiveMetadata($component)
14541458
);
14551459

@@ -1498,7 +1502,7 @@ public function testInterfaceTypedLivePropCannotBeDehydrated(): void
14981502
$this->expectExceptionMessageMatches('/Cannot dehydrate value typed as interface "Symfony\UX\LiveComponent\Tests\Fixtures\Dto\SimpleDtoInterface" on component ');
14991503
$this->expectExceptionMessageMatches('/ Change this to a concrete type that can be dehydrated/');
15001504

1501-
$this->hydrator()->dehydrate($component, new ComponentAttributes([]), $this->createLiveMetadata($component));
1505+
$this->hydrator()->dehydrate($component, $this->createComponentAttributes(), $this->createLiveMetadata($component));
15021506
}
15031507

15041508
/**
@@ -1634,7 +1638,7 @@ public function testHydrationTakeUpdatedParentPropsIntoAccount(): void
16341638
$liveMetadata = $this->createLiveMetadata($component);
16351639
$dehydrated = $this->hydrator()->dehydrate(
16361640
$component,
1637-
new ComponentAttributes([]),
1641+
$this->createComponentAttributes(),
16381642
$liveMetadata
16391643
);
16401644
$updatedFromParentData = ['shouldUppercase' => true];
@@ -1665,7 +1669,7 @@ public function testHydrationWithUpdatesParentPropsAndBadChecksumFails(): void
16651669
$liveMetadata = $this->createLiveMetadata($component);
16661670
$dehydrated = $this->hydrator()->dehydrate(
16671671
$component,
1668-
new ComponentAttributes([]),
1672+
$this->createComponentAttributes(),
16691673
$liveMetadata
16701674
);
16711675
$updatedFromParentData = ['name' => 'Kevin'];
@@ -1820,6 +1824,14 @@ public static function falseyValueProvider(): iterable
18201824
yield ['nullableBool', '', null];
18211825
yield 'fooey-o-booey-todo' => ['nullableBool', ' ', null];
18221826
}
1827+
1828+
private function createComponentAttributes(array $attributes = []): ComponentAttributes
1829+
{
1830+
$factory = self::getContainer()->get('ux.twig_component.component_attributes_factory');
1831+
\assert($factory instanceof ComponentAttributesFactory);
1832+
1833+
return $factory->create($attributes);
1834+
}
18231835

18241836
private function createLiveMetadata(object $component): LiveComponentMetadata
18251837
{

src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
use Symfony\UX\LiveComponent\LiveComponentHydrator;
2121
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
2222
use Symfony\UX\LiveComponent\Metadata\LivePropMetadata;
23+
use Symfony\UX\TwigComponent\ComponentAttributesFactory;
24+
use Twig\Environment;
2325

2426
final class LiveComponentHydratorTest extends TestCase
2527
{
@@ -34,6 +36,7 @@ public function testConstructWithEmptySecret(): void
3436
$this->createMock(LiveComponentMetadataFactory::class),
3537
$this->createMock(NormalizerInterface::class),
3638
'',
39+
new ComponentAttributesFactory($this->createMock(Environment::class)),
3740
);
3841
}
3942

@@ -45,6 +48,7 @@ public function testItCanHydrateWithNullValues()
4548
$this->createMock(LiveComponentMetadataFactory::class),
4649
new Serializer(normalizers: [new ObjectNormalizer()]),
4750
'foo',
51+
new ComponentAttributesFactory($this->createMock(Environment::class)),
4852
);
4953

5054
$hydratedValue = $hydrator->hydrateValue(

0 commit comments

Comments
 (0)