From f17a3c3c2447aad9d3adc8ba7707bc17fa2becf9 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 4 Nov 2026 15:12:48 -0600 Subject: [PATCH 1/3] Document the 1.2.15 Twig sandbox posture and drop Safe mode Theme templates, plugin views and module views are code written by a developer, and as of 1.2.15 they are not sandboxed. The one surface in core whose author is not a developer is mail, so mail content is where the sandbox stays. - `plugin/registration.md` gains "Rendering user-supplied templates": the `MarkupManager::makeSandboxedTwigEnvironment()` factory, what the allowlist permits, how to widen it, and the contract a caller follows - pass only the values you are willing to expose. The "Extending Twig" section now says markup tags are deliberately unavailable inside a sandbox, so registering one cannot widen somebody else's. - `cms/themes.md` says plainly that the PHP code section and the Twig markup around it reach the same application, that the permissions involved are developer-level, and where to go if the template's author is not. - `services/mail.md` says all mail content renders sandboxed, including mail views a plugin ships, and what that rules out. - `setup/configuration.md` loses the `cms.enableSafeMode` section: Safe mode is removed in 1.2.15, having never been a boundary between the permission it gated and the permissions its holder already had. --- cms/themes.md | 2 ++ plugin/registration.md | 35 +++++++++++++++++++++++++++++++++++ services/mail.md | 2 ++ setup/configuration.md | 7 ------- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/cms/themes.md b/cms/themes.md index 5ff675b5..57075265 100644 --- a/cms/themes.md +++ b/cms/themes.md @@ -125,6 +125,8 @@ parameter = "value" The code in the PHP section executes every time before the template is rendered. The PHP section is optional for all CMS templates and its contents depend on the template type where it is defined. The PHP code section can contain optional open and close PHP tags to enable syntax highlighting in text editors. The open and close tags should always be specified on a different line to the section separator `==`. +> **IMPORTANT:** A CMS template is code, and Winter treats it as code. Editing a page, layout or partial requires `cms.manage_pages`, `cms.manage_layouts` or `cms.manage_partials`, which also grant direct access to the theme's files and to this PHP section, and which belong to the Developer role. As of v1.2.15 theme templates are not rendered in a Twig sandbox and are not intended to be: their Twig markup reaches the same application as their PHP section does. Grant these permissions only to the people you would trust with the source code. If you need to render a template that someone *without* those permissions wrote, use [a sandboxed Twig environment](../plugin/registration#rendering-user-supplied-templates) instead. + ```twig url = "/blog" layout = "default" diff --git a/plugin/registration.md b/plugin/registration.md index 97f542e3..e5e353c0 100644 --- a/plugin/registration.md +++ b/plugin/registration.md @@ -336,6 +336,41 @@ The following Twig custom options are available: | `deprecated` | boolean | `false` | if true marks the current filter as being deprecated (usually used with `alternative` to provide an alternative option | | `alternative` | string | `''` | if `deprecated` is true, provides a recommended alternative filter to use instead. | +> **NOTE:** Markup tags registered this way are available to theme templates and to your own views. They are deliberately *not* available inside a [sandboxed Twig environment](#rendering-user-supplied-templates), so registering a markup tag can never widen somebody else's sandbox. + +## Rendering user-supplied templates + +Theme templates, plugin views and module views are code: they are written by a developer and they are not sandboxed. If your plugin renders Twig that one of *its users* wrote — a notification body edited in a form field, a document template stored against a record, anything typed into a `codeeditor` — ask for a sandboxed environment instead of the default one: + +```php +use System\Classes\MarkupManager; + +$twig = MarkupManager::makeSandboxedTwigEnvironment(); + +$html = $twig->createTemplate($userEditedTemplate)->render([ + 'order' => $order, +]); +``` + +The sandbox is an allowlist (`System\Twig\SandboxPolicy`). A sandboxed template may use the tags, filters, functions and tests the policy names, read the values you passed to `render()`, and nothing else. It cannot call a function you registered with `registerMarkupTags`, pull in another template with `extends`, `include`, `import` or `use`, or call a method on an object you passed in beyond `__toString()` and `count()`. On a Winter model it can read the attributes and relations the model exposes, and not the raw `attributes` bag, anything the model lists in `$hidden`, or any of the model's own methods. + +So the contract is a single rule: **pass only the values you are willing to expose.** What you pass is readable; nothing else is reachable. + +Widen the allowlist when your own templates legitimately need more than the baseline: + +```php +$twig = MarkupManager::makeSandboxedTwigEnvironment([ + 'filters' => ['my_plugin_filter'], + 'methods' => [\Acme\Shop\Models\Order::class => ['subtotal']], +]); +``` + +The accepted keys are `tags`, `filters`, `functions`, `tests` and `methods`; any other key throws rather than being silently ignored. Each one adds to the baseline, so widening filters leaves the rest of the policy where it was. + +Two things to know before you widen it. A filter or function you allow is allowed with whatever arguments the template chooses, so do not allow one that takes a callable, a file path or a template name. A method you allow is allowed on every instance of that class that reaches the template, including instances you did not pass in yourself. + +In Winter's own code the one sandboxed surface is mail: mail templates, layouts and partials are edited in the backend under `system.manage_mail_templates`, which grants no file access and no ability to author PHP, so all mail content renders through this policy. As of v1.2.15 that includes mail views your plugin ships, so a mail view that used `str_*`, `array_*`, `url_*`, `html_*` or `form_*`, or `{% include %}`, needs the value prepared in PHP and passed in instead. + ## Navigation menus Plugins can extend the backend navigation menus by overriding the `registerNavigation` method of the [Plugin registration class](#registration-file). This section shows you how to add menu items to the backend navigation area. An example of registering a top-level navigation menu item with two sub-menu items: diff --git a/services/mail.md b/services/mail.md index 98dbe2eb..07c5ca20 100644 --- a/services/mail.md +++ b/services/mail.md @@ -300,6 +300,8 @@ Mail::laterOn('queue-name', 5, 'acme.blog::mail.welcome', $data, function ($mess Mail messages can be sent in Winter using either mail views or mail templates. A mail view is supplied by the application or plugin in the file system in the **/views** directory. Whereas a mail template is managed using the backend interface via *System > Mail templates*. All mail messages support using Twig for markup. +> **IMPORTANT:** As of v1.2.15 all mail content is rendered in a [sandboxed Twig environment](../plugin/registration#rendering-user-supplied-templates), because a mail template can be edited in the backend by someone holding only `system.manage_mail_templates` — a permission that grants no file access and no ability to author PHP. A mail view or template may use the tags, filters and functions `System\Twig\SandboxPolicy` allows and read the data the message was given; it may not call a markup tag a plugin registered, pull in another template with `{% include %}` or `{% extends %}`, or call a method on an object passed into it. Prepare anything else in PHP and pass the result in as data. + Optionally, mail views can be [registered in the Plugin registration file](#registering-mail-layouts-templates-and-partials) with the `registerMailTemplates` method. This will automatically generate a mail template and allows them to be customized using the backend interface. ### Mail views diff --git a/setup/configuration.md b/setup/configuration.md index 8c334600..faf82613 100644 --- a/setup/configuration.md +++ b/setup/configuration.md @@ -201,16 +201,9 @@ The following features are enabled when debug mode is enabled: 1. [Detailed error pages](../cms/pages#error-page) are displayed. 1. Failed user authentication provides a specific reason. 1. [Combined assets](/docs/v1.2/markup/filters/theme) are not minified by default. -1. [Safe mode](#safe-mode) is disabled by default. > **IMPORTANT**: Always set the `app.debug` setting to `false` for production environments. -### Safe mode - -The safe mode setting is found in the `config/cms.php` configuration file with the `enableSafeMode` parameter. The default value is `null`. - -If safe mode is enabled, editing the PHP code section is disabled in CMS templates for security reasons. If set to `null`, safe mode is on when [debug mode](#debug-mode) is disabled. - ### CSRF protection Winter provides an easy method of protecting your application from [cross-site request forgeries](https://en.wikipedia.org/wiki/Cross-site_request_forgery). When enabled, a random token is created in your user sessions, that is then included when the [opening form tag is used](../services/html#form-tokens) or when an [AJAX request](../ajax/introduction.md) is made. This token is then validated server-side to ensure that the request is made by the correct user. From 5a4dbf54749b41a046274fadee245d9feb2a5bf2 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 4 Nov 2026 15:49:45 -0600 Subject: [PATCH 2/3] Correct which half of mail is sandboxed Both pages said all mail content renders sandboxed. Only the half a backend user wrote does: a mail template, layout or partial stored in the database. A mail view is a file in a repository and is not sandboxed, so a plugin's own mail view keeps working exactly as it did and the upgrade note about `str_*` and `{% include %}` in a mail view does not apply. Both pages now also carry the one exception, because plugin authors will hit it: a mail layout is always rendered sandboxed, since a layout row is copied out of its view file once and edited in place from then on, leaving nothing to say whether it still matches. --- plugin/registration.md | 2 +- services/mail.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugin/registration.md b/plugin/registration.md index e5e353c0..5589babf 100644 --- a/plugin/registration.md +++ b/plugin/registration.md @@ -369,7 +369,7 @@ The accepted keys are `tags`, `filters`, `functions`, `tests` and `methods`; any Two things to know before you widen it. A filter or function you allow is allowed with whatever arguments the template chooses, so do not allow one that takes a callable, a file path or a template name. A method you allow is allowed on every instance of that class that reaches the template, including instances you did not pass in yourself. -In Winter's own code the one sandboxed surface is mail: mail templates, layouts and partials are edited in the backend under `system.manage_mail_templates`, which grants no file access and no ability to author PHP, so all mail content renders through this policy. As of v1.2.15 that includes mail views your plugin ships, so a mail view that used `str_*`, `array_*`, `url_*`, `html_*` or `form_*`, or `{% include %}`, needs the value prepared in PHP and passed in instead. +In Winter's own code the one sandboxed surface is mail content that a backend user wrote — a mail template, layout or partial stored in the database and edited under `system.manage_mail_templates`, a permission that grants no file access and no ability to author PHP. [Mail views](../services/mail#message-content) your plugin *ships* are files in your repository, so they are not sandboxed and may use anything a view may use. The one exception is a mail **layout**, which is always rendered sandboxed because a layout row is copied out of its view file once and edited in place from then on, so write a layout to the baseline above even when you ship it as a view. ## Navigation menus diff --git a/services/mail.md b/services/mail.md index 07c5ca20..d2924a09 100644 --- a/services/mail.md +++ b/services/mail.md @@ -300,7 +300,9 @@ Mail::laterOn('queue-name', 5, 'acme.blog::mail.welcome', $data, function ($mess Mail messages can be sent in Winter using either mail views or mail templates. A mail view is supplied by the application or plugin in the file system in the **/views** directory. Whereas a mail template is managed using the backend interface via *System > Mail templates*. All mail messages support using Twig for markup. -> **IMPORTANT:** As of v1.2.15 all mail content is rendered in a [sandboxed Twig environment](../plugin/registration#rendering-user-supplied-templates), because a mail template can be edited in the backend by someone holding only `system.manage_mail_templates` — a permission that grants no file access and no ability to author PHP. A mail view or template may use the tags, filters and functions `System\Twig\SandboxPolicy` allows and read the data the message was given; it may not call a markup tag a plugin registered, pull in another template with `{% include %}` or `{% extends %}`, or call a method on an object passed into it. Prepare anything else in PHP and pass the result in as data. +> **IMPORTANT:** As of v1.2.15 mail content is rendered in one of two Twig environments, depending on where the content came from. A **mail view** is a file in your repository, so it renders unsandboxed like any other view and nothing about it has changed. A mail template, layout or partial **stored in the database** was written in the backend by someone who may hold only `system.manage_mail_templates` — a permission that grants no file access and no ability to author PHP — so it renders in a [sandboxed environment](../plugin/registration#rendering-user-supplied-templates): it may use the tags, filters and functions `System\Twig\SandboxPolicy` allows and read the data the message was given, and it may not call a markup tag a plugin registered, pull in another template with `{% include %}` or `{% extends %}`, or call a method on an object passed into it. +> +> A mail **layout** is always treated as backend-written, because a layout row is copied out of its view file once and edited in place from then on, with nothing left to say whether it still matches. So write a layout to the sandboxed baseline even when you ship it as a view. Optionally, mail views can be [registered in the Plugin registration file](#registering-mail-layouts-templates-and-partials) with the `registerMailTemplates` method. This will automatically generate a mail template and allows them to be customized using the backend interface. From 620df9d49340874f08257ea2fda9de4a7abc1260 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Wed, 4 Nov 2026 16:12:10 -0600 Subject: [PATCH 3/3] Mail is not sandboxed either `system.manage_mail_templates` belongs to the Developer role exactly as the `cms.manage_*` permissions do, and no stock role grants one without the others, so whoever can write a mail template can already write a CMS template and therefore PHP. Mail content is code and 1.2.15 treats it as code, so `services/mail.md` says so plainly, says who the permission should go to, and points a plugin that needs to render a body one of *its users* wrote at the sandboxed factory for that fragment alone. `plugin/registration.md` loses the paragraph that named mail as core's sandboxed surface. Winter renders nothing in a sandbox now, which makes that factory the only place a sandbox appears anywhere - the point of the section rather than an aside in it. --- plugin/registration.md | 2 +- services/mail.md | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/plugin/registration.md b/plugin/registration.md index 5589babf..23225de1 100644 --- a/plugin/registration.md +++ b/plugin/registration.md @@ -369,7 +369,7 @@ The accepted keys are `tags`, `filters`, `functions`, `tests` and `methods`; any Two things to know before you widen it. A filter or function you allow is allowed with whatever arguments the template chooses, so do not allow one that takes a callable, a file path or a template name. A method you allow is allowed on every instance of that class that reaches the template, including instances you did not pass in yourself. -In Winter's own code the one sandboxed surface is mail content that a backend user wrote — a mail template, layout or partial stored in the database and edited under `system.manage_mail_templates`, a permission that grants no file access and no ability to author PHP. [Mail views](../services/mail#message-content) your plugin *ships* are files in your repository, so they are not sandboxed and may use anything a view may use. The one exception is a mail **layout**, which is always rendered sandboxed because a layout row is copied out of its view file once and edited in place from then on, so write a layout to the baseline above even when you ship it as a view. +Winter itself renders nothing in a sandbox. Theme templates, module and plugin views, and [mail content](../services/mail#message-content) are all managed by permissions that belong to the Developer role, so they are code and are treated as code. This factory is for the one case core does not have: a template that somebody without any Winter permission wrote. ## Navigation menus diff --git a/services/mail.md b/services/mail.md index d2924a09..a2dbd97b 100644 --- a/services/mail.md +++ b/services/mail.md @@ -300,9 +300,7 @@ Mail::laterOn('queue-name', 5, 'acme.blog::mail.welcome', $data, function ($mess Mail messages can be sent in Winter using either mail views or mail templates. A mail view is supplied by the application or plugin in the file system in the **/views** directory. Whereas a mail template is managed using the backend interface via *System > Mail templates*. All mail messages support using Twig for markup. -> **IMPORTANT:** As of v1.2.15 mail content is rendered in one of two Twig environments, depending on where the content came from. A **mail view** is a file in your repository, so it renders unsandboxed like any other view and nothing about it has changed. A mail template, layout or partial **stored in the database** was written in the backend by someone who may hold only `system.manage_mail_templates` — a permission that grants no file access and no ability to author PHP — so it renders in a [sandboxed environment](../plugin/registration#rendering-user-supplied-templates): it may use the tags, filters and functions `System\Twig\SandboxPolicy` allows and read the data the message was given, and it may not call a markup tag a plugin registered, pull in another template with `{% include %}` or `{% extends %}`, or call a method on an object passed into it. -> -> A mail **layout** is always treated as backend-written, because a layout row is copied out of its view file once and edited in place from then on, with nothing left to say whether it still matches. So write a layout to the sandboxed baseline even when you ship it as a view. +> **IMPORTANT:** Mail content is **not** sandboxed, whether it is a view file or a database row edited in the backend. A mail template is code: it may use every markup tag, filter and function a theme template may, and it reaches the same application. Editing one requires `system.manage_mail_templates`, which belongs to the Developer role, so grant it only to the people you would trust with the source code. If you need to render a mail body that one of *your users* wrote, render that fragment yourself in a [sandboxed environment](../plugin/registration#rendering-user-supplied-templates) and pass the result in as data. Optionally, mail views can be [registered in the Plugin registration file](#registering-mail-layouts-templates-and-partials) with the `registerMailTemplates` method. This will automatically generate a mail template and allows them to be customized using the backend interface.