Skip to content
Open
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
15 changes: 15 additions & 0 deletions config/cachet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())],
];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())],
];
}

Expand Down
27 changes: 27 additions & 0 deletions src/Enums/IncidentTemplateEngineEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<self>
*/
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
11 changes: 11 additions & 0 deletions src/Renderers/BladeRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
27 changes: 27 additions & 0 deletions src/Renderers/TwigRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
*/
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<string>
*/
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);
Expand Down
31 changes: 31 additions & 0 deletions tests/Feature/Api/IncidentTemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 2 additions & 0 deletions tests/Unit/Actions/Incident/CreateIncidentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}',
Expand Down
26 changes: 26 additions & 0 deletions tests/Unit/Models/IncidentTemplateTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Cachet\Models\IncidentTemplate;
use Twig\Sandbox\SecurityError;

it('can fetch data', function () {
$template = IncidentTemplate::factory()->create([
Expand Down Expand Up @@ -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 }}!',
]);
Expand All @@ -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' %}"],
]);
Loading