From 573fefc8bfbbd1609a53ec9ee0d6c707f575f3e4 Mon Sep 17 00:00:00 2001 From: Jan Kahmen <36455663+kah-ja@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:46:00 +0200 Subject: [PATCH 1/2] Sandbox incident template rendering Twig incident templates were rendered by a bare Environment, so a template body could reach any PHP function through the filters that take a callable. Register SandboxExtension with a SecurityPolicy that allows only the tags and filters a status update needs. Blade bodies compile to PHP and Blade has no sandbox, so rendering them is now opt in through cachet.incident_templates.allow_blade. While the option is off, the engine fails validation on create and update, is disabled in the form, and BladeRenderer refuses to render. --- config/cachet.php | 14 +++++++++ .../CreateIncidentTemplateRequestData.php | 2 +- .../UpdateIncidentTemplateRequestData.php | 2 +- src/Enums/IncidentTemplateEngineEnum.php | 23 ++++++++++++++ .../IncidentTemplateResource.php | 1 + src/Renderers/BladeRenderer.php | 12 +++++++ src/Renderers/TwigRenderer.php | 27 ++++++++++++++++ tests/Feature/Api/IncidentTemplateTest.php | 31 +++++++++++++++++++ .../Actions/Incident/CreateIncidentTest.php | 2 ++ tests/Unit/Models/IncidentTemplateTest.php | 26 ++++++++++++++++ 10 files changed, 138 insertions(+), 2 deletions(-) diff --git a/config/cachet.php b/config/cachet.php index 3d429c0c..7566ee2a 100644 --- a/config/cachet.php +++ b/config/cachet.php @@ -267,6 +267,20 @@ */ 'demo_mode' => env('CACHET_DEMO_MODE', false), + /* + |-------------------------------------------------------------------------- + | Cachet Incident Templates + |-------------------------------------------------------------------------- + | + | Twig template bodies are rendered inside a sandbox. Blade template + | bodies are compiled to PHP and run with the permissions of the + | application, so they are only rendered when enabled here. + | + */ + 'incident_templates' => [ + 'allow_blade' => env('CACHET_ALLOW_BLADE_TEMPLATES', false), + ], + /* |-------------------------------------------------------------------------- | Cachet Blog Feed diff --git a/src/Data/Requests/IncidentTemplate/CreateIncidentTemplateRequestData.php b/src/Data/Requests/IncidentTemplate/CreateIncidentTemplateRequestData.php index 598044e7..9d943fbb 100644 --- a/src/Data/Requests/IncidentTemplate/CreateIncidentTemplateRequestData.php +++ b/src/Data/Requests/IncidentTemplate/CreateIncidentTemplateRequestData.php @@ -27,7 +27,7 @@ public static function rules(ValidationContext $context): array 'name' => ['required', 'string', 'max:255'], 'slug' => ['string'], 'template' => ['required', 'string'], - 'engine' => [Rule::enum(IncidentTemplateEngineEnum::class)], + 'engine' => [Rule::enum(IncidentTemplateEngineEnum::class)->only(IncidentTemplateEngineEnum::available())], ]; } diff --git a/src/Data/Requests/IncidentTemplate/UpdateIncidentTemplateRequestData.php b/src/Data/Requests/IncidentTemplate/UpdateIncidentTemplateRequestData.php index 79add9cb..0ab8a6ed 100644 --- a/src/Data/Requests/IncidentTemplate/UpdateIncidentTemplateRequestData.php +++ b/src/Data/Requests/IncidentTemplate/UpdateIncidentTemplateRequestData.php @@ -23,7 +23,7 @@ public static function rules(ValidationContext $context): array 'name' => ['string', 'max:255'], 'slug' => ['string'], 'template' => ['string'], - 'engine' => [Rule::enum(IncidentTemplateEngineEnum::class)], + 'engine' => [Rule::enum(IncidentTemplateEngineEnum::class)->only(IncidentTemplateEngineEnum::available())], ]; } diff --git a/src/Enums/IncidentTemplateEngineEnum.php b/src/Enums/IncidentTemplateEngineEnum.php index 44d6a01c..d7b77f28 100644 --- a/src/Enums/IncidentTemplateEngineEnum.php +++ b/src/Enums/IncidentTemplateEngineEnum.php @@ -12,6 +12,29 @@ enum IncidentTemplateEngineEnum: string implements HasColor, HasIcon, HasLabel case blade = 'blade'; case twig = 'twig'; + /** + * The engines that may be selected for a template. + * + * Blade template bodies compile to PHP, so they are only available when an + * installation opts in through cachet.incident_templates.allow_blade. + * + * @return list + */ + public static function available(): array + { + return config('cachet.incident_templates.allow_blade') + ? self::cases() + : [self::twig]; + } + + /** + * Determine if the engine may be selected for a template. + */ + public function isAvailable(): bool + { + return in_array($this, self::available(), strict: true); + } + public function getColor(): array { return match ($this) { diff --git a/src/Filament/Resources/IncidentTemplates/IncidentTemplateResource.php b/src/Filament/Resources/IncidentTemplates/IncidentTemplateResource.php index c004a3ba..4abdd1a5 100644 --- a/src/Filament/Resources/IncidentTemplates/IncidentTemplateResource.php +++ b/src/Filament/Resources/IncidentTemplates/IncidentTemplateResource.php @@ -57,6 +57,7 @@ public static function form(Schema $schema): Schema Select::make('engine') ->label(__('cachet::incident_template.form.engine_label')) ->options(IncidentTemplateEngineEnum::class) + ->disableOptionWhen(fn (string $value): bool => ! IncidentTemplateEngineEnum::from($value)->isAvailable()) ->default(IncidentTemplateEngineEnum::twig) ->live() ->required(), diff --git a/src/Renderers/BladeRenderer.php b/src/Renderers/BladeRenderer.php index 83867089..6bbda384 100644 --- a/src/Renderers/BladeRenderer.php +++ b/src/Renderers/BladeRenderer.php @@ -2,15 +2,27 @@ namespace Cachet\Renderers; +use Cachet\Enums\IncidentTemplateEngineEnum; use Illuminate\Support\Facades\Blade; +use RuntimeException; class BladeRenderer implements Renderer { /** * Render the template using Laravel Blade. + * + * Blade compiles echo tags and directives to PHP, so a template body is + * executable code. Rendering is therefore limited to installations that + * opt in through the cachet.incident_templates.allow_blade config value. + * + * @throws RuntimeException */ public function render(string $template, array $variables = []): string { + if (! IncidentTemplateEngineEnum::blade->isAvailable()) { + throw new RuntimeException('Blade incident templates are disabled. Set CACHET_ALLOW_BLADE_TEMPLATES=true to render them.'); + } + return Blade::render($template, $variables, deleteCachedView: true); } } diff --git a/src/Renderers/TwigRenderer.php b/src/Renderers/TwigRenderer.php index 428a8069..96c09e52 100644 --- a/src/Renderers/TwigRenderer.php +++ b/src/Renderers/TwigRenderer.php @@ -3,16 +3,43 @@ namespace Cachet\Renderers; use Twig\Environment; +use Twig\Extension\SandboxExtension; use Twig\Loader\ArrayLoader; +use Twig\Sandbox\SecurityPolicy; class TwigRenderer implements Renderer { + /** + * The Twig tags an incident template may use. + * + * @var list + */ + private const ALLOWED_TAGS = ['apply', 'for', 'if', 'set', 'verbatim', 'with']; + + /** + * The Twig filters an incident template may use. Filters that take a callable + * (map, filter, reduce, sort) are omitted, because a template can pass the + * name of any PHP function to them. + * + * @var list + */ + private const ALLOWED_FILTERS = [ + 'abs', 'capitalize', 'date', 'default', 'e', 'escape', 'first', 'format', 'join', 'keys', + 'last', 'length', 'lower', 'nl2br', 'number_format', 'raw', 'replace', 'reverse', 'round', + 'slice', 'split', 'striptags', 'title', 'trim', 'upper', 'url_encode', + ]; + public function __construct() {} public function render(string $template, array $variables = []): string { $env = new Environment(new ArrayLoader([])); + $env->addExtension(new SandboxExtension( + new SecurityPolicy(self::ALLOWED_TAGS, self::ALLOWED_FILTERS), + sandboxed: true, + )); + $template = $env->createTemplate($template); return $template->render($variables); diff --git a/tests/Feature/Api/IncidentTemplateTest.php b/tests/Feature/Api/IncidentTemplateTest.php index febc7676..4b2ffed0 100644 --- a/tests/Feature/Api/IncidentTemplateTest.php +++ b/tests/Feature/Api/IncidentTemplateTest.php @@ -91,6 +91,37 @@ ]); }); +it('cannot create an incident template with the blade engine when blade is not allowed', function () { + config()->set('cachet.incident_templates.allow_blade', false); + + Sanctum::actingAs(User::factory()->create(), ['incident-templates.manage']); + + $response = postJson('/status/api/incident-templates', [ + 'name' => 'New Template', + 'slug' => 'new-template', + 'template' => 'Hello {{ $name }}', + 'engine' => 'blade', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrorFor('engine'); +}); + +it('can create an incident template with the blade engine when blade is allowed', function () { + config()->set('cachet.incident_templates.allow_blade', true); + + Sanctum::actingAs(User::factory()->create(), ['incident-templates.manage']); + + $response = postJson('/status/api/incident-templates', [ + 'name' => 'New Template', + 'slug' => 'new-template', + 'template' => 'Hello {{ $name }}', + 'engine' => 'blade', + ]); + + $response->assertCreated(); +}); + it('cannot update an incident template when not authenticated', function () { $incidentTemplate = IncidentTemplate::factory()->create(); diff --git a/tests/Unit/Actions/Incident/CreateIncidentTest.php b/tests/Unit/Actions/Incident/CreateIncidentTest.php index e8e3c92c..1a7743a7 100644 --- a/tests/Unit/Actions/Incident/CreateIncidentTest.php +++ b/tests/Unit/Actions/Incident/CreateIncidentTest.php @@ -71,6 +71,8 @@ }); it('can create an incident with a blade template', function () { + config()->set('cachet.incident_templates.allow_blade', true); + $template = IncidentTemplate::factory()->blade()->create([ 'slug' => 'my-template', 'template' => 'This is a template: {{ $incident[\'name\'] }} foo: {{ $foo }}', diff --git a/tests/Unit/Models/IncidentTemplateTest.php b/tests/Unit/Models/IncidentTemplateTest.php index 2b0a2df8..a369e486 100644 --- a/tests/Unit/Models/IncidentTemplateTest.php +++ b/tests/Unit/Models/IncidentTemplateTest.php @@ -1,6 +1,7 @@ create([ @@ -28,6 +29,8 @@ }); it('can render a blade template', function () { + config()->set('cachet.incident_templates.allow_blade', true); + $template = IncidentTemplate::factory()->blade()->create([ 'template' => 'Hello, {{ $name }}!', ]); @@ -39,3 +42,26 @@ expect($output) ->toBe('Hello, James Brooks!'); }); + +it('does not render a blade template when blade is not allowed', function () { + config()->set('cachet.incident_templates.allow_blade', false); + + $template = IncidentTemplate::factory()->blade()->create([ + 'template' => 'Hello, {{ $name }}!', + ]); + + expect(fn () => $template->render(['name' => 'James Brooks'])) + ->toThrow(RuntimeException::class); +}); + +it('does not let a twig template call php functions', function (string $body) { + $template = IncidentTemplate::factory()->twig()->create([ + 'template' => $body, + ]); + + expect(fn () => $template->render(['name' => 'James Brooks'])) + ->toThrow(SecurityError::class); +})->with([ + 'callable filter' => ["{{ ['cachet']|map('strtoupper')|join }}"], + 'template include' => ["{% include 'another-template' %}"], +]); From 5e5439dae805f80e186a0c5c2629fdf66e28d312 Mon Sep 17 00:00:00 2001 From: Jan Kahmen <36455663+kah-ja@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:02:10 +0200 Subject: [PATCH 2/2] Move the Blade opt-in into a renderers config block Rename cachet.incident_templates.allow_blade to cachet.renderers.blade, so the config block is named after what it configures rather than after the one feature that uses those renderers. The env variable becomes CACHET_BLADE_RENDERER. Availability now lives on the engine itself: isAvailable() reads the config for its own renderer and available() derives the list from it. BladeRenderer reads the config value directly instead of asking the incident template enum. --- config/cachet.php | 13 ++++++------ src/Enums/IncidentTemplateEngineEnum.php | 20 +++++++++++-------- src/Renderers/BladeRenderer.php | 7 +++---- tests/Feature/Api/IncidentTemplateTest.php | 8 ++++---- .../Actions/Incident/CreateIncidentTest.php | 2 +- tests/Unit/Models/IncidentTemplateTest.php | 6 +++--- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/config/cachet.php b/config/cachet.php index 7566ee2a..ce620e1b 100644 --- a/config/cachet.php +++ b/config/cachet.php @@ -269,16 +269,17 @@ /* |-------------------------------------------------------------------------- - | Cachet Incident Templates + | Cachet Template Renderers |-------------------------------------------------------------------------- | - | Twig template bodies are rendered inside a sandbox. Blade template - | bodies are compiled to PHP and run with the permissions of the - | application, so they are only rendered when enabled here. + | Configure which renderers a template body may be rendered with. Twig + | bodies are rendered inside a sandbox. Blade bodies are compiled to + | PHP and run with the permissions of the application, so the Blade + | renderer is only used when an installation enables it here. | */ - 'incident_templates' => [ - 'allow_blade' => env('CACHET_ALLOW_BLADE_TEMPLATES', false), + 'renderers' => [ + 'blade' => env('CACHET_BLADE_RENDERER', false), ], /* diff --git a/src/Enums/IncidentTemplateEngineEnum.php b/src/Enums/IncidentTemplateEngineEnum.php index d7b77f28..4df2f2f1 100644 --- a/src/Enums/IncidentTemplateEngineEnum.php +++ b/src/Enums/IncidentTemplateEngineEnum.php @@ -13,26 +13,30 @@ enum IncidentTemplateEngineEnum: string implements HasColor, HasIcon, HasLabel case twig = 'twig'; /** - * The engines that may be selected for a template. - * - * Blade template bodies compile to PHP, so they are only available when an - * installation opts in through cachet.incident_templates.allow_blade. + * The engines whose renderer is available to a template. * * @return list */ public static function available(): array { - return config('cachet.incident_templates.allow_blade') - ? self::cases() - : [self::twig]; + return array_values(array_filter( + self::cases(), + fn (self $engine): bool => $engine->isAvailable(), + )); } /** * Determine if the engine may be selected for a template. + * + * Blade template bodies compile to PHP, so the Blade renderer is only + * available when an installation enables cachet.renderers.blade. */ public function isAvailable(): bool { - return in_array($this, self::available(), strict: true); + return match ($this) { + self::blade => (bool) config('cachet.renderers.blade'), + self::twig => true, + }; } public function getColor(): array diff --git a/src/Renderers/BladeRenderer.php b/src/Renderers/BladeRenderer.php index 6bbda384..8709bfb7 100644 --- a/src/Renderers/BladeRenderer.php +++ b/src/Renderers/BladeRenderer.php @@ -2,7 +2,6 @@ namespace Cachet\Renderers; -use Cachet\Enums\IncidentTemplateEngineEnum; use Illuminate\Support\Facades\Blade; use RuntimeException; @@ -13,14 +12,14 @@ class BladeRenderer implements Renderer * * Blade compiles echo tags and directives to PHP, so a template body is * executable code. Rendering is therefore limited to installations that - * opt in through the cachet.incident_templates.allow_blade config value. + * enable the Blade renderer through the cachet.renderers.blade config value. * * @throws RuntimeException */ public function render(string $template, array $variables = []): string { - if (! IncidentTemplateEngineEnum::blade->isAvailable()) { - throw new RuntimeException('Blade incident templates are disabled. Set CACHET_ALLOW_BLADE_TEMPLATES=true to render them.'); + if (! config('cachet.renderers.blade')) { + throw new RuntimeException('The Blade renderer is disabled. Set CACHET_BLADE_RENDERER=true to render Blade templates.'); } return Blade::render($template, $variables, deleteCachedView: true); diff --git a/tests/Feature/Api/IncidentTemplateTest.php b/tests/Feature/Api/IncidentTemplateTest.php index 4b2ffed0..943ad5a0 100644 --- a/tests/Feature/Api/IncidentTemplateTest.php +++ b/tests/Feature/Api/IncidentTemplateTest.php @@ -91,8 +91,8 @@ ]); }); -it('cannot create an incident template with the blade engine when blade is not allowed', function () { - config()->set('cachet.incident_templates.allow_blade', false); +it('cannot create an incident template with the blade engine when the blade renderer is disabled', function () { + config()->set('cachet.renderers.blade', false); Sanctum::actingAs(User::factory()->create(), ['incident-templates.manage']); @@ -107,8 +107,8 @@ $response->assertJsonValidationErrorFor('engine'); }); -it('can create an incident template with the blade engine when blade is allowed', function () { - config()->set('cachet.incident_templates.allow_blade', true); +it('can create an incident template with the blade engine when the blade renderer is enabled', function () { + config()->set('cachet.renderers.blade', true); Sanctum::actingAs(User::factory()->create(), ['incident-templates.manage']); diff --git a/tests/Unit/Actions/Incident/CreateIncidentTest.php b/tests/Unit/Actions/Incident/CreateIncidentTest.php index 1a7743a7..c6f02d44 100644 --- a/tests/Unit/Actions/Incident/CreateIncidentTest.php +++ b/tests/Unit/Actions/Incident/CreateIncidentTest.php @@ -71,7 +71,7 @@ }); it('can create an incident with a blade template', function () { - config()->set('cachet.incident_templates.allow_blade', true); + config()->set('cachet.renderers.blade', true); $template = IncidentTemplate::factory()->blade()->create([ 'slug' => 'my-template', diff --git a/tests/Unit/Models/IncidentTemplateTest.php b/tests/Unit/Models/IncidentTemplateTest.php index a369e486..7a650d50 100644 --- a/tests/Unit/Models/IncidentTemplateTest.php +++ b/tests/Unit/Models/IncidentTemplateTest.php @@ -29,7 +29,7 @@ }); it('can render a blade template', function () { - config()->set('cachet.incident_templates.allow_blade', true); + config()->set('cachet.renderers.blade', true); $template = IncidentTemplate::factory()->blade()->create([ 'template' => 'Hello, {{ $name }}!', @@ -43,8 +43,8 @@ ->toBe('Hello, James Brooks!'); }); -it('does not render a blade template when blade is not allowed', function () { - config()->set('cachet.incident_templates.allow_blade', false); +it('does not render a blade template when the blade renderer is disabled', function () { + config()->set('cachet.renderers.blade', false); $template = IncidentTemplate::factory()->blade()->create([ 'template' => 'Hello, {{ $name }}!',