Compare commits

..

2 Commits

Author SHA1 Message Date
Simon Wisselink 56cf94dacd changelog / docs 2026-05-03 22:32:41 +02:00
Simon Wisselink a0e2b022a9 add support for testing smarty v4 on php8.5 2026-05-03 22:31:13 +02:00
24 changed files with 37 additions and 383 deletions
+1
View File
@@ -34,6 +34,7 @@ jobs:
- "8.2"
- "8.3"
- "8.4"
- "8.5"
compiler:
- default
-6
View File
@@ -6,12 +6,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [4.5.7] - 2026-06-29
- Security: prevent symlinks inside a trusted `secure_dir`/template directory from being used to read files outside of it (CWE-22 path traversal), affecting `{include}` and `{fetch}` of local files
- Security: `{html_image}` now escapes the `file`, `path_prefix`, `href`/`link`, `width` and `height` attributes (it already escaped `alt` and pass-through attributes), and `{html_select_date}` casts `day_size`/`month_size`/`year_size` to int (matching `{html_select_time}`), preventing untrusted values passed into these attributes from breaking out of the generated HTML (CWE-79)
- Security: `{fetch}` no longer follows HTTP redirects for remote resources while a security policy is active, preventing an open redirect on a trusted host from bypassing `trusted_uri` (CWE-918 server-side request forgery)
## [4.5.6] - 2025-08-26
- Fixed that modifiers called like function would be compiled to modifier name instead of calling the registered callback [#1100](https://github.com/smarty-php/smarty/issues/1100)
- Replace SMARTY_VERSION constant with $smarty.version in debug.tpl [#1073](https://github.com/smarty-php/smarty/issues/1073)
+1 -1
View File
@@ -7,7 +7,7 @@ Smarty is a template engine for PHP, facilitating the separation of presentation
Read the [documentation](https://smarty-php.github.io/smarty/) to find out how to use it.
## Requirements
Smarty can be run with PHP 7.1 to PHP 8.4.
Smarty can be run with PHP 7.1 to PHP 8.5.
## Installation
Smarty versions 3.1.11 or later can be installed with [Composer](https://getcomposer.org/).
+1
View File
@@ -0,0 +1 @@
- PHP8.5 support [#1184](https://github.com/smarty-php/smarty/issues/1184)
+5
View File
@@ -52,3 +52,8 @@ services:
service: base
build:
dockerfile: ./utilities/testrunners/php84/Dockerfile
php85:
extends:
service: base
build:
dockerfile: ./utilities/testrunners/php85/Dockerfile
+1 -1
View File
@@ -1,7 +1,7 @@
# Getting started
## Requirements
Smarty can be run with PHP 7.1 to PHP 8.4.
Smarty can be run with PHP 7.1 to PHP 8.5.
## Installation
Smarty can be installed with [Composer](https://getcomposer.org/).
+1 -1
View File
@@ -107,7 +107,7 @@ class Smarty extends Smarty_Internal_TemplateBase
/**
* smarty version
*/
const SMARTY_VERSION = '4.5.7';
const SMARTY_VERSION = '4.5.6';
/**
* define variable scopes
*/
+1 -18
View File
@@ -191,24 +191,7 @@ function smarty_function_fetch($params, $template)
return;
}
} else {
if ($protocol && isset($template->smarty->security_policy)) {
// Remote resource (e.g. https://) reached through file_get_contents().
// isTrustedUri() only validates the initial URL, but file_get_contents()
// follows redirects by default, so an open redirect on an otherwise
// trusted host could be used to reach a non-trusted target (SSRF).
// Disable redirect-following while a security policy is in effect.
$context = stream_context_create(
array(
'http' => array(
'follow_location' => 0,
'max_redirects' => 1,
),
)
);
$content = @file_get_contents($params[ 'file' ], false, $context);
} else {
$content = @file_get_contents($params[ 'file' ]);
}
$content = @file_get_contents($params[ 'file' ]);
if ($content === false) {
throw new SmartyException("{fetch} cannot read resource '" . $params[ 'file' ] . "'");
}
+3 -8
View File
@@ -75,7 +75,7 @@ function smarty_function_html_image($params, Smarty_Internal_Template $template)
break;
case 'link':
case 'href':
$prefix = '<a href="' . smarty_function_escape_special_chars($_val) . '">';
$prefix = '<a href="' . $_val . '">';
$suffix = '</a>';
break;
default:
@@ -153,11 +153,6 @@ function smarty_function_html_image($params, Smarty_Internal_Template $template)
$width = round($width * $_resize);
$height = round($height * $_resize);
}
// $alt and the pass-through attributes ($extra) are already escaped above;
// escape the remaining value-context params at output time so untrusted
// values cannot break out of the attribute (CWE-79). The unescaped $file/
// $width/$height are still used for getimagesize()/DPI math above.
return $prefix . '<img src="' . smarty_function_escape_special_chars($path_prefix . $file) . '" alt="' . $alt
. '" width="' . smarty_function_escape_special_chars($width) . '" height="'
. smarty_function_escape_special_chars($height) . '"' . $extra . ' />' . $suffix;
return $prefix . '<img src="' . $path_prefix . $file . '" alt="' . $alt . '" width="' . $width . '" height="' .
$height . '"' . $extra . ' />' . $suffix;
}
+3 -7
View File
@@ -131,6 +131,9 @@ function smarty_function_html_select_date($params, Smarty_Internal_Template $tem
case 'day_value_format':
case 'month_format':
case 'month_value_format':
case 'day_size':
case 'month_size':
case 'year_size':
case 'all_extra':
case 'day_extra':
case 'month_extra':
@@ -148,13 +151,6 @@ function smarty_function_html_select_date($params, Smarty_Internal_Template $tem
case 'year_id':
$$_key = (string)$_value;
break;
case 'day_size':
case 'month_size':
case 'year_size':
// numeric HTML size attribute; cast to int (consistent with
// html_select_time) so it cannot break out of size="…" (CWE-79)
$$_key = (int)$_value;
break;
case 'display_days':
case 'display_months':
case 'display_years':
+2 -24
View File
@@ -590,34 +590,12 @@ class Smarty_Security
*/
private function _checkDir($filepath, $dirs)
{
// Resolve the canonical, symlink-free path of the requested file so that
// a symlink located inside a trusted directory cannot be abused to read
// a file outside of it (CWE-22 path traversal). Smarty::_realpath() only
// normalizes the path as a string and does not follow symlinks, so we
// fall back to it only when the file does not yet exist on disk (e.g.
// config/cache paths that are validated before being written).
$realpath = @realpath($filepath);
$resolved = $realpath !== false ? $realpath : $this->smarty->_realpath($filepath, true);
$directory = dirname($resolved) . DIRECTORY_SEPARATOR;
// Canonicalize the trusted directories the same way. This keeps
// legitimate symlinked deployment paths working (e.g. a Capistrano-style
// "current" release symlink, or macOS' /var -> /private/var): both the
// file and the trusted directories are compared after symlinks have been
// resolved.
$trusted = array();
foreach ($dirs as $dir => $unused) {
$trusted[ $dir ] = true;
if (($dirRealpath = @realpath($dir)) !== false) {
$trusted[ rtrim($dirRealpath, '\\/') . DIRECTORY_SEPARATOR ] = true;
}
}
$directory = dirname($this->smarty->_realpath($filepath, true)) . DIRECTORY_SEPARATOR;
$_directory = array();
if (!preg_match('#[\\\\/][.][.][\\\\/]#', $directory)) {
while (true) {
// test if the directory is trusted
if (isset($trusted[ $directory ])) {
if (isset($dirs[ $directory ])) {
return $_directory;
}
// abort if we've reached root
+3 -2
View File
@@ -5,7 +5,7 @@
# - ./run-tests-for-all-php-versions.sh --group 20221124
# - ./run-tests-for-all-php-versions.sh --exclude-group slow
COMPOSE_CMD="mutagen-compose"
COMPOSE_CMD="docker-compose"
$COMPOSE_CMD run --rm php71 ./run-tests.sh $@ && \
$COMPOSE_CMD run --rm php72 ./run-tests.sh $@ && \
@@ -15,4 +15,5 @@ $COMPOSE_CMD run --rm php80 ./run-tests.sh $@ && \
$COMPOSE_CMD run --rm php81 ./run-tests.sh $@ && \
$COMPOSE_CMD run --rm php82 ./run-tests.sh $@ && \
$COMPOSE_CMD run --rm php83 ./run-tests.sh $@ && \
$COMPOSE_CMD run --rm php84 ./run-tests.sh $@
$COMPOSE_CMD run --rm php84 ./run-tests.sh $@ && \
$COMPOSE_CMD run --rm php85 ./run-tests.sh $@
@@ -375,87 +375,6 @@ class SecurityTest extends PHPUnit_Smarty
$this->smarty->display('string:{$smarty.template_object}');
}
/**
* A symlink located inside a trusted secure_dir must not be usable to read
* a file outside of it (CWE-22 path traversal via symlink).
*/
public function testSymlinkEscapeFromSecureDirIsRejected()
{
list($secureDir, $outsideFile) = $this->createSymlinkFixture('secret-outside-content');
$link = $secureDir . DIRECTORY_SEPARATOR . 'finance_doc';
if (!@symlink($outsideFile, $link)) {
$this->markTestSkipped('Unable to create symlinks on this platform');
}
$this->smarty->security_policy->secure_dir = array($secureDir . DIRECTORY_SEPARATOR);
$this->expectException('SmartyException');
$this->expectExceptionMessage('not trusted file path');
// Use forward slashes: backslashes in a double-quoted template string are
// interpreted as escape sequences (\f, \r, ...), which would corrupt a
// Windows path. Forward slashes work on every platform.
$this->smarty->fetch('string:{include file="' . str_replace('\\', '/', $link) . '"}');
}
/**
* A symlink that stays inside the trusted secure_dir must keep working, so
* legitimate (e.g. deployment) symlinks are not broken by the fix above.
*/
public function testSymlinkWithinSecureDirIsAllowed()
{
list($secureDir) = $this->createSymlinkFixture('secret-outside-content');
$target = $secureDir . DIRECTORY_SEPARATOR . 'real.tpl';
file_put_contents($target, 'inside-content');
$link = $secureDir . DIRECTORY_SEPARATOR . 'linked.tpl';
if (!@symlink($target, $link)) {
$this->markTestSkipped('Unable to create symlinks on this platform');
}
$this->smarty->security_policy->secure_dir = array($secureDir . DIRECTORY_SEPARATOR);
// Forward slashes so backslashes in a Windows path are not mistaken for
// escape sequences inside the double-quoted template string.
$this->assertEquals('inside-content', $this->smarty->fetch('string:{include file="' . str_replace('\\', '/', $link) . '"}'));
}
/**
* Builds a temporary directory tree for the symlink tests: a (canonicalized)
* secure directory plus a file located outside of it. The tree is removed in
* tearDown(). Returns array(secureDir, outsideFile).
*/
private function createSymlinkFixture($outsideContent)
{
$base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'smarty_symlink_' . getmypid() . '_' . uniqid();
$secureDir = $base . DIRECTORY_SEPARATOR . 'secure';
mkdir($secureDir, 0777, true);
$outsideFile = $base . DIRECTORY_SEPARATOR . 'outside.txt';
file_put_contents($outsideFile, $outsideContent);
// Canonicalize so secure_dir is symlink-free (sys_get_temp_dir() itself
// may sit under a symlink, e.g. /var -> /private/var on macOS).
$this->symlinkFixtureDir = realpath($base);
return array(realpath($secureDir), realpath($outsideFile));
}
/** @var string|null temp dir created by createSymlinkFixture(), removed in tearDown */
private $symlinkFixtureDir = null;
protected function tearDown(): void
{
if (!empty($this->symlinkFixtureDir) && is_dir($this->symlinkFixtureDir)) {
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->symlinkFixtureDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $entry) {
($entry->isDir() && !$entry->isLink()) ? rmdir($entry->getPathname()) : unlink($entry->getPathname());
}
rmdir($this->symlinkFixtureDir);
$this->symlinkFixtureDir = null;
}
parent::tearDown();
}
}
class mysecuritystaticclass
@@ -1381,7 +1381,7 @@ class CompileBlockExtendsTest extends PHPUnit_Smarty
public function testEscaping()
{
$this->expectException(SmartyException::class);
$this->expectExceptionMessageRegExp('/Unable to load.*/');
$this->expectExceptionMessageMatches('/Unable to load.*/');
$this->assertEquals('hello world', $this->smarty->fetch('escaping.tpl'));
}
@@ -1391,7 +1391,7 @@ class CompileBlockExtendsTest extends PHPUnit_Smarty
public function testEscaping2()
{
$this->expectException(SmartyException::class);
$this->expectExceptionMessageRegExp('/Unable to load.*/');
$this->expectExceptionMessageMatches('/Unable to load.*/');
$this->assertEquals('hello world', $this->smarty->fetch('escaping2.tpl'));
}
@@ -1401,7 +1401,7 @@ class CompileBlockExtendsTest extends PHPUnit_Smarty
public function testEscaping3()
{
$this->expectException(SmartyException::class);
$this->expectExceptionMessageRegExp('/Unable to load.*/');
$this->expectExceptionMessageMatches('/Unable to load.*/');
$this->assertEquals('hello world', $this->smarty->fetch('escaping3.tpl'));
}
@@ -88,7 +88,7 @@ class CompileIncludeTest extends PHPUnit_Smarty
public function testIncludeFilenameEscaping()
{
$this->expectException(SmartyException::class);
$this->expectExceptionMessageRegExp('/Unable to load.*/');
$this->expectExceptionMessageMatches('/Unable to load.*/');
$tpl = $this->smarty->createTemplate('test_include_security.tpl');
$content = $this->smarty->fetch($tpl);
$this->assertEquals("hello world", $content);
@@ -82,99 +82,4 @@ class PluginFunctionFetchTest extends PHPUnit_Smarty
$this->smarty->fetch('string:{fetch file="/templates/../etc/passwd"}');
}
/**
* When a security policy is in effect, {fetch} of a remote resource must not
* follow redirects, otherwise an open redirect on a trusted host could be
* used to bypass trusted_uri and reach an internal target (SSRF, CWE-918).
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function testFetchRemoteDisablesRedirectsUnderSecurity()
{
FetchContextCaptureStreamWrapper::$capturedOptions = null;
stream_wrapper_register('ssrftest', 'FetchContextCaptureStreamWrapper');
try {
$this->smarty->enableSecurity();
$this->smarty->security_policy->trusted_uri[] = '/^ssrftest:\/\/allowed$/';
$result = $this->smarty->fetch('string:{fetch file="ssrftest://allowed/data"}');
$this->assertSame('BODY', $result);
$this->assertIsArray(FetchContextCaptureStreamWrapper::$capturedOptions);
$this->assertArrayHasKey('http', FetchContextCaptureStreamWrapper::$capturedOptions);
$this->assertSame(0, FetchContextCaptureStreamWrapper::$capturedOptions['http']['follow_location']);
$this->assertLessThanOrEqual(1, FetchContextCaptureStreamWrapper::$capturedOptions['http']['max_redirects']);
} finally {
stream_wrapper_unregister('ssrftest');
}
}
/**
* Without a security policy there is no trusted_uri to bypass, so the
* redirect-disabling stream context is not applied (backwards compatible).
*
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function testFetchRemoteKeepsDefaultBehaviorWithoutSecurity()
{
FetchContextCaptureStreamWrapper::$capturedOptions = null;
stream_wrapper_register('ssrftest', 'FetchContextCaptureStreamWrapper');
try {
$result = $this->smarty->fetch('string:{fetch file="ssrftest://allowed/data"}');
$this->assertSame('BODY', $result);
$this->assertSame(array(), FetchContextCaptureStreamWrapper::$capturedOptions);
} finally {
stream_wrapper_unregister('ssrftest');
}
}
}
/**
* Minimal custom stream wrapper used by the fetch SSRF tests: it records the
* stream context options that {fetch} passes to file_get_contents() and returns
* a fixed body so the call succeeds without touching the network.
*/
class FetchContextCaptureStreamWrapper
{
/** @var resource|null populated by PHP when a context is passed */
public $context;
/** @var array|null options captured from the context on the last open */
public static $capturedOptions = null;
private $read = false;
public function stream_open($path, $mode, $options, &$opened_path)
{
self::$capturedOptions = isset($this->context) ? stream_context_get_options($this->context) : array();
return true;
}
public function stream_read($count)
{
if ($this->read) {
return '';
}
$this->read = true;
return 'BODY';
}
public function stream_eof()
{
return $this->read;
}
public function stream_stat()
{
return array();
}
public function url_stat($path, $flags)
{
return array();
}
}
@@ -1,84 +0,0 @@
<?php
/**
* Smarty PHPunit tests of the {html_image} function plugin
*
* @package PHPunit
*/
/**
* class for {html_image} tests
*
* @runTestsInSeparateProcess
* @preserveGlobalState disabled
* @backupStaticAttributes enabled
*/
class PluginFunctionHtmlImageTest extends PHPUnit_Smarty
{
public function setUp(): void
{
$this->setUpSmarty(__DIR__);
}
public function testInit()
{
$this->cleanDirs();
}
/**
* Passing both width and height skips the getimagesize() lookup, so no real
* image file is needed to render the tag.
*/
private function render($params)
{
$tpl = $this->smarty->createTemplate('eval:{html_image file=$file width=$width height=$height href=$href path_prefix=$path_prefix}');
$tpl->assign($params + array(
'file' => 'pic.jpg',
'width' => 44,
'height' => 68,
'href' => '',
'path_prefix' => '',
));
return $tpl->fetch();
}
public function testHrefIsEscaped()
{
$result = $this->render(array('href' => '"><script>alert(1)</script>'));
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('&lt;script&gt;', $result);
}
public function testWidthIsEscaped()
{
$result = $this->render(array('width' => '44" onload="alert(1)'));
$this->assertStringNotContainsString('onload="', $result);
$this->assertStringContainsString('&quot;', $result);
}
public function testHeightIsEscaped()
{
$result = $this->render(array('height' => '68" onmouseover="alert(1)'));
$this->assertStringNotContainsString('onmouseover="', $result);
}
public function testFileAndPathPrefixAreEscaped()
{
$result = $this->render(array('file' => 'pic.jpg"><script>alert(1)</script>', 'path_prefix' => '"><b>'));
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringNotContainsString('<b>', $result);
}
/**
* Benign values must be unchanged (no breakage, no double-encoding of an
* ampersand already present in a URL).
*/
public function testBenignValuesAreUnchanged()
{
$result = $this->render(array('width' => 44, 'height' => 68, 'href' => 'detail.php?id=1&page=2'));
$this->assertStringContainsString('width="44"', $result);
$this->assertStringContainsString('height="68"', $result);
$this->assertStringContainsString('src="pic.jpg"', $result);
$this->assertStringContainsString('href="detail.php?id=1&amp;page=2"', $result);
$this->assertStringNotContainsString('&amp;amp;', $result);
}
}
@@ -308,19 +308,6 @@ class PluginFunctionHtmlSelectDateTest extends PHPUnit_Smarty
$this->assertEquals($result, $tpl->fetch());
}
/**
* day_size/month_size/year_size are numeric HTML size attributes and are
* cast to int (consistent with html_select_time) so an untrusted value
* cannot break out of size="" (CWE-79).
*/
public function testSizeIsCastToInt()
{
$tpl = $this->smarty->createTemplate('eval:{html_select_date time=' . $this->now . ' year_size="5\" onfocus=alert(1) x=\""}');
$result = $tpl->fetch();
$this->assertStringContainsString('size="5"', $result);
$this->assertStringNotContainsString('onfocus', $result);
}
public function testFieldOrder()
{
$n = "\n";
@@ -1,33 +0,0 @@
<?php
/**
* Smarty PHPunit test reproducing issue #1189.
*
* When a parent template is {include}d, then a child template that {extends}
* the parent overrides a {block}, a subsequent {include} of the parent in the
* same render must still show the parent's block content.
*
* The block override from the extending child must not leak into the later
* include of the parent template.
*
* @see https://github.com/smarty-php/smarty/issues/1189
*
* @preserveGlobalState disabled
*/
class IncludeExtendsBlockLeakIssue1189Test extends PHPUnit_Smarty
{
public function setUp(): void
{
$this->setUpSmarty(__DIR__);
}
/**
* Sequence: include parent -> include child(extends parent) -> include parent.
* Expected: PARENT CHILD PARENT
* Bug (#1189): PARENT CHILD CHILD
*/
public function testBlockOverrideDoesNotLeakIntoLaterParentInclude()
{
$result = $this->smarty->fetch('top.tpl');
$this->assertSame('PARENT CHILD PARENT', preg_replace('/\s+/', ' ', trim($result)));
}
}
@@ -1,2 +0,0 @@
{extends file="parent.tpl"}
{block name=message}CHILD{/block}
@@ -1 +0,0 @@
{block name=message}PARENT{/block}
@@ -1 +0,0 @@
{include file="parent.tpl"} {include file="child.tpl"} {include file="parent.tpl"}
@@ -35,7 +35,7 @@ class ExtendsIssue419Test extends PHPUnit_Smarty
public function testextendsSecurity()
{
$this->expectException(SmartyException::class);
$this->expectExceptionMessageRegExp('/Unable to load.*/');
$this->expectExceptionMessageMatches('/Unable to load.*/');
$this->assertEquals('child', $this->smarty->fetch('string:{include "001_parent.tpl\', var_dump(shell_exec(\'ls\')), 1, 2, 3, 4, 5, 6);}}?>"}'));
}
+10
View File
@@ -0,0 +1,10 @@
FROM php:8.5-rc-cli-bullseye
## Basic utilities
RUN apt-get update -yqq && apt-get install -y curl apt-utils git zip unzip
## Composer
COPY ./utilities/testrunners/shared/install-composer.sh /root/install-composer.sh
WORKDIR /root
RUN sh ./install-composer.sh
RUN mv ./composer.phar /usr/local/bin/composer