mirror of
https://github.com/smarty-php/smarty.git
synced 2026-08-04 20:44:13 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7494850e2 |
@@ -6,15 +6,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [5.8.4] - 2026-06-29
|
||||
- Fixed a `TypeError` on PHP 8 when `Security::$static_classes` was set to a non-array value (e.g. the string `'none'`) to disable static class access; any non-array value now cleanly denies access. Use `Security::$static_classes = null` to disable access to all static classes.
|
||||
- Security: the built-in `stream:` resource type now validates the nested stream wrapper against the security policy, so a template such as `stream:php://filter/...` can no longer bypass `Security::$streams` (including `Security::$streams = null`) to read local files (CWE-22)
|
||||
|
||||
|
||||
## [5.8.3] - 2026-06-28
|
||||
- fixed a regression from #1189 where a child template's block override no longer applied to a template {include}d by the parent [#1192](https://github.com/smarty-php/smarty/issues/1192)
|
||||
|
||||
|
||||
## [5.8.2] - 2026-06-24
|
||||
- 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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- fixed a regression from #1189 where a child template's block override no longer applied to a template {include}d by the parent [#1192](https://github.com/smarty-php/smarty/issues/1192)
|
||||
@@ -1,3 +1,4 @@
|
||||
version: "2"
|
||||
services:
|
||||
base:
|
||||
build:
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Python dependencies for building/previewing the docs.
|
||||
#
|
||||
# pip install -r docs/requirements.txt
|
||||
# mkdocs serve # local preview
|
||||
# mike deploy 5.x # publish
|
||||
#
|
||||
# pymdown-extensions must be >=11: earlier releases pass filename=None to
|
||||
# Pygments, and Pygments >=2.19 then crashes with
|
||||
# "'NoneType' object has no attribute 'replace'" on any untitled code block.
|
||||
mkdocs>=1.6
|
||||
mkdocs-material>=9.7
|
||||
pymdown-extensions>=11
|
||||
Pygments>=2.19
|
||||
mike>=2.2
|
||||
@@ -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="docker compose"
|
||||
COMPOSE_CMD="mutagen-compose"
|
||||
|
||||
$COMPOSE_CMD run --rm php72 ./run-tests.sh $@ && \
|
||||
$COMPOSE_CMD run --rm php73 ./run-tests.sh $@ && \
|
||||
|
||||
@@ -54,19 +54,8 @@ class StreamPlugin extends RecompiledPlugin {
|
||||
$filepath = str_replace(':', '://', $source->getFullResourceName());
|
||||
}
|
||||
|
||||
// Validate the underlying stream wrapper against the security policy.
|
||||
// When the built-in "stream" resource type is used (e.g.
|
||||
// stream:php://filter/...), BasePlugin::load() matches the "stream"
|
||||
// sysplugin before the stream_get_wrappers()/isTrustedStream() check,
|
||||
// so the nested wrapper ("php" here) is never validated. Parse the
|
||||
// wrapper scheme from the resolved path and check it explicitly so that
|
||||
// e.g. Security::$streams = null blocks it before fopen() (CWE-22/-441).
|
||||
$smarty = $source->getSmarty();
|
||||
if (is_object($smarty->security_policy) && ($_pos = strpos($filepath, '://')) !== false) {
|
||||
$smarty->security_policy->isTrustedStream(strtolower(substr($filepath, 0, $_pos)));
|
||||
}
|
||||
|
||||
$t = '';
|
||||
// the availability of the stream has already been checked in Smarty\Resource\Base::fetch()
|
||||
$fp = fopen($filepath, 'r+');
|
||||
if ($fp) {
|
||||
while (!feof($fp) && ($current_line = fgets($fp)) !== false) {
|
||||
|
||||
+2
-6
@@ -52,7 +52,7 @@ class Security {
|
||||
/**
|
||||
* This is an array of trusted static classes.
|
||||
* If empty access to all static classes is allowed.
|
||||
* To disable access to all static classes set $static_classes = null.
|
||||
* If set to 'none' none is allowed.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
@@ -206,11 +206,7 @@ class Security {
|
||||
* @return boolean true if class is trusted
|
||||
*/
|
||||
public function isTrustedStaticClass($class_name, $compiler) {
|
||||
// Only an array enables access: an empty array allows all classes, a
|
||||
// populated array is an allowlist. Any other value (null, or the
|
||||
// documented "none") denies all. Using is_array() rather than isset()
|
||||
// also avoids a PHP 8 TypeError from passing a non-array to in_array().
|
||||
if (is_array($this->static_classes)
|
||||
if (isset($this->static_classes)
|
||||
&& (empty($this->static_classes) || in_array($class_name, $this->static_classes))
|
||||
) {
|
||||
return true;
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ class Smarty extends \Smarty\TemplateBase {
|
||||
/**
|
||||
* smarty version
|
||||
*/
|
||||
const SMARTY_VERSION = '5.8.4';
|
||||
const SMARTY_VERSION = '5.8.2';
|
||||
|
||||
/**
|
||||
* define caching modes
|
||||
|
||||
+1
-1
@@ -535,7 +535,7 @@ class Template extends TemplateBase {
|
||||
*/
|
||||
public function getRightDelimiter()
|
||||
{
|
||||
return $this->right_delimiter ?? $this->getSmarty()->getRightDelimiter();
|
||||
return $this->right_delimiter ?? $this->getSmarty()->getRightDelimiter();;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -243,39 +243,6 @@ class SecurityTest extends PHPUnit_Smarty
|
||||
$this->smarty->fetch('string:{$smarty.template_object::square(5)}');
|
||||
}
|
||||
|
||||
/**
|
||||
* The default (empty array) allows access to all static classes. Documents
|
||||
* the backwards-compatible behaviour.
|
||||
*/
|
||||
public function testStaticClassAllowedByDefault()
|
||||
{
|
||||
$this->smarty->security_policy->static_classes = array();
|
||||
$this->assertEquals('25', $this->smarty->fetch('string:{mysecuritystaticclass::square(5)}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting static_classes to null disables access to all static classes.
|
||||
*/
|
||||
public function testStaticClassDeniedWhenNull()
|
||||
{
|
||||
$this->expectException(\Smarty\Exception::class);
|
||||
$this->expectExceptionMessage("access to static class 'mysecuritystaticclass' not allowed by security setting");
|
||||
$this->smarty->security_policy->static_classes = null;
|
||||
$this->smarty->fetch('string:{mysecuritystaticclass::square(5)}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: a non-array value such as the string 'none' must deny access
|
||||
* cleanly instead of raising a PHP 8 TypeError from in_array().
|
||||
*/
|
||||
public function testStaticClassDeniedWhenNonArray()
|
||||
{
|
||||
$this->expectException(\Smarty\Exception::class);
|
||||
$this->expectExceptionMessage("access to static class 'mysecuritystaticclass' not allowed by security setting");
|
||||
$this->smarty->security_policy->static_classes = 'none';
|
||||
$this->smarty->fetch('string:{mysecuritystaticclass::square(5)}');
|
||||
}
|
||||
|
||||
public function testChangedTrustedDirectory()
|
||||
{
|
||||
$this->smarty->security_policy->secure_dir = array(
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Smarty PHPunit tests for stream-wrapper security
|
||||
*
|
||||
* @package PHPunit
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression tests ensuring the built-in "stream" resource type cannot be used
|
||||
* to bypass the stream-wrapper restrictions enforced by Smarty Security.
|
||||
*
|
||||
* @runTestsInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
* @backupStaticAttributes enabled
|
||||
*/
|
||||
class StreamWrapperSecurityTest extends PHPUnit_Smarty
|
||||
{
|
||||
private $secretFile;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->setUpSmarty(__DIR__);
|
||||
$this->secretFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR
|
||||
. 'smarty_stream_secret_' . getmypid() . '_' . uniqid() . '.txt';
|
||||
file_put_contents($this->secretFile, 'STREAM-WRAPPER-SECRET');
|
||||
$this->smarty->setForceCompile(true);
|
||||
$this->smarty->enableSecurity();
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
if ($this->secretFile && file_exists($this->secretFile)) {
|
||||
unlink($this->secretFile);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function phpFilterUri()
|
||||
{
|
||||
return 'php://filter/read=convert.base64-encode/resource=' . $this->secretFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity: a direct php:// stream is rejected when all streams are disabled.
|
||||
*/
|
||||
public function testDirectPhpStreamIsBlocked()
|
||||
{
|
||||
$this->smarty->security_policy->streams = null;
|
||||
$this->expectException(\Smarty\Exception::class);
|
||||
$this->expectExceptionMessage("stream 'php' not allowed by security setting");
|
||||
$this->smarty->fetch('string:{include file="' . $this->phpFilterUri() . '"}');
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in "stream" resource type must not let a nested php:// wrapper
|
||||
* escape the same restriction (CWE-22 / wrapper bypass).
|
||||
*/
|
||||
public function testStreamResourceCannotBypassDisabledStreams()
|
||||
{
|
||||
$this->smarty->security_policy->streams = null;
|
||||
$this->expectException(\Smarty\Exception::class);
|
||||
$this->expectExceptionMessage("stream 'php' not allowed by security setting");
|
||||
$this->smarty->fetch('string:{include file="stream:' . $this->phpFilterUri() . '"}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Even when some streams are allowed, a nested wrapper that is not on the
|
||||
* allowlist must still be rejected through the "stream" resource type.
|
||||
*/
|
||||
public function testStreamResourceRejectsWrapperNotOnAllowlist()
|
||||
{
|
||||
$this->smarty->security_policy->streams = array('file');
|
||||
$this->expectException(\Smarty\Exception::class);
|
||||
$this->expectExceptionMessage("stream 'php' not allowed by security setting");
|
||||
$this->smarty->fetch('string:{include file="stream:' . $this->phpFilterUri() . '"}');
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper explicitly allowed by the policy must keep working through the
|
||||
* "stream" resource type (no backwards-compatibility break).
|
||||
*/
|
||||
public function testStreamResourceAllowsWhitelistedWrapper()
|
||||
{
|
||||
stream_wrapper_register('smartyteststream', 'StreamSecurityTestWrapper');
|
||||
try {
|
||||
$this->smarty->security_policy->streams = array('smartyteststream');
|
||||
$this->smarty->assign('name', 'World');
|
||||
$result = $this->smarty->fetch('string:{include file="stream:smartyteststream://x"}');
|
||||
$this->assertEquals('hello World', $result);
|
||||
} finally {
|
||||
stream_wrapper_unregister('smartyteststream');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal read-only stream wrapper returning a fixed template body, used by the
|
||||
* allowlist (positive) test above.
|
||||
*/
|
||||
#[AllowDynamicProperties]
|
||||
class StreamSecurityTestWrapper
|
||||
{
|
||||
public $context;
|
||||
private $pos = 0;
|
||||
private $data = 'hello {$name}';
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
$this->pos = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function stream_read($count)
|
||||
{
|
||||
$ret = substr($this->data, $this->pos, $count);
|
||||
$this->pos += strlen($ret);
|
||||
return $ret;
|
||||
}
|
||||
|
||||
public function stream_eof()
|
||||
{
|
||||
return $this->pos >= strlen($this->data);
|
||||
}
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user