├── phpstan.neon ├── ecs.php ├── phpunit.xml ├── src ├── console │ └── SandboxErrorHandler.php ├── web │ ├── SandboxErrorHandler.php │ └── SandboxView.php ├── config │ ├── whitelist-sandbox.php │ └── blacklist-sandbox.php ├── helpers │ └── SecurityPolicy.php └── twig │ ├── BlacklistSecurityPolicy.php │ ├── WhitelistSecurityPolicy.php │ └── BaseSecurityPolicy.php ├── Makefile ├── LICENSE.md ├── CHANGELOG.md ├── composer.json └── README.md /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - %currentWorkingDirectory%/vendor/craftcms/phpstan/phpstan.neon 3 | 4 | parameters: 5 | level: 5 6 | phpVersion: 80200 # PHP 8.2 7 | paths: 8 | - src 9 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 8 | __DIR__ . '/src', 9 | __FILE__, 10 | ]); 11 | $ecsConfig->parallel(); 12 | $ecsConfig->sets([SetList::CRAFT_CMS_4]); 13 | }; 14 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/console/SandboxErrorHandler.php: -------------------------------------------------------------------------------- 1 | getPrevious()) !== null) { 20 | $exception = $previousException; 21 | } 22 | parent::handleException($exception); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAJOR_VERSION?=5 2 | PLUGINDEV_PROJECT_DIR?=/Users/andrew/webdev/sites/plugindev/cms_v${MAJOR_VERSION}/ 3 | VENDOR?=nystudio107 4 | PROJECT_PATH?=${VENDOR}/$(shell basename $(CURDIR)) 5 | 6 | .PHONY: dev docs release 7 | 8 | # Start up the buildchain dev server 9 | dev: 10 | # Start up the docs dev server 11 | docs: 12 | # Run code quality tools, tests, and build the buildchain & docs in preparation for a release 13 | release: --code-quality --code-tests --buildchain-clean-build --docs-clean-build 14 | # The internal targets used by the dev & release targets 15 | --buildchain-clean-build: 16 | --code-quality: 17 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- ecs check vendor/${PROJECT_PATH}/src --fix 18 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- phpstan analyze -c vendor/${PROJECT_PATH}/phpstan.neon 19 | --code-tests: 20 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- pest --configuration=vendor/nystudio107/craft-twig-sandbox/phpunit.xml --test-directory=vendor/nystudio107/craft-twig-sandbox/tests 21 | --docs-clean-build: 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) nystudio107 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Craft Twig Sandbox Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 5.0.5 - 2028.08.18 6 | ### Added 7 | * Provide a mechanism for adding Twig Extensions in bulk to the `SandboxView` ([#1632](https://github.com/nystudio107/craft-seomatic/issues/1632)) 8 | 9 | ## 5.0.4 - 2025.06.10 10 | ### Fixed 11 | * Remove errant dependency on SEOmatic in the `SecurityPolicy` helper class 12 | 13 | ## 5.0.3 - 2025.06.08 14 | ### Added 15 | * Add an example `config/blacklist-sandbox.php` and `config/whitelist-sandbox.php` files for user-customizable Twig sandbox environments 16 | * Add `SecurityPolicy::createFromFile()` to create a new Twig sandbox from a config file in the `config/` directory 17 | 18 | ### Changed 19 | * Cleaned up the `BlacklistSecurityPolicy` to no longer blacklist innocuous tags/filters/functions 20 | 21 | ## 5.0.2 - 2025.02.17 22 | ### Added 23 | * Craft Twig Sandbox no longer automatically handles exceptions when rendering sandbox templates. Instead, you can decide whether to handle the exception yourself, or pass it along to the `sandboxErrorHandler` for display in the browser/console 24 | 25 | ### Changed 26 | * Use the official `markhuot/craft-pest-core:^2.0.4` package instead of the patch version from @bencroker 27 | 28 | ## 5.0.1 - 2024.07.29 29 | ### Changed 30 | * Removed the special-casing for the Craft Closure, since it now uses a different loading mechanism 31 | * Simplify the `SanboxView` to use `::registerTwigExtension` rather than overriding `::createTwig()` 32 | 33 | ## 5.0.0 - 2024.07.03 34 | ### Added 35 | * Initial release 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nystudio107/craft-twig-sandbox", 3 | "description": "Allows you to easily create a sandboxed Twig environment where you can control what tags, filters, functions, and object methods/properties are allowed", 4 | "version": "5.0.5", 5 | "keywords": [ 6 | "craft", 7 | "cms", 8 | "craftcms", 9 | "twig", 10 | "sandbox", 11 | "security" 12 | ], 13 | "support": { 14 | "docs": "https://github.com/nystudio107/craft-twig-sandbox/blob/v5/README.md", 15 | "issues": "https://github.com/nystudio107/craft-twig-sandbox/issues", 16 | "source": "https://github.com/nystudio107/craft-twig-sandbox" 17 | }, 18 | "license": "MIT", 19 | "authors": [ 20 | { 21 | "name": "nystudio107", 22 | "homepage": "https://nystudio107.com" 23 | } 24 | ], 25 | "require": { 26 | "craftcms/cms": "^5.0.0", 27 | "twig/twig": "^3.0.0" 28 | }, 29 | "require-dev": { 30 | "craftcms/ecs": "dev-main", 31 | "craftcms/phpstan": "dev-main", 32 | "craftcms/rector": "dev-main", 33 | "markhuot/craft-pest-core": "^2.0.4" 34 | }, 35 | "repositories": [ 36 | ], 37 | "scripts": { 38 | "test": "pest", 39 | "lint": "pest --coverage", 40 | "phpstan": "phpstan --ansi --memory-limit=1G", 41 | "check-cs": "ecs check --ansi", 42 | "fix-cs": "ecs check --fix --ansi" 43 | }, 44 | "config": { 45 | "allow-plugins": { 46 | "craftcms/plugin-installer": true, 47 | "pestphp/pest-plugin": true, 48 | "yiisoft/yii2-composer": true 49 | }, 50 | "optimize-autoloader": true, 51 | "sort-packages": true 52 | }, 53 | "autoload": { 54 | "psr-4": { 55 | "nystudio107\\crafttwigsandbox\\": "src/" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/web/SandboxErrorHandler.php: -------------------------------------------------------------------------------- 1 | getPrevious()) !== null) { 23 | $exception = $previousException; 24 | } 25 | parent::handleException($exception); 26 | } 27 | 28 | /** 29 | * @inheritDoc 30 | */ 31 | public function renderCallStackItem($file, $line, $class, $method, $args, $index): string 32 | { 33 | try { 34 | $templateInfo = Template::resolveTemplatePathAndLine($file ?? '', $line); 35 | if ($templateInfo !== false) { 36 | [$file, $line] = $templateInfo; 37 | } 38 | } catch (SecurityError $e) { 39 | $line = $e->getTemplateLine(); 40 | $file = $e->getSourceContext()->getPath() ?: null; 41 | } catch (Throwable $e) { 42 | // That's fine 43 | } 44 | 45 | // Call the grandparent ErrorHandler::renderCallStackItem() so Craft's ErrorHandler::renderCallStackItem() 46 | // doesn't throw an additional exception when trying to render the callstack 47 | $reflectionMethod = new ReflectionMethod(get_parent_class(get_parent_class($this)), 'renderCallStackItem'); 48 | 49 | return $reflectionMethod->invokeArgs($this, [$file, $line, $class, $method, $args, $index]); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/config/whitelist-sandbox.php: -------------------------------------------------------------------------------- 1 | WhitelistSecurityPolicy::class, 20 | 'twigTags' => [ 21 | 'for', 22 | 'if', 23 | 'set', 24 | ], 25 | 'twigFilters' => [ 26 | 'capitalize', 27 | 'date', 28 | 'escape', 29 | 'first', 30 | 'join', 31 | 'keys', 32 | 'last', 33 | 'length', 34 | 'lower', 35 | 'markdown', 36 | 'nl2br', 37 | 'number_format', 38 | 'raw', 39 | 'replace', 40 | 'sort', 41 | 'split', 42 | 'striptags', 43 | 'title', 44 | 'trim', 45 | 'upper', 46 | 'camel', 47 | 'contains', 48 | 'currency', 49 | 'date', 50 | 'datetime', 51 | 'id', 52 | 'index', 53 | 'indexOf', 54 | 'kebab', 55 | 'lcfirst', 56 | 'length', 57 | 'markdown', 58 | 'md', 59 | 'merge', 60 | 'money', 61 | 'pascal', 62 | 'percentage', 63 | 'purify', 64 | 'snake', 65 | 'time', 66 | 'timestamp', 67 | 'translate', 68 | 't', 69 | 'ucfirst', 70 | 'ucwords', 71 | ], 72 | 'twigFunctions' => [ 73 | 'date', 74 | 'max', 75 | 'min', 76 | 'random', 77 | 'range', 78 | 'collect', 79 | ], 80 | 'twigMethods' => [ 81 | ], 82 | 'twigProperties' => [ 83 | ], 84 | ]; 85 | -------------------------------------------------------------------------------- /src/web/SandboxView.php: -------------------------------------------------------------------------------- 1 | sandboxErrorHandler = Craft::$app->getRequest()->getIsConsoleRequest() ? new ConsoleSandboxErrorHandler() : new WebSandboxErrorHandler(); 43 | // Use the passed in SecurityPolicy, or create a default security policy 44 | $this->securityPolicy = $this->securityPolicy ?? new BlacklistSecurityPolicy(); 45 | // Add the SandboxExtension with our SecurityPolicy lazily via ::registerTwigExtension() 46 | $this->registerTwigExtension(new SandboxExtension($this->securityPolicy, true)); 47 | // Register the additional TwigExtension classes 48 | foreach ($this->twigExtensionClasses as $className) { 49 | if (class_exists($className)) { 50 | $this->registerTwigExtension(new $className()); 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/helpers/SecurityPolicy.php: -------------------------------------------------------------------------------- 1 | $envConfig) { 58 | if ($env === '*') { 59 | $mergedConfig = ArrayHelper::merge($mergedConfig, $envConfig); 60 | } 61 | } 62 | 63 | return $mergedConfig; 64 | } 65 | 66 | // Private Methods 67 | // ========================================================================= 68 | 69 | /** 70 | * Return a path from an alias and a partial path 71 | * 72 | * @param string $alias 73 | * @param string $filePath 74 | * 75 | * @return string 76 | */ 77 | private static function getConfigFilePath(string $alias, string $filePath): string 78 | { 79 | $path = DIRECTORY_SEPARATOR . ltrim($filePath, DIRECTORY_SEPARATOR); 80 | $path = Craft::getAlias($alias) 81 | . DIRECTORY_SEPARATOR 82 | . str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $path) 83 | . '.php'; 84 | 85 | return $path; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/config/blacklist-sandbox.php: -------------------------------------------------------------------------------- 1 | BlacklistSecurityPolicy::class, 20 | 'twigTags' => [ 21 | 'autoescape', 22 | 'block', 23 | 'deprecated', 24 | 'do', 25 | 'embed', 26 | 'extends', 27 | 'flush', 28 | 'from', 29 | 'import', 30 | 'include', 31 | 'macro', 32 | 'sandbox', 33 | 'use', 34 | 'verbatim', 35 | 'cache', 36 | 'css', 37 | 'dd', 38 | 'dump', 39 | 'exit', 40 | 'header', 41 | 'hook', 42 | 'html', 43 | 'js', 44 | 'namespace', 45 | 'nav', 46 | 'paginate', 47 | 'redirect', 48 | 'requireAdmin', 49 | 'requireEdition', 50 | 'requireGuest', 51 | 'requireLogin', 52 | 'requirePermission', 53 | 'script', 54 | 'tag', 55 | ], 56 | 'twigFilters' => [ 57 | 'convert_encoding', 58 | 'data_uri', 59 | 'filter', 60 | 'inky_to_html', 61 | 'inline_css', 62 | 'map', 63 | 'merge', 64 | 'reduce', 65 | 'sort', 66 | 'spaceless', 67 | 'url_encode', 68 | 'append', 69 | 'attr', 70 | 'base64_decode', 71 | 'base64_encode', 72 | 'column', 73 | 'encenc', 74 | 'filesize', 75 | 'filter', 76 | 'hash', 77 | 'json_encode', 78 | 'json_decode', 79 | 'multisort', 80 | 'namespace', 81 | 'ns', 82 | 'namespaceAttributes', 83 | 'namespaceInputId', 84 | 'namespaceInputName', 85 | 'parseAttr', 86 | 'parseRefs', 87 | 'prepend', 88 | 'removeClass', 89 | 'where', 90 | ], 91 | 'twigFunctions' => [ 92 | 'attribute', 93 | 'block', 94 | 'constant', 95 | 'cycle', 96 | 'dump', 97 | 'html_classes', 98 | 'parent', 99 | 'source', 100 | 'template_from_string', 101 | 'actionInput', 102 | 'alias', 103 | 'beginBody', 104 | 'block', 105 | 'canCreateDrafts', 106 | 'canDelete', 107 | 'canDeleteForSite', 108 | 'canDuplicate', 109 | 'canSave', 110 | 'canView', 111 | 'ceil', 112 | 'className', 113 | 'clone', 114 | 'combine', 115 | 'configure', 116 | 'constant', 117 | 'create', 118 | 'csrfInput', 119 | 'dump', 120 | 'endBody', 121 | 'expression', 122 | 'failMessageInput', 123 | 'getenv', 124 | 'gql', 125 | 'head', 126 | 'hiddenInput', 127 | 'input', 128 | 'parseBooleanEnv', 129 | 'parseEnv', 130 | 'plugin', 131 | 'redirectInput', 132 | 'renderObjectTemplate', 133 | 'source', 134 | 'successMessageInput', 135 | ], 136 | 'twigMethods' => [ 137 | ], 138 | 'twigProperties' => [ 139 | ], 140 | ]; 141 | -------------------------------------------------------------------------------- /src/twig/BlacklistSecurityPolicy.php: -------------------------------------------------------------------------------- 1 | getTwigTags(), true)) { 38 | throw new SecurityNotAllowedTagError(sprintf('Tag "%s" is not allowed.', $tag), $tag); 39 | } 40 | } 41 | 42 | foreach ($filters as $filter) { 43 | if (in_array($filter, $this->getTwigFilters(), true)) { 44 | throw new SecurityNotAllowedFilterError(sprintf('Filter "%s" is not allowed.', $filter), $filter); 45 | } 46 | } 47 | 48 | foreach ($functions as $function) { 49 | if (in_array($function, $this->getTwigFunctions(), true)) { 50 | throw new SecurityNotAllowedFunctionError(sprintf('Function "%s" is not allowed.', $function), $function); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function checkMethodAllowed($obj, $method): void 59 | { 60 | if ($obj instanceof Template || $obj instanceof Markup) { 61 | return; 62 | } 63 | 64 | $method = strtolower($method); 65 | $allowed = true; 66 | foreach ($this->getTwigMethods() as $class => $methods) { 67 | if ($obj instanceof $class) { 68 | if ($methods[0] === '*' || in_array($method, $methods, true)) { 69 | $allowed = false; 70 | break; 71 | } 72 | } 73 | } 74 | 75 | if (!$allowed) { 76 | $class = \get_class($obj); 77 | throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method); 78 | } 79 | } 80 | 81 | /** 82 | * @inheritDoc 83 | */ 84 | public function checkPropertyAllowed($obj, $property): void 85 | { 86 | $allowed = true; 87 | $property = strtolower($property); 88 | foreach ($this->getTwigProperties() as $class => $properties) { 89 | if ($obj instanceof $class) { 90 | if ($properties[0] === '*' || in_array($property, $properties, true)) { 91 | $allowed = false; 92 | break; 93 | } 94 | } 95 | } 96 | 97 | if (!$allowed) { 98 | $class = \get_class($obj); 99 | throw new SecurityNotAllowedPropertyError(sprintf('Accessing "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/twig/WhitelistSecurityPolicy.php: -------------------------------------------------------------------------------- 1 | getTwigTags(), true)) { 38 | throw new SecurityNotAllowedTagError(sprintf('Tag "%s" is not allowed.', $tag), $tag); 39 | } 40 | } 41 | 42 | foreach ($filters as $filter) { 43 | if (!in_array($filter, $this->getTwigFilters(), true)) { 44 | throw new SecurityNotAllowedFilterError(sprintf('Filter "%s" is not allowed.', $filter), $filter); 45 | } 46 | } 47 | 48 | foreach ($functions as $function) { 49 | if (!in_array($function, $this->getTwigFunctions(), true)) { 50 | throw new SecurityNotAllowedFunctionError(sprintf('Function "%s" is not allowed.', $function), $function); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function checkMethodAllowed($obj, $method): void 59 | { 60 | if ($obj instanceof Template || $obj instanceof Markup) { 61 | return; 62 | } 63 | 64 | $method = strtolower($method); 65 | $allowed = false; 66 | foreach ($this->getTwigMethods() as $class => $methods) { 67 | if ($obj instanceof $class) { 68 | if ($methods[0] === '*' || in_array($method, $methods, true)) { 69 | $allowed = true; 70 | break; 71 | } 72 | } 73 | } 74 | 75 | if (!$allowed) { 76 | $class = \get_class($obj); 77 | throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method); 78 | } 79 | } 80 | 81 | /** 82 | * @inheritDoc 83 | */ 84 | public function checkPropertyAllowed($obj, $property): void 85 | { 86 | $allowed = false; 87 | $property = strtolower($property); 88 | foreach ($this->getTwigProperties() as $class => $properties) { 89 | if ($obj instanceof $class) { 90 | if ($properties[0] === '*' || in_array($property, $properties, true)) { 91 | $allowed = true; 92 | break; 93 | } 94 | } 95 | } 96 | 97 | if (!$allowed) { 98 | $class = \get_class($obj); 99 | throw new SecurityNotAllowedPropertyError(sprintf('Accessing "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/twig/BaseSecurityPolicy.php: -------------------------------------------------------------------------------- 1 | twigTags; 83 | } 84 | 85 | public function setTwigTags(array $tags): void 86 | { 87 | $this->twigTags = $tags; 88 | } 89 | 90 | public function getTwigFilters(): array 91 | { 92 | return $this->twigFilters; 93 | } 94 | 95 | public function setTwigFilters(array $filters): void 96 | { 97 | $this->twigFilters = $filters; 98 | } 99 | 100 | public function getTwigFunctions(): array 101 | { 102 | return $this->twigFunctions; 103 | } 104 | 105 | public function setTwigFunctions(array $functions): void 106 | { 107 | $this->twigFunctions = $functions; 108 | } 109 | 110 | public function getTwigMethods(): array 111 | { 112 | return $this->twigMethods; 113 | } 114 | 115 | public function setTwigMethods(array $methods): void 116 | { 117 | $this->twigMethods = []; 118 | foreach ($methods as $class => $m) { 119 | $this->twigMethods[$class] = array_map(static function($value) { 120 | return strtolower($value); 121 | }, is_array($m) ? $m : [$m]); 122 | } 123 | } 124 | 125 | 126 | public function getTwigProperties(): array 127 | { 128 | return $this->twigProperties; 129 | } 130 | 131 | public function setTwigProperties(array $properties): void 132 | { 133 | $this->twigProperties = []; 134 | foreach ($properties as $class => $p) { 135 | $this->twigProperties[$class] = array_map(static function($value) { 136 | return strtolower($value); 137 | }, is_array($p) ? $p : [$p]); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/nystudio107/craft-twig-sandbox/badges/quality-score.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-twig-sandbox/?branch=develop) [![Code Coverage](https://scrutinizer-ci.com/g/nystudio107/craft-twig-sandbox/badges/coverage.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-twig-sandbox/?branch=develop) [![Build Status](https://scrutinizer-ci.com/g/nystudio107/craft-twig-sandbox/badges/build.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-twig-sandbox/build-status/develop) [![Code Intelligence Status](https://scrutinizer-ci.com/g/nystudio107/craft-twig-sandbox/badges/code-intelligence.svg?b=v5)](https://scrutinizer-ci.com/code-intelligence) 2 | 3 | # Craft Twig Sandbox 4 | 5 | Allows you to easily create a sandboxed Twig environment where you can control what tags, filters, functions, and object methods/properties are allowed 6 | 7 | ## Requirements 8 | 9 | Craft Twig Sandbox requires Craft CMS 5.x 10 | 11 | ## Installation 12 | 13 | To install Craft Twig Sandbox, follow these steps: 14 | 15 | 1. Open your terminal and go to your Craft project: 16 | 17 | cd /path/to/project 18 | 19 | 2. Then tell Composer to require the package: 20 | 21 | composer require nystudio107/craft-twig-sandbox 22 | 23 | ## About Craft Twig Sandbox 24 | 25 | Rather than just creating a new Twig `Environment` for the sandbox, Craft Twig Sandbox sub-classes the Craft `View` class, which has a few benefits: 26 | 27 | * You get all of the Craft provided tags, filters, functions, objects, globals, etc. available to you if you want 28 | * Plugin-provided tags, filters, functions, and objects are available if you want 29 | * You get access to the familiar `.renderObjectTemplate()`, `.renderString()`, `.renderPageTemplate()` and `.renderTemplate()` methods 30 | * All of the normal Craft events and scaffolding related to template rendering are present as well 31 | 32 | It also implements an `ErrorHandler` that sub-classes the Craft `ErrorHandler` which is used to handle exceptions that happen when rendering Twig templates. This allows you to optionally display exceptions such as: 33 | 34 | ``` 35 | Twig\Sandbox\SecurityNotAllowedFunctionError 36 | Function "dump" is not allowed in "__string_template__b0120324b463b0e0d2c2618b7c5ce3ba" at line 1. 37 | ``` 38 | 39 | ## Using Craft Twig Sandbox 40 | 41 | In its simplest form, you can create a Twig Sandbox like so: 42 | 43 | ```php 44 | use nystudio107\crafttwigsandbox\web\SandboxView; 45 | 46 | $sandboxView = new SandboxView(); 47 | ``` 48 | 49 | This will create a new `SandboxView` that works just like the Craft web `View` class so you can use any of the `View` render methods for Twig templates: 50 | ```php 51 | $result = $sandboxView->renderString(); 52 | $result = $sandboxView->renderObjectTemplate(); 53 | $result = $sandboxView->renderPageTemplate(); 54 | $result = $sandboxView->renderTemplate(); 55 | ``` 56 | 57 | ...and they will be rendered using the default `BlacklistSecurityPolicy` so blacklisted Twig tags, filters, functions, and object methods/properties will not be allowed. 58 | 59 | If any tags, filters, functions, or object methods/properties are used that are not allowed by the security policy, a `SecurityError` exception will be thrown. 60 | 61 | **N.B.:** For performance reasons, you should create a `SandboxView` once, and use it throughout your application's lifecycle, rather than re-creating it every time you want to render Twig using it. 62 | 63 | ### Exception handling 64 | 65 | Note that in the above example, exceptions will be thrown if the security policy is violated; so you can handle the exception yourself if you like: 66 | 67 | ```php 68 | use nystudio107\crafttwigsandbox\web\SandboxView; 69 | use Twig\Sandbox\SecurityError; 70 | 71 | $sandboxView = new SandboxView(); 72 | try { 73 | $result = $sandboxView->renderTemplate(); 74 | } catch (\Throwable $e) { 75 | // If this is a Twig Runtime exception, use the previous one instead 76 | if ($e instanceof SecurityError && ($previousException = $e->getPrevious()) !== null) { 77 | $e = $previousException; 78 | } 79 | // Exception handling here 80 | } 81 | ``` 82 | 83 | Or if you want to use Craft's default web/console exception handling when rendering templates, you can do that like this: 84 | 85 | ```php 86 | use nystudio107\crafttwigsandbox\web\SandboxView; 87 | 88 | $sandboxView = new SandboxView(); 89 | try { 90 | $result = $sandboxView->renderTemplate(); 91 | } catch (\Throwable $e) { 92 | $sandboxView->sandboxErrorHandler->handleException($e) 93 | } 94 | ``` 95 | 96 | ...and the exception with a full stack trace will be displayed in the web browser, or in the console (depending on the type of the current request). 97 | 98 | ### BlacklistSecurityPolicy 99 | 100 | The `BlacklistSecurityPolicy` is a `SecurityPolicy` that specifies the Twig tags, filters, functions, and object methods/properties that **are not** allowed. 101 | 102 | It defaults to [reasonable subset of blacklisted](https://github.com/nystudio107/craft-twig-sandbox/blob/develop-v5/src/twig/BlacklistSecurityPolicy.php#L19) Twig tags, filters, and functions, but you can customize it as you see fit: 103 | 104 | ```php 105 | use nystudio107\crafttwigsandbox\twig\BlacklistSecurityPolicy; 106 | use nystudio107\crafttwigsandbox\web\SandboxView; 107 | 108 | $securityPolicy = new BlacklistSecurityPolicy([ 109 | 'twigTags' => ['import'], 110 | 'twigFilters' => ['base64_decode', 'base64_encode'], 111 | 'twigFunctions' => ['dump'], 112 | ]); 113 | $sandboxView = new SandboxView(['securityPolicy' => $securityPolicy]); 114 | $result = $sandboxView->renderString("{{ dump() }}", []); 115 | ``` 116 | 117 | You can also control what object methods and properties are allowed to be accessed. By default, the `BlacklistSecurityPolicy` does not restrict access to any object methods or properties. 118 | 119 | For example, if you didn't want people to be able to access the `password` property of the `DbConfig` object via: 120 | 121 | ```twig 122 | {{ craft.app.config.db.password }} 123 | ``` 124 | or 125 | ```twig 126 | {{ craft.app.getConfig().getDb().password }} 127 | ``` 128 | ...you would do: 129 | 130 | ```php 131 | use craft\config\DbConfig; 132 | use nystudio107\crafttwigsandbox\twig\BlacklistSecurityPolicy; 133 | use nystudio107\crafttwigsandbox\web\SandboxView; 134 | 135 | $securityPolicy = new BlacklistSecurityPolicy([ 136 | 'twigProperties' => [ 137 | DbConfig::class => ['password'] 138 | ], 139 | 'twigMethods' => [ 140 | DbConfig::class => ['getPassword'] 141 | ], 142 | ]); 143 | $sandboxView = new SandboxView(['securityPolicy' => $securityPolicy]); 144 | $result = $sandboxView->renderString("{{ craft.app.config.db.password }}", []); 145 | ``` 146 | 147 | If you don't want any properties or methods to be able to be accessed on a given object, you can pass in a `*` wildcard: 148 | 149 | ```php 150 | 'twigProperties' => [ 151 | DbConfig::class => '*' 152 | ], 153 | 'twigMethods' => [ 154 | DbConfig::class => '*' 155 | ], 156 | ``` 157 | 158 | ### WhitelistSecurityPolicy 159 | 160 | The `WhitelistSecurityPolicy` is a `SecurityPolicy` that specifies the Twig tags, filters, functions, and object methods/properties that **are** allowed. 161 | 162 | It defaults to [reasonable subset of whitelisted](https://github.com/nystudio107/craft-twig-sandbox/blob/develop-v5/src/twig/WhitelistSecurityPolicy.php#L19) Twig tags, filters, functions, and object methods/properties, but you can customize it as you see fit: 163 | 164 | ```php 165 | use nystudio107\crafttwigsandbox\twig\WhitelistSecurityPolicy; 166 | use nystudio107\crafttwigsandbox\web\SandboxView; 167 | 168 | $securityPolicy = new WhitelistSecurityPolicy([ 169 | 'twigTags' => ['for', 'if'], 170 | 'twigFilters' => ['replace', 'sort'], 171 | 'twigFunctions' => ['date', 'random'], 172 | ]); 173 | $sandboxView = new SandboxView(['securityPolicy' => $securityPolicy]); 174 | $result = $sandboxView->renderString("{{ dump() }}", []); 175 | ``` 176 | 177 | You can also control what object methods and properties are allowed to be accessed. By default, the `WhitelistSecurityPolicy` restricts access to all object methods or properties. 178 | 179 | That means you must explicitly specify each object property or method. 180 | 181 | For example, if you wanted to grant access to: 182 | 183 | ```twig 184 | {{ craft.app.config.general.devMode }} 185 | ``` 186 | or 187 | ```twig 188 | {{ craft.app.getConfig().getGeneral().getDevMode() }} 189 | ``` 190 | ...you would do: 191 | 192 | ```php 193 | use craft\config\GeneralConfig; 194 | use craft\services\Config; 195 | use craft\web\Application; 196 | use craft\web\twig\variables\CraftVariable; 197 | use nystudio107\crafttwigsandbox\twig\WhitelistSecurityPolicy; 198 | use nystudio107\crafttwigsandbox\web\SandboxView; 199 | 200 | $securityPolicy = new WhitelistSecurityPolicy([ 201 | 'twigProperties' => [ 202 | CraftVariable::class => ['app'], 203 | Application::class => ['config'], 204 | Config::class => ['general'], 205 | GeneralConfig::class => ['devMode'], 206 | ] 207 | 'twigMethods' => [ 208 | Application::class => ['getConfig'], 209 | Config::class => ['getGeneral'], 210 | ], 211 | ]); 212 | $sandboxView = new SandboxView(['securityPolicy' => $securityPolicy]); 213 | $result = $sandboxView->renderString("{{ craft.app.config.general.devMode }}", []); 214 | ``` 215 | 216 | If you want all properties or methods to be able to be accessed on a given object, you can pass in a `*` wildcard: 217 | 218 | ```php 219 | 'twigProperties' => [ 220 | DbConfig::class => '*' 221 | ], 222 | 'twigMethods' => [ 223 | DbConfig::class => '*' 224 | ], 225 | ``` 226 | 227 | ### SecurityPolicy from a config file 228 | 229 | Often you'll want to provide a sane Twig sandbox, but also allow your users to add or remove from the policy as they see fit. 230 | 231 | To make this easy to do, there is a `SecurityPolicy::createFromFile()` helper method to create a sandbox security policy from a config file: 232 | ```php 233 | public static function createFromFile(string $filePath, ?string $alias = null): BaseSecurityPolicy 234 | ``` 235 | 236 | You pass it in a `$filePath`, and it will look for a file of that name (with `.php` added to the end of it) in the `craft/config/` directory. If no file is found, it will then also try to resolve the optional `$alias` and look for the file in that directory. 237 | 238 | If the file still is not found, it will return a default `BlacklistSecurityPolicy`. 239 | 240 | The config file is a standard [Yii2 Object Configuration file](https://www.yiiframework.com/doc/guide/2.0/en/concept-configurations). 241 | 242 | Example files you can copy & rename exists in the `craft-twig-standbox` codebase in `src/config/`, as `blacklist-sandbox.php` and `whitelist-sandbox-php`. 243 | 244 | These are the default files that are used to create the respective security policies when you allocate a new `BlacklistSecurityPolicy` or `WhitelistSecurityPolicy`, and pass in no object configuration. 245 | 246 | So for a practical example, the author of the SEOmatic plugin would copy the `config/blacklist-sandbox.php` file to that plugin's `src/` directory as `seomatic-sandbox.php`, and put in any customizations that they might want there. 247 | 248 | Then they could direct their users to copy the `seomatic-sandbox.php` file to their `craft/config/` directory if they wanted to make any customizations to it. 249 | 250 | Then to create the sandbox view in the plugin, they would do: 251 | 252 | ```php 253 | use nystudio107\crafttwigsandbox\helpers\SecurityPolicy; 254 | 255 | $securityPolicy = SecurityPolicy::createFromFile('seomatic-sandbox', '@nystudio107/seomatic'); 256 | $sandboxView = new SandboxView(['securityPolicy' => $securityPolicy]); 257 | ``` 258 | 259 | This will cause it to create the sandbox from the `seomatic-sandbox.php` file in the `craft/config/` directory (if it exists), and if it does not exist, it will load the config file from the `seomatic-sandbox.php` in the `@nystudio107/seomatic` directory (which points to the plugin's source). 260 | 261 | Craft automatically creates a namespaced alias for each plugin. 262 | 263 | ### Adding TwigExtensions 264 | 265 | By default, the Twig `SandboxView` will only have the Twig extensions in it that Craft itself registers. Any Twig extensions that are added by plugins or modules will not be present. 266 | 267 | This is because there is no "Register Twig Extensions" event sent by Craft that plugins are modules can listen for, so the `SandboxView` has no way to tell each plugin or module to register their Twig extensions in the Twig `SandboxView`. 268 | 269 | Instead, if you have Twig extensions that you want added to your Twig `SandboxView`, you can either do it manually: 270 | 271 | ```php 272 | $sandboxView = new SandboxView(); 273 | $sandboxView->registerTwigExtension(new MyTwigExtension()); 274 | ``` 275 | 276 | ...or you can pass in an array of class names to the constructor, and have the Twig `SandboxView` instantiate the Twig extensions for you: 277 | 278 | ```php 279 | $sandboxView = new SandboxView(['twigExtensionClasses' => [ 280 | MyTwigExtension::class, 281 | AnotherTwigExtension::class, 282 | ]]); 283 | ``` 284 | 285 | ### Custom SecurityPolicy 286 | 287 | You can also create your own custom `SecurityPolicy` to use, it just needs to conform to the Twig [`SecurityPolicyInterface`](https://github.com/twigphp/Twig/blob/3.x/src/Sandbox/SecurityPolicyInterface.php): 288 | 289 | ```php 290 | use my\custom\SecurityPolicy; 291 | use nystudio107\crafttwigsandbox\web\SandboxView; 292 | 293 | $securityPolicy = new SecurityPolicy([ 294 | ]); 295 | $sandboxView = new SandboxView(['securityPolicy' => $securityPolicy]); 296 | $result = $sandboxView->renderString("{{ dump() }}", []); 297 | ``` 298 | 299 | ### Adding a SandboxView via `config/app.php` 300 | 301 | If you want to make a Twig sandbox available globally in your Craft application, you can add the following to your `config/app.php`: 302 | 303 | ```php 304 | use craft\config\DbConfig; 305 | use nystudio107\crafttwigsandbox\twig\BlacklistSecurityPolicy; 306 | use nystudio107\crafttwigsandbox\web\SandboxView; 307 | 308 | return [ 309 | // ... 310 | 'components' => [ 311 | 'sandboxView' => [ 312 | 'class' => SandboxView::class, 313 | 'securityPolicy' => new BlacklistSecurityPolicy([ 314 | 'twigProperties' => [ 315 | DbConfig::class => '*' 316 | ], 317 | 'twigMethods' => [ 318 | DbConfig::class => '*' 319 | ], 320 | ]), 321 | ], 322 | ], 323 | ]; 324 | ``` 325 | 326 | This will create a globally available component that you can use via: 327 | ```php 328 | Craft::$app->sandboxView->renderString('{% set password = craft.app.getConfig().getDb().password("") %}'); 329 | ``` 330 | 331 | You can even globally replace the default Craft `view` with a `SandboxView` if you want: 332 | 333 | ```php 334 | return [ 335 | // ... 336 | 'components' => [ 337 | 'view' => [ 338 | 'class' => SandboxView::class, 339 | 'securityPolicy' => new BlacklistSecurityPolicy([ 340 | 'twigProperties' => [ 341 | DbConfig::class => '*' 342 | ], 343 | 'twigMethods' => [ 344 | DbConfig::class => '*' 345 | ], 346 | ]), 347 | ], 348 | ], 349 | ]; 350 | ``` 351 | 352 | ## Craft Twig Sandbox Roadmap 353 | 354 | Brought to you by [nystudio107](https://nystudio107.com/) 355 | --------------------------------------------------------------------------------