From ef2f7d3eced6c0f575a2b0e8d3b9b8f3499b7341 Mon Sep 17 00:00:00 2001 From: b-pm Date: Wed, 9 Sep 2026 11:44:25 +0000 Subject: [PATCH 1/4] Preserve Liquid variables in email template link hrefs HTMLPurifier URI encoding was turning {{ order.number }} into %7B%7B%20order.number%20%7D%7D on save. Protect Liquid tokens around purification for email template bodies. Fixes #1183 --- .../CreateEmailTemplateHandler.php | 2 +- .../UpdateEmailTemplateHandler.php | 2 +- .../HtmlPurifier/HtmlPurifierService.php | 28 ++++++++++ .../HtmlPurifier/HtmlPurifierServiceTest.php | 53 +++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php index 5eb1de1c99..8ddc4a1176 100644 --- a/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php +++ b/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php @@ -50,7 +50,7 @@ public function handle(UpsertEmailTemplateDTO $dto): EmailTemplateDomainObject 'event_id' => $dto->event_id, 'template_type' => $dto->template_type->value, 'subject' => $dto->subject, - 'body' => $this->purifier->purify($dto->body), + 'body' => $this->purifier->purifyPreservingLiquid($dto->body), 'cta' => $dto->cta, 'engine' => $dto->engine->value, 'is_active' => $dto->is_active, diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php index e8a14bdf44..dc1f8a2a36 100644 --- a/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php +++ b/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php @@ -48,7 +48,7 @@ public function handle(UpsertEmailTemplateDTO $dto): EmailTemplateDomainObject return $this->emailTemplateRepository->updateFromArray($template->getId(), [ 'subject' => $dto->subject, - 'body' => $this->purifier->purify($dto->body), + 'body' => $this->purifier->purifyPreservingLiquid($dto->body), 'cta' => $dto->cta, 'engine' => $dto->engine->value, 'is_active' => $dto->is_active, diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php index fc13a850db..39afca57de 100644 --- a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php +++ b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php @@ -28,4 +28,32 @@ public function purify(?string $html): ?string return $this->htmlPurifier->purify($html, $this->config); } + + /** + * Purify HTML while preserving Liquid template tokens such as + * `{{ order.number }}` and `{% if ... %}` so URI encoding does not turn + * them into `%7B%7B...%7D%7D` inside hrefs and other attributes. + */ + public function purifyPreservingLiquid(?string $html): ?string + { + if ($html === null) { + return null; + } + + $tokens = []; + $protected = preg_replace_callback( + '/\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}/', + static function (array $matches) use (&$tokens): string { + $placeholder = 'LIQUIDTOKEN'.count($tokens).'X'; + $tokens[$placeholder] = $matches[0]; + + return $placeholder; + }, + $html, + ); + + $purified = $this->htmlPurifier->purify($protected, $this->config); + + return strtr($purified, $tokens); + } } diff --git a/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php b/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php new file mode 100644 index 0000000000..0c783197a5 --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php @@ -0,0 +1,53 @@ +service = new HtmlPurifierService(new HTMLPurifier()); + } + + public function test_purify_preserving_liquid_keeps_tokens_in_hrefs(): void + { + $html = '

View order

'; + + $result = $this->service->purifyPreservingLiquid($html); + + $this->assertStringContainsString('{{ order.number }}', $result); + $this->assertStringNotContainsString('%7B%7B', $result); + $this->assertStringNotContainsString('LIQUIDTOKEN', $result); + } + + public function test_default_purify_still_encodes_braces_in_hrefs(): void + { + $html = '

View order

'; + + $result = $this->service->purify($html); + + $this->assertStringNotContainsString('{{ order.number }}', (string) $result); + $this->assertStringContainsString('%7B%7B', (string) $result); + } + + public function test_purify_preserving_liquid_keeps_tag_tokens(): void + { + $html = '

