From 121f428d3231bd64356d46b74cd416a688e21493 Mon Sep 17 00:00:00 2001 From: Jack Sleight Date: Fri, 9 Oct 2026 14:44:53 +0100 Subject: [PATCH] Escape bound attributes passed to components from Antlers --- src/Tags/ComponentProxy.php | 20 +++- .../Concerns/CompilesBladeComponents.php | 13 +++ .../Components/AttributeEscapingTest.php | 97 +++++++++++++++++++ .../escaped_attributes.antlers.html | 2 + .../escaped_attributes_blade.blade.php | 2 + 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 tests/Antlers/Components/AttributeEscapingTest.php create mode 100644 tests/__fixtures__/views/components/escaped_attributes.antlers.html create mode 100644 tests/__fixtures__/views/components/escaped_attributes_blade.blade.php diff --git a/src/Tags/ComponentProxy.php b/src/Tags/ComponentProxy.php index 5a18593cc87..63a40fa1541 100644 --- a/src/Tags/ComponentProxy.php +++ b/src/Tags/ComponentProxy.php @@ -7,6 +7,7 @@ use Illuminate\View\Compilers\BladeCompiler; use Illuminate\View\Compilers\ComponentTagCompiler; use Illuminate\View\ComponentAttributeBag; +use Illuminate\View\DynamicComponent; use ReflectionClass; use Throwable; @@ -36,7 +37,7 @@ public function index() $tagCompiler = $this->makeComponentTagCompiler(); $className = $tagCompiler->componentClass($componentName); - $data = $this->params->except('component_name___')->all(); + $data = $this->params->except(['component_name___', 'component_bound___'])->all(); $attributes = new ComponentAttributeBag($data); $constructorParameters = []; @@ -74,7 +75,9 @@ public function index() $component = $className::resolve($constructorParameters + ((array) $attributes->getIterator())); $component->withName($componentName); $__env->startComponent($component->resolveView(), $component->data()); - $component->withAttributes($attributes->getAttributes()); + $component->withAttributes($className === DynamicComponent::class + ? $attributes->getAttributes() + : $this->escapeBoundAttributes($attributes->getAttributes())); if ($this->content) { $contextData = array_merge($this->isolatedContext?->all() ?? [], [ @@ -110,9 +113,9 @@ public function componentSlot() $contextData = self::$componentStack[array_key_last(self::$componentStack)][1]; $slot = $this->params->get('component_slot___'); - $context = $this->params->except('component_slot___')->all(); + $context = $this->params->except(['component_slot___', 'component_bound___'])->all(); - $__env->slot($slot, null, $context); + $__env->slot($slot, null, $this->escapeBoundAttributes($context)); echo $this->parse($contextData); @@ -122,6 +125,15 @@ public function componentSlot() } } + protected function escapeBoundAttributes(array $attributes): array + { + $bound = explode(',', $this->params->get('component_bound___', '')); + + return collect($attributes) + ->map(fn ($value, $key) => in_array($key, $bound) ? BladeCompiler::sanitizeComponentAttribute($value) : $value) + ->all(); + } + protected function handleViewException(Throwable $e, $obLevel) { while (ob_get_level() > $obLevel) { diff --git a/src/View/Antlers/Language/Parser/Concerns/CompilesBladeComponents.php b/src/View/Antlers/Language/Parser/Concerns/CompilesBladeComponents.php index 5fed964f1c2..26aa91a31f1 100644 --- a/src/View/Antlers/Language/Parser/Concerns/CompilesBladeComponents.php +++ b/src/View/Antlers/Language/Parser/Concerns/CompilesBladeComponents.php @@ -34,6 +34,10 @@ protected function compileBladeComponent(ComponentNode $component, string $prefi $params .= ' component_name___="'.$name.'"'; } + if ($bound = $this->getBoundParameterNames($component)) { + $params .= ' component_bound___="'.implode(',', $bound).'"'; + } + if ($component->isClosingTag && ! $component->isSelfClosing) { return "{{ /%component_proxy:{$tagMethod} }}"; } @@ -51,6 +55,15 @@ protected function compileBladeComponent(ComponentNode $component, string $prefi return $open.$innerContent.$close; } + protected function getBoundParameterNames(ComponentNode $component): array + { + return collect($component->parameters) + ->filter(fn ($parameter) => in_array($parameter->type, [ParameterType::DynamicVariable, ParameterType::ShorthandDynamicVariable])) + ->map(fn ($parameter) => $parameter->materializedName) + ->values() + ->all(); + } + protected function getParamValue(string $value): string { return Str::replace('"', '\\"', $value); diff --git a/tests/Antlers/Components/AttributeEscapingTest.php b/tests/Antlers/Components/AttributeEscapingTest.php new file mode 100644 index 00000000000..5036363031d --- /dev/null +++ b/tests/Antlers/Components/AttributeEscapingTest.php @@ -0,0 +1,97 @@ + & d'; + + public static function componentProvider() + { + return [ + 'antlers component' => ['escaped_attributes'], + 'blade component' => ['escaped_attributes_blade'], + ]; + } + + #[Test] + #[DataProvider('componentProvider')] + public function bound_attributes_are_escaped($component) + { + $this->assertSame( + '
', + trim($this->renderString('', ['value' => $this->value])) + ); + } + + #[Test] + #[DataProvider('componentProvider')] + public function shorthand_bound_attributes_are_escaped($component) + { + $this->assertSame( + '
', + trim($this->renderString('', ['value' => $this->value])) + ); + } + + #[Test] + #[DataProvider('componentProvider')] + public function bound_attributes_match_blade($component) + { + $this->assertSame( + trim(Blade::render('', ['value' => $this->value])), + trim($this->renderString('', ['value' => $this->value])) + ); + } + + #[Test] + #[DataProvider('componentProvider')] + public function literal_attributes_are_not_escaped_again($component) + { + $this->assertSame( + '
', + trim($this->renderString('')) + ); + } + + #[Test] + #[DataProvider('componentProvider')] + public function bound_props_are_not_escaped($component) + { + $this->assertSame( + '
a "b" & d
', + trim($this->renderString('', ['value' => $this->value])) + ); + } + + #[Test] + public function bound_slot_attributes_are_escaped() + { + $template = <<<'EOT' + + Header + Footer + +EOT; + + $this->assertStringContainsString( + '
', + $this->renderString($template, ['value' => $this->value]) + ); + } + + #[Test] + #[DataProvider('componentProvider')] + public function dynamic_component_attributes_are_escaped_once($component) + { + $this->assertSame( + '
', + trim($this->renderString('', ['value' => $this->value])) + ); + } +} diff --git a/tests/__fixtures__/views/components/escaped_attributes.antlers.html b/tests/__fixtures__/views/components/escaped_attributes.antlers.html new file mode 100644 index 00000000000..4f909a9feec --- /dev/null +++ b/tests/__fixtures__/views/components/escaped_attributes.antlers.html @@ -0,0 +1,2 @@ +@props(['title' => null]) +
{{ title }}
diff --git a/tests/__fixtures__/views/components/escaped_attributes_blade.blade.php b/tests/__fixtures__/views/components/escaped_attributes_blade.blade.php new file mode 100644 index 00000000000..299e3e70745 --- /dev/null +++ b/tests/__fixtures__/views/components/escaped_attributes_blade.blade.php @@ -0,0 +1,2 @@ +@props(['title' => null]) +
{!! $title !!}