diff --git a/composer.lock b/composer.lock index 1e6c5433edd..dd75c2b7b8b 100644 --- a/composer.lock +++ b/composer.lock @@ -535,7 +535,7 @@ "dist": { "type": "path", "url": "core", - "reference": "8d44b7ebb7fcecab239633915529ccd5f776e475" + "reference": "dfeea1e2ff4bfda2a0f64d1243d365595b164d59" }, "require": { "asm89/stack-cors": "^1.1", @@ -580,7 +580,7 @@ "symfony/translation": "^4.4", "symfony/validator": "^4.4", "symfony/yaml": "^4.4.19", - "twig/twig": "^2.12.0", + "twig/twig": "^2.15.3", "typo3/phar-stream-wrapper": "^3.1.3" }, "conflict": { @@ -4469,16 +4469,16 @@ }, { "name": "twig/twig", - "version": "v2.14.11", + "version": "v2.15.3", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "66baa66f29ee30e487e05f1679903e36eb01d727" + "reference": "ab402673db8746cb3a4c46f3869d6253699f614a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/66baa66f29ee30e487e05f1679903e36eb01d727", - "reference": "66baa66f29ee30e487e05f1679903e36eb01d727", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/ab402673db8746cb3a4c46f3869d6253699f614a", + "reference": "ab402673db8746cb3a4c46f3869d6253699f614a", "shasum": "" }, "require": { @@ -4494,7 +4494,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.14-dev" + "dev-master": "2.15-dev" } }, "autoload": { @@ -4533,7 +4533,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v2.14.11" + "source": "https://github.com/twigphp/Twig/tree/v2.15.3" }, "funding": [ { @@ -4545,7 +4545,7 @@ "type": "tidelift" } ], - "time": "2022-02-04T06:57:25+00:00" + "time": "2022-09-28T08:40:08+00:00" }, { "name": "typo3/phar-stream-wrapper", diff --git a/composer/Metapackage/CoreRecommended/composer.json b/composer/Metapackage/CoreRecommended/composer.json index c4dc926540a..af585589ed0 100644 --- a/composer/Metapackage/CoreRecommended/composer.json +++ b/composer/Metapackage/CoreRecommended/composer.json @@ -61,7 +61,7 @@ "symfony/validator": "v4.4.35", "symfony/var-dumper": "v5.4.0", "symfony/yaml": "v4.4.34", - "twig/twig": "v2.14.11", + "twig/twig": "v2.15.3", "typo3/phar-stream-wrapper": "v3.1.7" } } diff --git a/core/assets/scaffold/files/default.services.yml b/core/assets/scaffold/files/default.services.yml index ff6797d954c..cdeb41fc498 100644 --- a/core/assets/scaffold/files/default.services.yml +++ b/core/assets/scaffold/files/default.services.yml @@ -93,6 +93,21 @@ parameters: # Disabling the Twig cache is not recommended in production environments. # @default true cache: true + # File extensions: + # + # List of file extensions the Twig system is allowed to load via the + # twig.loader.filesystem service. Files with other extensions will not be + # loaded unless they are added here. For example, to allow a file named + # 'example.partial' to be loaded, add 'partial' to this list. To load files + # with no extension, add an empty string '' to the list. + # + # @default ['css', 'html', 'js', 'svg', 'twig'] + allowed_file_extensions: + - css + - html + - js + - svg + - twig renderer.config: # Renderer required cache contexts: # diff --git a/core/composer.json b/core/composer.json index 8ccce492e77..5ac11669ceb 100644 --- a/core/composer.json +++ b/core/composer.json @@ -33,7 +33,7 @@ "symfony/polyfill-php80": "^1.16", "symfony/yaml": "^4.4.19", "typo3/phar-stream-wrapper": "^3.1.3", - "twig/twig": "^2.12.0", + "twig/twig": "^2.15.3", "doctrine/reflection": "^1.1", "doctrine/annotations": "^1.12", "guzzlehttp/guzzle": "^6.5.8", diff --git a/core/core.services.yml b/core/core.services.yml index 0fa5ab98a3a..232bec113b0 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -15,6 +15,12 @@ parameters: debug: false auto_reload: null cache: true + allowed_file_extensions: + - css + - html + - js + - svg + - twig renderer.config: required_cache_contexts: ['languages:language_interface', 'theme', 'user.permissions'] auto_placeholder_conditions: @@ -1659,7 +1665,7 @@ services: # We use '.' instead of '%app.root%' as the path for non-namespaced template # files so that they match the relative paths of templates loaded via the # theme registry or via Twig namespaces. - arguments: ['.', '@module_handler', '@theme_handler'] + arguments: ['.', '@module_handler', '@theme_handler', '%twig.config%'] tags: - { name: twig.loader, priority: 100 } twig.loader.theme_registry: diff --git a/core/lib/Drupal.php b/core/lib/Drupal.php index 52643134bc0..6497742de13 100644 --- a/core/lib/Drupal.php +++ b/core/lib/Drupal.php @@ -75,7 +75,7 @@ class Drupal { /** * The current system version. */ - const VERSION = '9.3.22-dev'; + const VERSION = '9.3.23-dev'; /** * Core API compatibility. diff --git a/core/lib/Drupal/Core/Template/Loader/FilesystemLoader.php b/core/lib/Drupal/Core/Template/Loader/FilesystemLoader.php index 1864b3e128c..127a94ff031 100644 --- a/core/lib/Drupal/Core/Template/Loader/FilesystemLoader.php +++ b/core/lib/Drupal/Core/Template/Loader/FilesystemLoader.php @@ -4,6 +4,7 @@ namespace Drupal\Core\Template\Loader; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Extension\ThemeHandlerInterface; +use Twig\Error\LoaderError; use Twig\Loader\FilesystemLoader as TwigFilesystemLoader; /** @@ -15,6 +16,13 @@ use Twig\Loader\FilesystemLoader as TwigFilesystemLoader; */ class FilesystemLoader extends TwigFilesystemLoader { + /** + * Allowed file extensions. + * + * @var string[] + */ + protected $allowedFileExtensions = ['css', 'html', 'js', 'svg', 'twig']; + /** * Constructs a new FilesystemLoader object. * @@ -24,8 +32,10 @@ class FilesystemLoader extends TwigFilesystemLoader { * The module handler service. * @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler * The theme handler service. + * @param mixed[] $twig_config + * Twig configuration from the service container. */ - public function __construct($paths, ModuleHandlerInterface $module_handler, ThemeHandlerInterface $theme_handler) { + public function __construct($paths, ModuleHandlerInterface $module_handler, ThemeHandlerInterface $theme_handler, array $twig_config = []) { parent::__construct($paths); // Add namespaced paths for modules and themes. @@ -39,6 +49,15 @@ class FilesystemLoader extends TwigFilesystemLoader { foreach ($namespaces as $name => $path) { $this->addPath($path . '/templates', $name); + // Allow accessing the root of an extension by using the namespace without + // using directory traversal from the `/templates` directory. + $this->addPath($path, $name); + } + if (!empty($twig_config['allowed_file_extensions'])) { + // Provide a safe fallback for sites that have not updated their + // services.yml file or rebuilt the container, as well as for child + // classes. + $this->allowedFileExtensions = $twig_config['allowed_file_extensions']; } } @@ -56,4 +75,38 @@ class FilesystemLoader extends TwigFilesystemLoader { $this->paths[$namespace][] = rtrim($path, '/\\'); } + /** + * {@inheritdoc} + */ + protected function findTemplate($name, $throw = TRUE) { + $extension = pathinfo($name, PATHINFO_EXTENSION); + if (!in_array($extension, $this->allowedFileExtensions, TRUE)) { + if (!$throw) { + return NULL; + } + // Customize the list of extensions if no file extension is allowed. + $extensions = $this->allowedFileExtensions; + $no_extension = array_search('', $extensions, TRUE); + if (is_int($no_extension)) { + unset($extensions[$no_extension]); + $extensions[] = 'or no file extension'; + } + if (empty($extension)) { + $extension = 'no file extension'; + } + throw new LoaderError(sprintf("Template %s has an invalid file extension (%s). Only templates ending in one of %s are allowed. Set the twig.config.allowed_file_extensions container parameter to customize the allowed file extensions", $name, $extension, implode(', ', $extensions))); + } + + // Previously it was possible to access files in the parent directory of a + // namespace. This was removed in Twig 2.15.3. In order to support backwards + // compatibility, we are adding path directory as a namespace, and therefore + // we can remove the directory traversal from the name. + // @todo deprecate this functionality for removal in Drupal 11. + if (preg_match('/(^\@[^\/]+\/)\.\.\/(.*)/', $name, $matches)) { + $name = $matches[1] . $matches[2]; + } + + return parent::findTemplate($name, $throw); + } + } diff --git a/core/modules/help_topics/src/HelpTopicTwigLoader.php b/core/modules/help_topics/src/HelpTopicTwigLoader.php index cdfeaf616f4..aa6c8c4a04c 100644 --- a/core/modules/help_topics/src/HelpTopicTwigLoader.php +++ b/core/modules/help_topics/src/HelpTopicTwigLoader.php @@ -95,4 +95,18 @@ class HelpTopicTwigLoader extends FilesystemLoader { return new Source($contents, $name, $path); } + /** + * {@inheritdoc} + */ + protected function findTemplate($name, $throw = TRUE) { + if (!str_ends_with($name, '.html.twig')) { + if (!$throw) { + return NULL; + } + $extension = pathinfo($name, PATHINFO_EXTENSION); + throw new LoaderError(sprintf("Help topic %s has an invalid file extension (%s). Only help topics ending .html.twig are allowed.", $name, $extension)); + } + return parent::findTemplate($name, $throw); + } + } diff --git a/core/modules/system/tests/src/Kernel/Theme/TwigIncludeTest.php b/core/modules/system/tests/src/Kernel/Theme/TwigIncludeTest.php new file mode 100644 index 00000000000..c97e606ad1d --- /dev/null +++ b/core/modules/system/tests/src/Kernel/Theme/TwigIncludeTest.php @@ -0,0 +1,157 @@ +enableModules(['system']); + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + + $element['test'] = [ + '#type' => 'inline_template', + '#template' => "{% include '@system/container.html.twig' %}", + ]; + $this->assertEquals("
\n", $renderer->renderRoot($element)); + + // Test that SQL files cannot be included in Twig templates by default. + $element = []; + $element['test'] = [ + '#type' => 'inline_template', + '#template' => "{% include '@__main__\/core/tests/fixtures/files/sql-2.sql' %}", + ]; + try { + $renderer->renderRoot($element); + $this->fail('Expected exception not thrown'); + } + catch (LoaderError $e) { + $this->assertStringContainsString('Template "@__main__/core/tests/fixtures/files/sql-2.sql" is not defined', $e->getMessage()); + } + /** @var \Drupal\Core\Template\Loader\FilesystemLoader $loader */ + $loader = \Drupal::service('twig.loader.filesystem'); + try { + $loader->getSourceContext('@__main__\/core/tests/fixtures/files/sql-2.sql'); + $this->fail('Expected exception not thrown'); + } + catch (LoaderError $e) { + $this->assertStringContainsString('Template @__main__\/core/tests/fixtures/files/sql-2.sql has an invalid file extension (sql). Only templates ending in one of css, html, js, svg, twig are allowed. Set the twig.config.allowed_file_extensions container parameter to customize the allowed file extensions', $e->getMessage()); + } + + // Allow SQL files to be included. + $twig_config = $this->container->getParameter('twig.config'); + $twig_config['allowed_file_extensions'][] = 'sql'; + $this->twigConfig = $twig_config; + $this->container->get('kernel')->shutdown(); + $this->container->get('kernel')->boot(); + /** @var \Drupal\Core\Template\Loader\FilesystemLoader $loader */ + $loader = \Drupal::service('twig.loader.filesystem'); + $source = $loader->getSourceContext('@__main__\/core/tests/fixtures/files/sql-2.sql'); + $this->assertSame(file_get_contents('core/tests/fixtures/files/sql-2.sql'), $source->getCode()); + + // Test the fallback to the default list of extensions provided by the + // class. + $this->assertSame(['css', 'html', 'js', 'svg', 'twig', 'sql'], \Drupal::getContainer()->getParameter('twig.config')['allowed_file_extensions']); + unset($twig_config['allowed_file_extensions']); + $this->twigConfig = $twig_config; + $this->container->get('kernel')->shutdown(); + $this->container->get('kernel')->boot(); + $this->assertArrayNotHasKey('allowed_file_extensions', \Drupal::getContainer()->getParameter('twig.config')); + /** @var \Drupal\Core\Template\Loader\FilesystemLoader $loader */ + $loader = \Drupal::service('twig.loader.filesystem'); + try { + $loader->getSourceContext('@__main__\/core/tests/fixtures/files/sql-2.sql'); + $this->fail('Expected exception not thrown'); + } + catch (LoaderError $e) { + $this->assertStringContainsString('Template @__main__\/core/tests/fixtures/files/sql-2.sql has an invalid file extension (sql). Only templates ending in one of css, html, js, svg, twig are allowed. Set the twig.config.allowed_file_extensions container parameter to customize the allowed file extensions', $e->getMessage()); + } + + // Test a file with no extension. + file_put_contents($this->siteDirectory . '/test_file', 'This is a test!'); + /** @var \Drupal\Core\Template\Loader\FilesystemLoader $loader */ + $loader = \Drupal::service('twig.loader.filesystem'); + try { + $loader->getSourceContext('@__main__\/' . $this->siteDirectory . '/test_file'); + $this->fail('Expected exception not thrown'); + } + catch (LoaderError $e) { + $this->assertStringContainsString('test_file has an invalid file extension (no file extension). Only templates ending in one of css, html, js, svg, twig are allowed. Set the twig.config.allowed_file_extensions container parameter to customize the allowed file extensions', $e->getMessage()); + } + + // Allow files with no extension. + $twig_config['allowed_file_extensions'] = ['twig', '']; + $this->twigConfig = $twig_config; + $this->container->get('kernel')->shutdown(); + $this->container->get('kernel')->boot(); + /** @var \Drupal\Core\Template\Loader\FilesystemLoader $loader */ + $loader = \Drupal::service('twig.loader.filesystem'); + $source = $loader->getSourceContext('@__main__\/' . $this->siteDirectory . '/test_file'); + $this->assertSame('This is a test!', $source->getCode()); + + // Ensure the error message makes sense when no file extension is allowed. + try { + $loader->getSourceContext('@__main__\/core/tests/fixtures/files/sql-2.sql'); + $this->fail('Expected exception not thrown'); + } + catch (LoaderError $e) { + $this->assertStringContainsString('Template @__main__\/core/tests/fixtures/files/sql-2.sql has an invalid file extension (sql). Only templates ending in one of twig, or no file extension are allowed. Set the twig.config.allowed_file_extensions container parameter to customize the allowed file extensions', $e->getMessage()); + } + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + if (!empty($this->twigConfig)) { + $container->setParameter('twig.config', $this->twigConfig); + } + } + + /** + * {@inheritdoc} + */ + protected function setUpFilesystem(): void { + // Use a real file system and not VFS so that we can include files from the + // site using @__main__ in a template. + $public_file_directory = $this->siteDirectory . '/files'; + $private_file_directory = $this->siteDirectory . '/private'; + + mkdir($this->siteDirectory, 0775); + mkdir($this->siteDirectory . '/files', 0775); + mkdir($this->siteDirectory . '/private', 0775); + mkdir($this->siteDirectory . '/files/config/sync', 0775, TRUE); + + $this->setSetting('file_public_path', $public_file_directory); + $this->setSetting('file_private_path', $private_file_directory); + $this->setSetting('config_sync_directory', $this->siteDirectory . '/files/config/sync'); + } + +} diff --git a/sites/default/default.services.yml b/sites/default/default.services.yml index ff6797d954c..cdeb41fc498 100644 --- a/sites/default/default.services.yml +++ b/sites/default/default.services.yml @@ -93,6 +93,21 @@ parameters: # Disabling the Twig cache is not recommended in production environments. # @default true cache: true + # File extensions: + # + # List of file extensions the Twig system is allowed to load via the + # twig.loader.filesystem service. Files with other extensions will not be + # loaded unless they are added here. For example, to allow a file named + # 'example.partial' to be loaded, add 'partial' to this list. To load files + # with no extension, add an empty string '' to the list. + # + # @default ['css', 'html', 'js', 'svg', 'twig'] + allowed_file_extensions: + - css + - html + - js + - svg + - twig renderer.config: # Renderer required cache contexts: #