Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@

class HtmlPurifierService
{
private const ENCODED_LIQUID_TOKEN = '/%7B%7B((?:%20|%7C|[A-Za-z0-9_.\-])*)%7D%7D/i';

private const DECODABLE = [
'%20' => ' ',
'%7C' => '|',
'%7c' => '|',
];

private HTMLPurifier_Config $config;

public function __construct(private readonly HTMLPurifier $htmlPurifier)
Expand All @@ -28,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),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace Tests\Feature\Services\Application\Handlers\EmailTemplate;

use HiEvents\DomainObjects\Enums\EmailTemplateType;
use HiEvents\Models\User;
use HiEvents\Services\Application\Handlers\EmailTemplate\CreateEmailTemplateHandler;
use HiEvents\Services\Application\Handlers\EmailTemplate\DTO\UpsertEmailTemplateDTO;
use HiEvents\Services\Application\Handlers\EmailTemplate\UpdateEmailTemplateHandler;
use HiEvents\Services\Domain\Email\EmailTemplateService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class EmailTemplateBodyPurificationTest extends TestCase
{
use DatabaseTransactions;

private const BODY = '<p>Hi {{ order.first_name }}</p>'
.'<p><a href="https://example.com/orders?ref={{ order.number }}">View your order</a></p>'
.'<p>{{ \'<img src=x onerror=alert(1)>\' }}</p>';

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'],
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,6 +40,132 @@ public function test_scripts_are_still_removed(): void
$this->assertStringNotContainsString('alert', (string) $this->service->purify('<script>alert(1)</script>Hi'));
}

#[DataProvider('liquidTokenInUriProvider')]
public function test_liquid_tokens_survive_purification_inside_uris(string $html, string $expectedHref): void
{
$purified = (string) $this->service->purify($html);

$this->assertStringContainsString($expectedHref, $purified);
$this->assertStringNotContainsString('%7B%7B', $purified);
}

public static function liquidTokenInUriProvider(): array
{
return [
'query string' => [
'<a href="https://example.com/orders?ref={{ order.number }}">View</a>',
'href="https://example.com/orders?ref={{ order.number }}"',
],
'path segment' => [
'<a href="https://example.com/{{ event.slug }}/tickets">View</a>',
'href="https://example.com/{{ event.slug }}/tickets"',
],
'fragment' => [
'<a href="https://example.com/orders#{{ order.id }}">View</a>',
'href="https://example.com/orders#{{ order.id }}"',
],
'entire href' => [
'<a href="{{ order.url }}">View</a>',
'href="{{ order.url }}"',
],
'token with filter' => [
'<a href="https://example.com/{{ event.slug | downcase }}">View</a>',
'href="https://example.com/{{ event.slug | downcase }}"',
],
'multiple tokens' => [
'<a href="https://example.com/o?ref={{ order.number }}&t={{ order.id }}">View</a>',
'href="https://example.com/o?ref={{ order.number }}&amp;t={{ order.id }}"',
],
'token without surrounding spaces' => [
'<a href="https://example.com/o?ref={{order.number}}">View</a>',
'href="https://example.com/o?ref={{order.number}}"',
],
];
}

public function test_liquid_tokens_in_text_nodes_are_untouched(): void
{
$html = '<p>Hi {{ order.first_name }}</p><p>{% if order.is_payment_required %}Due{% endif %}</p>';

$this->assertSame($html, $this->service->purify($html));
}

public function test_liquid_href_still_gets_nofollow_and_target_blank(): void
{
$purified = (string) $this->service->purify('<a href="https://spam.example/?r={{ order.number }}">x</a>');

$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($mustNotContain, $purified);
}

public static function hostileLiquidTokenProvider(): array
{
return [
'script tag in a string literal' => [
'<p>{{ \'<script>alert(1)</script>\' }}</p>',
'<script',
],
'event handler in a string literal' => [
'<p>{{ "<img src=x onerror=alert(1)>" }}</p>',
'onerror',
],
'attribute breakout' => [
'<a href="{{ " onmouseover="alert(1) }}">hi</a>',
'onmouseover',
],
'quote smuggled inside a token' => [
'<a href="https://example.com/{{ order.a" onmouseover="alert(1) }}">x</a>',
'onmouseover',
],
'javascript scheme inside a token' => [
'<a href="{{ javascript:alert(1) }}">x</a>',
'javascript:',
],
];
}

#[DataProvider('unrelatedMarkupProvider')]
public function test_purification_of_non_liquid_markup_is_unchanged(string $html, string $expected): void
{
$this->assertSame($expected, $this->service->purify($html));
}

public static function unrelatedMarkupProvider(): array
{
return [
'relative link' => ['<a href="/manage/orders">L</a>', '<a href="/manage/orders">L</a>'],
'mailto' => ['<a href="mailto:a@b.com">M</a>', '<a href="mailto:a@b.com">M</a>'],
'image' => ['<img src="https://example.com/i.png" alt="x">', '<img src="https://example.com/i.png" alt="x" />'],
'javascript scheme is stripped' => ['<a href="javascript:alert(1)">x</a>', '<a>x</a>'],
'data uri is stripped' => ['<img src="data:text/html;base64,PHNjcmlwdD4=" alt="x">', ''],
'event handler is stripped' => ['<img src="x" onerror="alert(1)" alt="x">', '<img src="x" alt="x" />'],
];
}

public function test_percent_encoded_braces_typed_by_hand_also_decode(): void
{
$this->assertSame('<p>{{foo}}</p>', $this->service->purify('<p>%7B%7Bfoo%7D%7D</p>'));
}

public function test_decoding_can_only_emit_characters_that_are_inert_in_markup(): void
{
$purified = (string) $this->service->purify(
'<a href="https://example.com/?a=%7B%7B%22onerror%3D%3Cscript%3E%7D%7D">x</a>'
);

$this->assertStringNotContainsString('<script', $purified);
$this->assertStringNotContainsString('onerror=', $purified);
}

public function test_null_is_preserved(): void
{
$this->assertNull($this->service->purify(null));
Expand Down
Loading