{% if order.is_paid %}Paid{% endif %}

'; + + $result = $this->service->purifyPreservingLiquid($html); + + $this->assertStringContainsString('{% if order.is_paid %}', $result); + $this->assertStringContainsString('{{ event.id }}', $result); + $this->assertStringContainsString('{% endif %}', $result); + } +} From d3a3cc88006ec230464feaacbb9076f8aab49a55 Mon Sep 17 00:00:00 2001 From: b-pm Date: Wed, 9 Sep 2026 12:09:18 +0000 Subject: [PATCH 2/4] Address review: harden Liquid-preserving HTML purify Use a per-call random placeholder prefix, fall back to normal purify when preg_replace_callback fails, and drop the explanatory docblock per project conventions. --- .../HtmlPurifier/HtmlPurifierService.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php index 39afca57de..f494e2dc53 100644 --- a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php +++ b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php @@ -5,6 +5,7 @@ use HTMLPurifier; use HTMLPurifier_Config; use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; class HtmlPurifierService { @@ -29,22 +30,18 @@ public function purify(?string $html): ?string return $this->htmlPurifier->purify($html, $this->config); } - /** - * Purify HTML while preserving Liquid template tokens such as - * `{{ order.number }}` and `{% if ... %}` so URI encoding does not turn - * them into `%7B%7B...%7D%7D` inside hrefs and other attributes. - */ public function purifyPreservingLiquid(?string $html): ?string { if ($html === null) { return null; } + $prefix = 'LQ'.Str::lower(Str::random(12)); $tokens = []; $protected = preg_replace_callback( '/\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}/', - static function (array $matches) use (&$tokens): string { - $placeholder = 'LIQUIDTOKEN'.count($tokens).'X'; + static function (array $matches) use (&$tokens, $prefix): string { + $placeholder = $prefix.count($tokens).'X'; $tokens[$placeholder] = $matches[0]; return $placeholder; @@ -52,6 +49,10 @@ static function (array $matches) use (&$tokens): string { $html, ); + if ($protected === null) { + return $this->purify($html); + } + $purified = $this->htmlPurifier->purify($protected, $this->config); return strtr($purified, $tokens); From ad7eea1851709328ae0851fe6921bea683bdb281 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Wed, 9 Sep 2026 21:59:27 +0100 Subject: [PATCH 3/4] Preserve Liquid tokens via a URI filter instead of post-purify restore --- .../CreateEmailTemplateHandler.php | 2 +- .../UpdateEmailTemplateHandler.php | 2 +- .../HtmlPurifier/HtmlPurifierService.php | 37 ++---- .../HtmlPurifier/LiquidTokenUriFilter.php | 41 ++++++ .../EmailTemplateBodyPurificationTest.php | 97 ++++++++++++++ .../HtmlPurifier/HtmlPurifierServiceTest.php | 119 +++++++++++++++--- 6 files changed, 248 insertions(+), 50 deletions(-) create mode 100644 backend/app/Services/Infrastructure/HtmlPurifier/LiquidTokenUriFilter.php create mode 100644 backend/tests/Feature/Services/Application/Handlers/EmailTemplate/EmailTemplateBodyPurificationTest.php diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php index 8ddc4a1176..5eb1de1c99 100644 --- a/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php +++ b/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php @@ -50,7 +50,7 @@ public function handle(UpsertEmailTemplateDTO $dto): EmailTemplateDomainObject 'event_id' => $dto->event_id, 'template_type' => $dto->template_type->value, 'subject' => $dto->subject, - 'body' => $this->purifier->purifyPreservingLiquid($dto->body), + 'body' => $this->purifier->purify($dto->body), 'cta' => $dto->cta, 'engine' => $dto->engine->value, 'is_active' => $dto->is_active, diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php index dc1f8a2a36..e8a14bdf44 100644 --- a/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php +++ b/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php @@ -48,7 +48,7 @@ public function handle(UpsertEmailTemplateDTO $dto): EmailTemplateDomainObject return $this->emailTemplateRepository->updateFromArray($template->getId(), [ 'subject' => $dto->subject, - 'body' => $this->purifier->purifyPreservingLiquid($dto->body), + 'body' => $this->purifier->purify($dto->body), 'cta' => $dto->cta, 'engine' => $dto->engine->value, 'is_active' => $dto->is_active, diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php index cf9fba383e..250672f751 100644 --- a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php +++ b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php @@ -5,10 +5,13 @@ use HTMLPurifier; use HTMLPurifier_Config; use Illuminate\Support\Facades\File; -use Illuminate\Support\Str; class HtmlPurifierService { + private const URI_DEFINITION_ID = 'hievents.uri'; + + private const URI_DEFINITION_REV = 1; + private HTMLPurifier_Config $config; public function __construct(private readonly HTMLPurifier $htmlPurifier) @@ -21,6 +24,10 @@ public function __construct(private readonly HTMLPurifier $htmlPurifier) $this->config->set('Cache.SerializerPath', $cachePath); $this->config->set('HTML.Nofollow', true); $this->config->set('HTML.TargetBlank', true); + $this->config->set('URI.DefinitionID', self::URI_DEFINITION_ID); + $this->config->set('URI.DefinitionRev', self::URI_DEFINITION_REV); + + $this->config->maybeGetRawURIDefinition()?->addFilter(new LiquidTokenUriFilter, $this->config); } public function purify(?string $html): ?string @@ -31,32 +38,4 @@ public function purify(?string $html): ?string return $this->htmlPurifier->purify($html, $this->config); } - - public function purifyPreservingLiquid(?string $html): ?string - { - if ($html === null) { - return null; - } - - $prefix = 'LQ'.Str::lower(Str::random(12)); - $tokens = []; - $protected = preg_replace_callback( - '/\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}/', - static function (array $matches) use (&$tokens, $prefix): string { - $placeholder = $prefix.count($tokens).'X'; - $tokens[$placeholder] = $matches[0]; - - return $placeholder; - }, - $html, - ); - - if ($protected === null) { - return $this->purify($html); - } - - $purified = $this->htmlPurifier->purify($protected, $this->config); - - return strtr($purified, $tokens); - } } diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/LiquidTokenUriFilter.php b/backend/app/Services/Infrastructure/HtmlPurifier/LiquidTokenUriFilter.php new file mode 100644 index 0000000000..1127aef8cf --- /dev/null +++ b/backend/app/Services/Infrastructure/HtmlPurifier/LiquidTokenUriFilter.php @@ -0,0 +1,41 @@ + ' ', + '%7C' => '|', + '%7c' => '|', + ]; + + public $name = 'LiquidToken'; + + public $post = true; + + /** + * @param HTMLPurifier_URI $uri + */ + public function filter(&$uri, $config, $context): bool + { + foreach (['path', 'query', 'fragment'] as $component) { + if ($uri->$component === null) { + continue; + } + + $uri->$component = preg_replace_callback( + self::ENCODED_TOKEN, + static fn (array $matches): string => '{{'.strtr($matches[1], self::DECODABLE).'}}', + $uri->$component, + ); + } + + return true; + } +} diff --git a/backend/tests/Feature/Services/Application/Handlers/EmailTemplate/EmailTemplateBodyPurificationTest.php b/backend/tests/Feature/Services/Application/Handlers/EmailTemplate/EmailTemplateBodyPurificationTest.php new file mode 100644 index 0000000000..86a93027f0 --- /dev/null +++ b/backend/tests/Feature/Services/Application/Handlers/EmailTemplate/EmailTemplateBodyPurificationTest.php @@ -0,0 +1,97 @@ +Hi {{ order.first_name }}

