diff --git a/config/cachet.php b/config/cachet.php index 3d429c0c..ce620e1b 100644 --- a/config/cachet.php +++ b/config/cachet.php @@ -267,6 +267,21 @@ */ 'demo_mode' => env('CACHET_DEMO_MODE', false), + /* + |-------------------------------------------------------------------------- + | Cachet Template Renderers + |-------------------------------------------------------------------------- + | + | 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. + | + */ + 'renderers' => [ + 'blade' => env('CACHET_BLADE_RENDERER', 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..4df2f2f1 100644 --- a/src/Enums/IncidentTemplateEngineEnum.php +++ b/src/Enums/IncidentTemplateEngineEnum.php @@ -12,6 +12,33 @@ enum IncidentTemplateEngineEnum: string implements HasColor, HasIcon, HasLabel case blade = 'blade'; case twig = 'twig'; + /** + * The engines whose renderer is available to a template. + * + * @return list + */ + public static function available(): array + { + 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 match ($this) { + self::blade => (bool) config('cachet.renderers.blade'), + self::twig => 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..8709bfb7 100644 --- a/src/Renderers/BladeRenderer.php +++ b/src/Renderers/BladeRenderer.php @@ -3,14 +3,25 @@ namespace Cachet\Renderers; 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 + * enable the Blade renderer through the cachet.renderers.blade config value. + * + * @throws RuntimeException */ public function render(string $template, array $variables = []): string { + 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/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..943ad5a0 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 the blade renderer is disabled', function () { + config()->set('cachet.renderers.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 the blade renderer is enabled', function () { + config()->set('cachet.renderers.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..c6f02d44 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.renderers.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..7a650d50 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.renderers.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 the blade renderer is disabled', function () { + config()->set('cachet.renderers.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' %}"], +]);