Skip to content
This repository has been archived by the owner on Nov 25, 2024. It is now read-only.

Converted addClosure to a public method #1

Merged
merged 1 commit into from
May 20, 2024
Merged

Converted addClosure to a public method #1

merged 1 commit into from
May 20, 2024

Conversation

lindseydiloreto
Copy link
Contributor

Converts addClosure to a public method, with a $twig parameter for optionally supplying a custom Twig environment.

Once converted to a public method, it will be much easier for 3rd-party packages to support the Closure module...

// If the Closure module is installed
if (Craft::$app->hasModule('closure')) {

    // Add it to our Twig sandbox
    \nystudio107\closure\Closure::getInstance()?->addClosure($twigSandbox);

}

It will be immediately beneficial to both Formie and Notifier, making it much easier to integrate Closure with both plugins.

Thanks for considering this PR! 🙏

@lindseydiloreto lindseydiloreto requested a review from khalwat as a code owner May 18, 2024 03:03
@khalwat
Copy link
Contributor

khalwat commented May 18, 2024

So this would definitely be an easy fix... but I'm wondering if there's a way it can work automatically with a sandboxed environment? Perhaps a different event I could listen to, other than View::EVENT_BEFORE_RENDER_TEMPLATE that would catch it?

@lindseydiloreto
Copy link
Contributor Author

Maybe? A lot of that closure script was pretty mysterious to me.

The event probably isn't being triggered because we're running render directly from the TwigTemplate object, not via a Craft method...

@khalwat
Copy link
Contributor

khalwat commented May 20, 2024

So the other alternative I can think of is that you also throw the same Event that Craft does before you render the object template... but that would only work if you instantiated your own View object to use when rendering sandbox'd templates.

That actually might not be a bad route to explore in general, only because you could create this custom view once when your plugin inits, and then you wouldn't have the setup/teardown overhead every time you're rendering via your sandbox (though I don't know if you're actually doing it multiple times).

A decent bit of the code in the links that Josh sent along:

https://discord.com/channels/@me/545035542098870273/1241514968176791634

...seems to replicate what is in the Craft View class anyway... so what if you just subclassed the Craft View and overrode the createTwig() method to get all the things, and then just apply your sandbox policy to the default Craft View object?

I'm thinking it might simplify things for you in general, but just a thought.

@lindseydiloreto
Copy link
Contributor Author

I certainly appreciate your creative problem solving on this, although I worry that you may be overcomplicating the solution. This PR is a perfect fix, we simply need to make the addClosure method public so Josh and I can access it from elsewhere.

I can confidently say that me adding a new event, testing that event, documenting that event, and supporting that event into the future are not a simpler solution than just accepting this ready-to-go PR.

I've tested this PR thoroughly on my local machine, it's doing exactly what we need. Is there a reason you are reluctant to accept it as written? Or are you simply searching for a hypothetical "better" solution?

@khalwat
Copy link
Contributor

khalwat commented May 20, 2024

@lindseydiloreto yeah sure I'll merge the PR in, but I think you misunderstand me.

If you do what I suggest, you wouldn't need to do any of what you describe. Existing things like Closure would "just work" because you'll be throwing the same event that it already listens for. You aren't creating a new event, so you don't need to document it.

Also my suggestion mostly came because it appears that doing it the way I suggest would remove a lot of duplicated code that's already in the current implementation. Might be simpler in the end!

@khalwat khalwat merged commit 25f1eb4 into nystudio107:develop May 20, 2024
2 checks passed
@lindseydiloreto
Copy link
Contributor Author

Ok great, thank you. It is possible I don't fully understand what you were going for. This PR will do the trick perfectly for now though, and if a better solution emerges down the road, I'm happy to adjust Notifier to compensate.

Thanks @khalwat, much appreciated! 🍺

@khalwat
Copy link
Contributor

khalwat commented May 21, 2024

Okay, curiosity got the best of me... and I can confirm that it works. Much simpler, too compared to: https://github.com/verbb/verbb-base/blob/63785695d99eadcbceafb0813cf9372c142377b3/src/services/Templates.php

<?php

namespace modules\sitemodule\web;

use craft\events\CreateTwigEvent;
use craft\web\View;
use modules\sitemodule\twig\SecurityPolicy;
use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityPolicyInterface;

class SandboxView extends View
{
    // The security policy to use for this View
    public ?SecurityPolicyInterface $securityPolicy = null;