' + .'

View your order

' + .'

{{ \'\' }}

'; + + private int $accountId; + + protected function setUp(): void + { + parent::setUp(); + + $user = User::factory()->withAccount()->create(); + $this->accountId = $user->accounts()->first()->id; + } + + public function test_creating_a_template_keeps_liquid_tokens_in_links(): void + { + $template = $this->app->make(CreateEmailTemplateHandler::class)->handle($this->dto()); + + $stored = DB::table('email_templates')->where('id', $template->getId())->value('body'); + + $this->assertStringContainsString('href="https://example.com/orders?ref={{ order.number }}"', $stored); + $this->assertStringNotContainsString('%7B%7B', $stored); + } + + public function test_creating_a_template_still_strips_markup_smuggled_through_a_token(): void + { + $template = $this->app->make(CreateEmailTemplateHandler::class)->handle($this->dto()); + + $stored = DB::table('email_templates')->where('id', $template->getId())->value('body'); + + $this->assertStringNotContainsString('onerror', $stored); + } + + public function test_updating_a_template_keeps_liquid_tokens_in_links(): void + { + $created = $this->app->make(CreateEmailTemplateHandler::class)->handle($this->dto()); + + $updated = $this->app->make(UpdateEmailTemplateHandler::class)->handle($this->dto($created->getId())); + + $stored = DB::table('email_templates')->where('id', $updated->getId())->value('body'); + + $this->assertStringContainsString('href="https://example.com/orders?ref={{ order.number }}"', $stored); + $this->assertStringNotContainsString('onerror', $stored); + } + + public function test_a_stored_token_resolves_to_a_real_value_when_rendered(): void + { + $template = $this->app->make(CreateEmailTemplateHandler::class)->handle($this->dto()); + + $stored = (string) DB::table('email_templates')->where('id', $template->getId())->value('body'); + + $rendered = $this->app->make(EmailTemplateService::class)->previewTemplate( + 'Your order', + $stored, + EmailTemplateType::ORDER_CONFIRMATION, + )['body']; + + $this->assertMatchesRegularExpression( + '/href="https:\/\/example\.com\/orders\?ref=[^"{%]+"/', + $rendered, + ); + $this->assertStringNotContainsString('{{', $rendered); + $this->assertStringNotContainsString('onerror', $rendered); + } + + private function dto(?int $id = null): UpsertEmailTemplateDTO + { + return new UpsertEmailTemplateDTO( + account_id: $this->accountId, + template_type: EmailTemplateType::ORDER_CONFIRMATION, + subject: 'Your order', + body: self::BODY, + id: $id, + cta: ['label' => 'View order', 'url_token' => 'order.url'], + ); + } +} diff --git a/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php b/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php index 34c807a820..6de75445ba 100644 --- a/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php +++ b/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Services\Infrastructure\HtmlPurifier; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; class HtmlPurifierServiceTest extends TestCase @@ -39,40 +40,120 @@ public function test_scripts_are_still_removed(): void $this->assertStringNotContainsString('alert', (string) $this->service->purify('Hi')); } - public function test_null_is_preserved(): void + #[DataProvider('liquidTokenInUriProvider')] + public function test_liquid_tokens_survive_purification_inside_uris(string $html, string $expectedHref): void { - $this->assertNull($this->service->purify(null)); + $purified = (string) $this->service->purify($html); + + $this->assertStringContainsString($expectedHref, $purified); + $this->assertStringNotContainsString('%7B%7B', $purified); } - public function test_purify_preserving_liquid_keeps_tokens_in_hrefs(): void + public static function liquidTokenInUriProvider(): array { - $html = '

View order

'; + return [ + 'query string' => [ + 'View', + 'href="https://example.com/orders?ref={{ order.number }}"', + ], + 'path segment' => [ + 'View', + 'href="https://example.com/{{ event.slug }}/tickets"', + ], + 'fragment' => [ + 'View', + 'href="https://example.com/orders#{{ order.id }}"', + ], + 'entire href' => [ + 'View', + 'href="{{ order.url }}"', + ], + 'token with filter' => [ + 'View', + 'href="https://example.com/{{ event.slug | downcase }}"', + ], + 'multiple tokens' => [ + 'View', + 'href="https://example.com/o?ref={{ order.number }}&t={{ order.id }}"', + ], + 'token without surrounding spaces' => [ + 'View', + 'href="https://example.com/o?ref={{order.number}}"', + ], + ]; + } - $result = $this->service->purifyPreservingLiquid($html); + public function test_liquid_tokens_in_text_nodes_are_untouched(): void + { + $html = '

Hi {{ order.first_name }}

{% if order.is_payment_required %}Due{% endif %}

'; - $this->assertStringContainsString('{{ order.number }}', $result); - $this->assertStringNotContainsString('%7B%7B', $result); - $this->assertStringNotContainsString('LIQUIDTOKEN', $result); + $this->assertSame($html, $this->service->purify($html)); } - public function test_default_purify_still_encodes_braces_in_hrefs(): void + public function test_liquid_href_still_gets_nofollow_and_target_blank(): void { - $html = '

View order

'; + $purified = (string) $this->service->purify('x'); - $result = $this->service->purify($html); + $this->assertStringContainsString('{{ order.number }}', $purified); + $this->assertStringContainsString('rel="nofollow', $purified); + $this->assertStringContainsString('target="_blank"', $purified); + } + + #[DataProvider('hostileLiquidTokenProvider')] + public function test_liquid_tokens_cannot_smuggle_markup_past_the_purifier(string $html, string $mustNotContain): void + { + $purified = (string) $this->service->purify($html); - $this->assertStringNotContainsString('{{ order.number }}', (string) $result); - $this->assertStringContainsString('%7B%7B', (string) $result); + $this->assertStringNotContainsString($mustNotContain, $purified); } - public function test_purify_preserving_liquid_keeps_tag_tokens(): void + public static function hostileLiquidTokenProvider(): array { - $html = '

{% if order.is_paid %}Paid{% endif %}

'; + return [ + 'script tag in a string literal' => [ + '

{{ \'\' }}

', + ' [ + '

{{ "" }}

', + 'onerror', + ], + 'attribute breakout' => [ + 'hi', + 'onmouseover', + ], + 'quote smuggled inside a token' => [ + 'x', + 'onmouseover', + ], + 'javascript scheme inside a token' => [ + 'x', + 'javascript:', + ], + ]; + } - $result = $this->service->purifyPreservingLiquid($html); + #[DataProvider('unrelatedMarkupProvider')] + public function test_purification_of_non_liquid_markup_is_unchanged(string $html, string $expected): void + { + $this->assertSame($expected, $this->service->purify($html)); + } - $this->assertStringContainsString('{% if order.is_paid %}', $result); - $this->assertStringContainsString('{{ event.id }}', $result); - $this->assertStringContainsString('{% endif %}', $result); + public static function unrelatedMarkupProvider(): array + { + return [ + 'relative link' => ['L', 'L'], + 'mailto' => ['M', 'M'], + 'image' => ['x', 'x'], + 'javascript scheme is stripped' => ['x', 'x'], + 'data uri is stripped' => ['x', ''], + 'event handler is stripped' => ['x', 'x'], + 'encoded braces in text are literal' => ['

%7B%7Bfoo%7D%7D

', '

%7B%7Bfoo%7D%7D

'], + ]; + } + + public function test_null_is_preserved(): void + { + $this->assertNull($this->service->purify(null)); } } From 23dcd92f955c2860e0ce532dbc343cd372016f0b Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Wed, 9 Sep 2026 22:26:43 +0100 Subject: [PATCH 4/4] Decode Liquid tokens after purify instead of via a custom URI definition --- .../HtmlPurifier/HtmlPurifierService.php | 18 ++++---- .../HtmlPurifier/LiquidTokenUriFilter.php | 41 ------------------- .../HtmlPurifier/HtmlPurifierServiceTest.php | 16 +++++++- 3 files changed, 26 insertions(+), 49 deletions(-) delete mode 100644 backend/app/Services/Infrastructure/HtmlPurifier/LiquidTokenUriFilter.php diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php index 250672f751..48ef14a1f7 100644 --- a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php +++ b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php @@ -8,9 +8,13 @@ class HtmlPurifierService { - private const URI_DEFINITION_ID = 'hievents.uri'; + private const ENCODED_LIQUID_TOKEN = '/%7B%7B((?:%20|%7C|[A-Za-z0-9_.\-])*)%7D%7D/i'; - private const URI_DEFINITION_REV = 1; + private const DECODABLE = [ + '%20' => ' ', + '%7C' => '|', + '%7c' => '|', + ]; private HTMLPurifier_Config $config; @@ -24,10 +28,6 @@ public function __construct(private readonly HTMLPurifier $htmlPurifier) $this->config->set('Cache.SerializerPath', $cachePath); $this->config->set('HTML.Nofollow', true); $this->config->set('HTML.TargetBlank', true); - $this->config->set('URI.DefinitionID', self::URI_DEFINITION_ID); - $this->config->set('URI.DefinitionRev', self::URI_DEFINITION_REV); - - $this->config->maybeGetRawURIDefinition()?->addFilter(new LiquidTokenUriFilter, $this->config); } public function purify(?string $html): ?string @@ -36,6 +36,10 @@ public function purify(?string $html): ?string return null; } - return $this->htmlPurifier->purify($html, $this->config); + return preg_replace_callback( + self::ENCODED_LIQUID_TOKEN, + static fn (array $matches): string => '{{'.strtr($matches[1], self::DECODABLE).'}}', + $this->htmlPurifier->purify($html, $this->config), + ); } } diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/LiquidTokenUriFilter.php b/backend/app/Services/Infrastructure/HtmlPurifier/LiquidTokenUriFilter.php deleted file mode 100644 index 1127aef8cf..0000000000 --- a/backend/app/Services/Infrastructure/HtmlPurifier/LiquidTokenUriFilter.php +++ /dev/null @@ -1,41 +0,0 @@ - ' ', - '%7C' => '|', - '%7c' => '|', - ]; - - public $name = 'LiquidToken'; - - public $post = true; - - /** - * @param HTMLPurifier_URI $uri - */ - public function filter(&$uri, $config, $context): bool - { - foreach (['path', 'query', 'fragment'] as $component) { - if ($uri->$component === null) { - continue; - } - - $uri->$component = preg_replace_callback( - self::ENCODED_TOKEN, - static fn (array $matches): string => '{{'.strtr($matches[1], self::DECODABLE).'}}', - $uri->$component, - ); - } - - return true; - } -} diff --git a/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php b/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php index 6de75445ba..143bdeb471 100644 --- a/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php +++ b/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php @@ -148,10 +148,24 @@ public static function unrelatedMarkupProvider(): array 'javascript scheme is stripped' => ['x', 'x'], 'data uri is stripped' => ['x', ''], 'event handler is stripped' => ['x', 'x'], - 'encoded braces in text are literal' => ['

%7B%7Bfoo%7D%7D

', '

%7B%7Bfoo%7D%7D

'], ]; } + public function test_percent_encoded_braces_typed_by_hand_also_decode(): void + { + $this->assertSame('

{{foo}}

', $this->service->purify('

%7B%7Bfoo%7D%7D

')); + } + + public function test_decoding_can_only_emit_characters_that_are_inert_in_markup(): void + { + $purified = (string) $this->service->purify( + 'x' + ); + + $this->assertStringNotContainsString('assertStringNotContainsString('onerror=', $purified); + } + public function test_null_is_preserved(): void { $this->assertNull($this->service->purify(null));