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' => [
+ '{{ \'\' }}
',
+ '