    public function init(): void
    {
        parent::init();
        // Use the passed in SecurityPolicy, or create a default security policy
        $this->securityPolicy = $this->securityPolicy ?? new SecurityPolicy(
            $this->allowedTags(),
            $this->allowedFilters(),
            $this->allowedMethods(),
            $this->allowedProperties(),
            $this->allowedFunctions()
        );
        // Add the SandboxExtension with our SecurityPolicy after Twig is created
        $this->on(View::EVENT_AFTER_CREATE_TWIG, function(CreateTwigEvent $event) {
            $twig = $event->twig;
            $twig->addExtension(new SandboxExtension($this->securityPolicy, true));
        });
    }
}

Just implement the allowedXxxxx() methods to return the appropriate allowed tags, filters, etc. The SecurityPolicy class is just a copy pasta of: https://github.com/verbb/verbb-base/blob/63785695d99eadcbceafb0813cf9372c142377b3/src/twig/SecurityPolicy.php

Then use it like this:

        $view = new SandboxView();
        $result = $view->renderString("{{ dump() }}", []);

...and see your exception thrown:

Twig\Sandbox\SecurityNotAllowedFunctionError: Function "dump" is not allowed in "__string_template__b0120324b463b0e0d2c2618b7c5ce3ba" at line 1. in /var/www/project/cms_v5/modules/sitemodule/src/twig/SecurityPolicy.php:80
Stack trace:
#0 /var/www/project/cms_v5/vendor/twig/twig/src/Extension/SandboxExtension.php(76): modules\sitemodule\twig\SecurityPolicy->checkSecurity(Array, Array, Array)
#1 /var/www/project/cms_v5/storage/runtime/compiled_templates/d7/d755ca53ed8940f30be518a665b6d6f5.php(80): Twig\Extension\SandboxExtension->checkSecurity(Array, Array, Array)
#2 /var/www/project/cms_v5/storage/runtime/compiled_templates/d7/d755ca53ed8940f30be518a665b6d6f5.php(32): __TwigTemplate_fa3fb94dc47e6d6b57b1b4700d67bea0->checkSecurity()
#3 /var/www/project/cms_v5/vendor/twig/twig/src/Environment.php(371): __TwigTemplate_fa3fb94dc47e6d6b57b1b4700d67bea0->__construct(Object(craft\web\twig\Environment))
#4 /var/www/project/cms_v5/vendor/twig/twig/src/Environment.php(401): Twig\Environment->loadTemplate('__TwigTemplate_...', '__string_templa...')
#5 /var/www/project/cms_v5/vendor/craftcms/cms/src/web/View.php(575): Twig\Environment->createTemplate('{{ dump() }}')
#6 /var/www/project/cms_v5/modules/sitemodule/src/SiteModule.php(127): craft\web\View->renderString('{{ dump() }}', Array)
#7 /var/www/project/cms_v5/vendor/yiisoft/yii2/base/BaseObject.php(109): modules\sitemodule\SiteModule->init()
#8 /var/www/project/cms_v5/vendor/yiisoft/yii2/base/Module.php(161): yii\base\BaseObject->__construct(Array)
#9 /var/www/project/cms_v5/modules/sitemodule/src/SiteModule.php(81): yii\base\Module->__construct('site-module', Object(craft\web\Application), Array)
#10 [internal function]: modules\sitemodule\SiteModule->__construct('site-module', Object(craft\web\Application), Array)
#11 /var/www/project/cms_v5/vendor/yiisoft/yii2/di/Container.php(411): ReflectionClass->newInstanceArgs(Array)
#12 /var/www/project/cms_v5/vendor/yiisoft/yii2/di/Container.php(170): yii\di\Container->build('modules\\sitemod...', Array, Array)
#13 /var/www/project/cms_v5/vendor/yiisoft/yii2/BaseYii.php(365): yii\di\Container->get('modules\\sitemod...', Array, Array)
#14 /var/www/project/cms_v5/vendor/yiisoft/yii2/base/Module.php(445): yii\BaseYii::createObject(Array, Array)
#15 /var/www/project/cms_v5/vendor/yiisoft/yii2/base/Application.php(313): yii\base\Module->getModule('site-module')
#16 /var/www/project/cms_v5/vendor/craftcms/cms/src/web/Application.php(127): yii\base\Application->bootstrap()
#17 /var/www/project/cms_v5/vendor/yiisoft/yii2/base/Application.php(271): craft\web\Application->bootstrap()
#18 /var/www/project/cms_v5/vendor/craftcms/cms/src/web/Application.php(104): yii\base\Application->init()
#19 /var/www/project/cms_v5/vendor/yiisoft/yii2/base/BaseObject.php(109): craft\web\Application->init()
#20 /var/www/project/cms_v5/vendor/yiisoft/yii2/base/Application.php(204): yii\base\BaseObject->__construct(Array)
#21 [internal function]: yii\base\Application->__construct(Array)
#22 /var/www/project/cms_v5/vendor/yiisoft/yii2/di/Container.php(419): ReflectionClass->newInstanceArgs(Array)
#23 /var/www/project/cms_v5/vendor/yiisoft/yii2/di/Container.php(170): yii\di\Container->build('craft\\web\\Appli...', Array, Array)
#24 /var/www/project/cms_v5/vendor/yiisoft/yii2/BaseYii.php(365): yii\di\Container->get('craft\\web\\Appli...', Array, Array)
#25 /var/www/project/cms_v5/vendor/craftcms/cms/src/Craft.php(70): yii\BaseYii::createObject(Array, Array)
#26 /var/www/project/cms_v5/vendor/craftcms/cms/bootstrap/bootstrap.php(316): Craft::createObject(Array)
#27 /var/www/project/cms_v5/vendor/craftcms/cms/bootstrap/web.php(40): require('/var/www/projec...')
#28 /var/www/project/cms_v5/web/index.php(11): require('/var/www/projec...')
#29 {main}

