diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 6b9d67dd26..dc7179f0de 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -15,7 +15,7 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} default_bump: patch - custom_tag: 1.1.0 + custom_tag: 1.2.0 - name: Create a GitHub release uses: ncipollo/release-action@v1 with: diff --git a/README.md b/README.md index 8145eee70f..51dff561c5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ It means that composer will look at `master` branch of repository configured und ## Changelog +### 2026-08-26 + +- Security: backported the view template path containment check from CakePHP 4.5.11 (CVE-2026-48820 / GHSA-wpvj-hjcr-h3p2). Element / view / layout names resolving outside the configured view template paths now throw `InvalidArgumentException`. BC note: `elementExists()` now throws for such names instead of returning `false`. + ### 2025-02-04 - Fixes for PHP 8.4: `session_set_save_handler` accepts object, removed `E_STRICT` reference. diff --git a/lib/Cake/Test/Case/View/ViewTest.php b/lib/Cake/Test/Case/View/ViewTest.php index 2b0f808e5a..fc4d726784 100644 --- a/lib/Cake/Test/Case/View/ViewTest.php +++ b/lib/Cake/Test/Case/View/ViewTest.php @@ -161,6 +161,16 @@ public function getLayoutFileName($name = null) { return $this->_getLayoutFileName($name); } +/** + * getElementFileName method + * + * @param string $name The name of the element to find. + * @return mixed Either a string to the element filename or false when one can't be found. + */ + public function getElementFileName($name) { + return $this->_getElementFileName($name); + } + /** * paths method * @@ -948,6 +958,78 @@ public function testElementCtpFallback() { $this->assertEquals($expected, $result); } +/** + * Test that elements outside of the view paths cannot be rendered. + * + * @return void + */ + public function testElementPathEscape() { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('it is not within any view template path.'); + $this->View->element('../../../../Console/Templates/default/views/index'); + } + +/** + * Test that relative paths resolving inside the Elements directory still work. + * + * @return void + */ + public function testElementPathRelativeInsideElements() { + $result = $this->View->element('nocache/../test_element'); + $this->assertSame('this is the test element', $result); + } + +/** + * Test that relative paths leaving Elements but staying inside a view path work. + * + * @return void + */ + public function testElementPathRelativeInsideViewPath() { + $View = new TestView($this->PostsController); + $file = $View->getElementFileName('../Posts/index'); + $expected = CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS . 'Posts' . DS . 'index.ctp'; + $this->assertSame(realpath($expected), $file); + + $result = $View->element('../Posts/index'); + $this->assertSame('posts index', $result); + } + +/** + * Test that layouts outside of the view paths cannot be resolved. + * + * @return void + */ + public function testLayoutPathEscape() { + $View = new TestView($this->PostsController); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('it is not within any view template path.'); + $View->getLayoutFileName('../../../../Console/Templates/default/views/index'); + } + +/** + * Test that views outside of the view paths cannot be resolved. + * + * @return void + */ + public function testViewPathEscape() { + $View = new TestView($this->PostsController); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('it is not within any view template path.'); + $View->getViewFileName('../../../../Console/Templates/default/views/index'); + } + +/** + * Test that the leading-slash view name branch cannot escape the view paths. + * + * @return void + */ + public function testViewPathEscapeLeadingSlash() { + $View = new TestView($this->PostsController); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('it is not within any view template path.'); + $View->getViewFileName('/../../../Console/Templates/default/views/index'); + } + /** * Test loadHelpers method * diff --git a/lib/Cake/View/ScaffoldView.php b/lib/Cake/View/ScaffoldView.php index 63f986aa90..776cd9d2da 100644 --- a/lib/Cake/View/ScaffoldView.php +++ b/lib/Cake/View/ScaffoldView.php @@ -34,6 +34,7 @@ class ScaffoldView extends View { * @param string $name name of the view file to get. * @return string action * @throws MissingViewException + * @throws InvalidArgumentException when the resolved file is outside the view paths. */ protected function _getViewFileName($name = null) { if ($name === null) { @@ -75,7 +76,7 @@ protected function _getViewFileName($name = null) { foreach ($paths as $path) { foreach ($names as $name) { if (file_exists($path . $name . $ext)) { - return $path . $name . $ext; + return $this->_checkFilePath($path . $name . $ext, $this->plugin); } } } diff --git a/lib/Cake/View/View.php b/lib/Cake/View/View.php index 2b7410f660..754736cd53 100644 --- a/lib/Cake/View/View.php +++ b/lib/Cake/View/View.php @@ -994,6 +994,7 @@ public function loadHelper($helperName, $settings = array()) { * @param string $name Controller action to find template filename for * @return string Template filename * @throws MissingViewException when a view file could not be found. + * @throws InvalidArgumentException when the resolved file is outside the view paths. */ protected function _getViewFileName($name = null) { $subDir = null; @@ -1024,7 +1025,7 @@ protected function _getViewFileName($name = null) { foreach ($exts as $ext) { foreach ($paths as $path) { if (file_exists($path . $name . $ext)) { - return $path . $name . $ext; + return $this->_checkFilePath($path . $name . $ext, $plugin); } } } @@ -1059,6 +1060,7 @@ public function pluginSplit($name, $fallback = true) { * @param string $name The name of the layout to find. * @return string Filename for layout file (.ctp). * @throws MissingLayoutException when a layout cannot be located + * @throws InvalidArgumentException when the resolved file is outside the view paths. */ protected function _getLayoutFileName($name = null) { if ($name === null) { @@ -1077,7 +1079,7 @@ protected function _getLayoutFileName($name = null) { foreach ($exts as $ext) { foreach ($paths as $path) { if (file_exists($path . $file . $ext)) { - return $path . $file . $ext; + return $this->_checkFilePath($path . $file . $ext, $plugin); } } } @@ -1102,6 +1104,7 @@ protected function _getExtensions() { * * @param string $name The name of the element to find. * @return mixed Either a string to the element filename or false when one can't be found. + * @throws InvalidArgumentException when the resolved file is outside the view paths. */ protected function _getElementFileName($name) { list($plugin, $name) = $this->pluginSplit($name); @@ -1111,13 +1114,50 @@ protected function _getElementFileName($name) { foreach ($exts as $ext) { foreach ($paths as $path) { if (file_exists($path . 'Elements' . DS . $name . $ext)) { - return $path . 'Elements' . DS . $name . $ext; + return $this->_checkFilePath($path . 'Elements' . DS . $name . $ext, $plugin); } } } return false; } +/** + * Check that a resolved template path is contained within one of the view paths. + * + * Paths that contain no `..` segment cannot escape the view path they were + * built from, so they are returned as is. Any other path is resolved with + * realpath() and must be contained in one of View::_paths(). The canonical + * path is returned after validation. + * + * @param string $file The resolved path to the template file. + * @param string $plugin The plugin the file was resolved for, if any. + * @return string The unmodified or canonical file path. + * @throws InvalidArgumentException When the file is not within a view path. + */ + protected function _checkFilePath($file, $plugin = null) { + if (strpos($file, '..') === false) { + return $file; + } + $absolute = realpath($file); + if ($absolute === false) { + return $file; + } + foreach ($this->_paths($plugin) as $path) { + if (strpos($absolute, rtrim($path, DS) . DS) === 0) { + return $absolute; + } + $root = realpath($path); + if ($root !== false && strpos($absolute, rtrim($root, DS) . DS) === 0) { + return $absolute; + } + } + throw new InvalidArgumentException(__d( + 'cake_dev', + 'Cannot use "%s" as a template, it is not within any view template path.', + $file + )); + } + /** * Return all possible paths to find view files in order *