@khalwat
Copy link
Contributor

khalwat commented May 21, 2024

Here's a backwards compatible version of the same code, which doesn't use the View::EVENT_AFTER_CREATE_TWIG event, which was pretty recently added to Craft.

<?php

namespace modules\sitemodule\web;

use craft\web\twig\Environment;
use craft\web\View;
use modules\sitemodule\twig\SecurityPolicy;
use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityPolicyInterface;

class SandboxView extends View
{
    // The security policy to use for this View
    public ?SecurityPolicyInterface $securityPolicy = null;

    public function init(): void
    {
        parent::init();
        // Use the passed in SecurityPolicy, or create a default security policy
        $this->securityPolicy = $this->securityPolicy ?? new SecurityPolicy(
            $this->allowedTags(),
            $this->allowedFilters(),
            $this->allowedMethods(),
            $this->allowedProperties(),
            $this->allowedFunctions()
        );
    }

    public function createTwig(): Environment
    {
        $twig = parent::createTwig();
        // Add the SandboxExtension with our SecurityPolicy after Twig is created
        $twig->addExtension(new SandboxExtension($this->securityPolicy, true));

        return $twig;
    }
}

@engram-design
Copy link

How does this solution work for you exactly? Closure uses the View::EVENT_BEFORE_RENDER_TEMPLATE event, but that's never triggered when using renderString() only when calling renderTemplate()

So if I try and run:

Craft::dd(Formie::$plugin->getSandboxView()->renderString("
{% set collection = collect(['a', 'b', 'c']) %}
{% set contains = collection.contains((value, key) => value == 'z') %}
{{ contains ? 'test' : 'test1' }}
"));

With your provided solution of extending the View class, I'll get an error:

An opened parenthesis is not properly closed. Unexpected token "punctuation" of value "," ("punctuation" expected with value ")") in "__string_template__455d9902f13f9e0eef65a21290df8e11" at line 4.

Because Closure hasn't been registered. Which brings us back to the original PR of being able to call the registration of the Closure functionality as we need it, not just assuming it's going to be used in View::EVENT_BEFORE_RENDER_TEMPLATE

Secondly, I'm not a massive fan of the double error's that are thrown when encountering an error. We can indeed add try / catch around renderString and renderTemplate but I'd rather align it to what Craft's View does. We want those error thrown and for the plugins that are calling these functions to sort out how to handle it.

For example, this is an error thrown right now when including dump() in a string, which isn't allowed by default, and with no try / catch of our own.

An Error occurred while handling another error:
Twig\Error\RuntimeError: The "Twig\Extension\SandboxExtension" extension is not enabled. in /Users/joshcrawford/public_html/formie-test/vendor/twig/twig/src/ExtensionSet.php:73
Stack trace:
#0 /Users/joshcrawford/public_html/formie-test/vendor/twig/twig/src/Environment.php(571): Twig\ExtensionSet->getExtension('Twig\\Extension\\...')
#1 /Users/joshcrawford/public_html/formie-test/storage/runtime/compiled_templates/3a/3a5834cba0a9fd7657ce3014054a4281.php(31): Twig\Environment->getExtension('\\Twig\\Extension...')
#2 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/helpers/Template.php(352): __TwigTemplate_450013f87285c1f86d0ab312165d702a->__construct(Object(craft\web\twig\Environment))
#3 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/web/ErrorHandler.php(252): craft\helpers\Template::resolveTemplatePathAndLine('/Users/joshcraw...', 80)
#4 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/web/ErrorHandler.php(346): craft\web\ErrorHandler->renderCallStackItem('/Users/joshcraw...', 80, 'Twig\\Extension\\...', 'checkSecurity', Array, 3)
#5 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/views/errorHandler/exception.php(441): yii\web\ErrorHandler->renderCallStack(Object(Twig\Sandbox\SecurityNotAllowedFunctionError))
#6 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/base/View.php(347): require('/Users/joshcraw...')
#7 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/base/View.php(257): yii\base\View->renderPhpFile('/Users/joshcraw...', Array)
#8 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/web/ErrorHandler.php(270): yii\base\View->renderFile('/Users/joshcraw...', Array, Object(craft\web\ErrorHandler))
#9 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/web/ErrorHandler.php(126): yii\web\ErrorHandler->renderFile('@yii/views/erro...', Array)
#10 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/web/ErrorHandler.php(192): yii\web\ErrorHandler->renderException(Object(Twig\Sandbox\SecurityNotAllowedFunctionError))
#11 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/base/ErrorHandler.php(152): craft\web\ErrorHandler->renderException(Object(Twig\Sandbox\SecurityNotAllowedFunctionError))
#12 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/web/ErrorHandler.php(66): yii\base\ErrorHandler->handleException(Object(Twig\Sandbox\SecurityNotAllowedFunctionError))
#13 [internal function]: craft\web\ErrorHandler->handleException(Object(Twig\Sandbox\SecurityNotAllowedFunctionError))
#14 {main}
Previous exception:
Twig\Sandbox\SecurityNotAllowedFunctionError: Function "dump" is not allowed in "__string_template__915d59f6c6607b12207ad0db34c772c0" at line 1. in /Users/joshcrawford/public_html/craft4-plugins/verbb-base/src/twig/SecurityPolicy.php:79
Stack trace:
#0 /Users/joshcrawford/public_html/formie-test/vendor/twig/twig/src/Extension/SandboxExtension.php(76): verbb\base\twig\SecurityPolicy->checkSecurity(Array, Array, Array)
#1 /Users/joshcrawford/public_html/formie-test/storage/runtime/compiled_templates/3a/3a5834cba0a9fd7657ce3014054a4281.php(80): Twig\Extension\SandboxExtension->checkSecurity(Array, Array, Array)
#2 /Users/joshcrawford/public_html/formie-test/storage/runtime/compiled_templates/3a/3a5834cba0a9fd7657ce3014054a4281.php(32): __TwigTemplate_450013f87285c1f86d0ab312165d702a->checkSecurity()
#3 /Users/joshcrawford/public_html/formie-test/vendor/twig/twig/src/Environment.php(371): __TwigTemplate_450013f87285c1f86d0ab312165d702a->__construct(Object(craft\web\twig\Environment))
#4 /Users/joshcrawford/public_html/formie-test/vendor/twig/twig/src/Environment.php(401): Twig\Environment->loadTemplate('__TwigTemplate_...', '__string_templa...')
#5 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/web/View.php(576): Twig\Environment->createTemplate('{{ dump("test")...')
#6 /Users/joshcrawford/public_html/craft4-plugins/formie/src/Formie.php(162): craft\web\View->renderString('{{ dump("test")...')
#7 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/base/BaseObject.php(109): verbb\formie\Formie->init()
#8 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/base/Module.php(161): yii\base\BaseObject->__construct(Array)
#9 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/base/Plugin.php(122): yii\base\Module->__construct('formie', Object(craft\web\Application), Array)
#10 /Users/joshcrawford/public_html/craft4-plugins/formie/src/base/PluginTrait.php(87): craft\base\Plugin->__construct('formie', Object(craft\web\Application), Array)
#11 [internal function]: verbb\formie\Formie->__construct('formie', Object(craft\web\Application), Array)
#12 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/di/Container.php(419): ReflectionClass->newInstanceArgs(Array)
#13 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/di/Container.php(170): yii\di\Container->build('verbb\\formie\\Fo...', Array, Array)
#14 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/BaseYii.php(365): yii\di\Container->get('verbb\\formie\\Fo...', Array, Array)
#15 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/Craft.php(59): yii\BaseYii::createObject(Array, Array)
#16 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/services/Plugins.php(948): Craft::createObject(Array, Array)
#17 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/services/Plugins.php(233): craft\services\Plugins->createPlugin('formie', Array)
#18 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/base/ApplicationTrait.php(1655): craft\services\Plugins->loadPlugins()
#19 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/web/Application.php(110): craft\web\Application->_postInit()
#20 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/base/BaseObject.php(109): craft\web\Application->init()
#21 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/base/Application.php(204): yii\base\BaseObject->__construct(Array)
#22 [internal function]: yii\base\Application->__construct(Array)
#23 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/di/Container.php(419): ReflectionClass->newInstanceArgs(Array)
#24 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/di/Container.php(170): yii\di\Container->build('craft\\web\\Appli...', Array, Array)
#25 /Users/joshcrawford/public_html/formie-test/vendor/yiisoft/yii2/BaseYii.php(365): yii\di\Container->get('craft\\web\\Appli...', Array, Array)
#26 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/src/Craft.php(59): yii\BaseYii::createObject(Array, Array)
#27 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/bootstrap/bootstrap.php(264): Craft::createObject(Array)
#28 /Users/joshcrawford/public_html/formie-test/vendor/craftcms/cms/bootstrap/web.php(40): require('/Users/joshcraw...')
#29 /Users/joshcrawford/public_html/formie-test/web/index.php(11): require('/Users/joshcraw...')
#30 {main}

This isn't exactly your solutions fault, and is more that the Craft error handler doesn't know about our custom environment.

@engram-design
Copy link

Oh! I forgot that you merged the PR, so not so much a pressing issue, just an FYI on your solution!

@khalwat
Copy link
Contributor

khalwat commented May 21, 2024

@engram-design lol I actually didn't test it with Closure... but you're right, it won't fix that. But interestingly, nor would it fix it in a non-sandboxed environment, if someone wanted to use Closure in a rendered string template.

Which makes me think that I should just register Closure via the View::EVENT_AFTER_CREATE_TWIG (I actually proposed that event to Brandon for Closure itself).

The only reason I don't do that now is backwards compatibility, as that event was added in Craft 4.3.0 -> https://github.com/craftcms/cms/blob/4.x/CHANGELOG.md#430---2022-10-25

But I think that's a reasonable enough minimum to require for updates to Closure, so I'm going to refactor it to require that as a minimum version of Craft.

@khalwat
Copy link
Contributor

khalwat commented May 21, 2024

As for the error handler, I'll have a look at that end of things too. The reality is if you're rendering a string template, you generally don't want to throw an exception anyway though, because it's part of a large UI. Rather you'd like to swallow the exception, and report it to the user in-context in your UI.

I do something similar in SEOmatic where I validate Twig templates, but if you make a mistake in the template it doesn't go full screen and throw a Twig error, it displays that error inline with the field as an error via a validator (which has the try/catch in it): https://github.com/nystudio107/craft-code-editor/blob/develop/src/validators/TwigTemplateValidator.php

Screenshot 2024-05-21 at 14 28 47

There's even an object template validator: https://github.com/nystudio107/craft-code-editor/blob/develop/src/validators/TwigObjectTemplateValidator.php

...and if you're not using craft-code-editor for your editable Twig templates... you totally should be, you get the Monaco editor with autocomplete, etc.

@engram-design
Copy link

The only reason I suggest keeping it a fatal error is to keep consistent with Craft rendering, and so that the plugins that implement this sandbox can handle the exception how they like.

@khalwat
Copy link
Contributor

khalwat commented Jul 1, 2024

@engram-design I've solved that problem... more soon :)

@khalwat
Copy link
Contributor

khalwat commented Jul 1, 2024

@lindseydiloreto @engram-design have a bash https://github.com/nystudio107/craft-twig-sandbox/

@lindseydiloreto
Copy link
Contributor Author

@khalwat Thanks for putting together a common sandbox! I look forward to taking it for a spin. 🍺

Once it's backported to Craft 4, I'll be able to roll it into Notifier.

@khalwat
Copy link
Contributor

khalwat commented Jul 2, 2024

@lindseydiloreto no problem. The "backport" is the same code, different semver. I'm just waiting until I'm ready to ship 1.0.0 before bothering to do that. Will be shortly.

@khalwat
Copy link
Contributor

khalwat commented Jul 30, 2024

@lindseydiloreto it's been backwported to Craft 4, and is released -> https://github.com/nystudio107/craft-twig-sandbox/

@lindseydiloreto
Copy link
Contributor Author

Brilliant, thanks @khalwat! Glad to finally have a common sandbox tool to lean on. 🎉 🙏

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants