Compare commits

..

7 Commits

Author SHA1 Message Date
Simon Wisselink 1bca68beec Fix TypeError for non-array static_classes in Security policy 2026-06-29 12:34:58 +02:00
Simon Wisselink 3c9f77a2e0 Security: validate nested stream wrapper in stream: resource (CWE-22) (#1195)
The built-in stream: resource type let a template bypass Security stream
restrictions. BasePlugin::load() matches the 'stream' sysplugin before the
stream_get_wrappers()/isTrustedStream() check, so a resource such as
stream:php://filter/read=convert.base64-encode/resource=/path was opened by
StreamPlugin::getContent() via fopen() on the nested php:// wrapper without
ever validating it. This bypassed Security::$streams (including
Security::$streams = null) and allowed reading arbitrary local files.

Parse the wrapper scheme from the resolved path in StreamPlugin::getContent()
and validate it with Security::isTrustedStream() before fopen(), giving the
stream: resource the same check the direct wrapper path already receives.

Adds regression tests covering the disabled-streams bypass, the
not-on-allowlist case, and a positive test that an explicitly allowed wrapper
still works.
2026-06-29 11:47:32 +02:00
Simon Wisselink 042dff64a9 Merge branch 'release/5.8.3' 2026-06-29 00:15:09 +02:00
Simon Wisselink 1830aa7a12 version bump 2026-06-29 00:15:07 +02:00
Simon Wisselink b83ffdd2d6 requirements for building docs, switched test-runner from mutagen to basic docker compose 2026-06-29 00:14:40 +02:00
Simon Wisselink ac27e1e5b6 fixed a regression from #1189 where a child template's block override no longer applied to a template {include}d by the parent
Fixes #1192
2026-06-29 00:12:45 +02:00
Simon Wisselink 17fae11a38 update documentation for building and previewing with mkdocs, fix unit tests for windows 2026-06-24 10:33:14 +02:00
14 changed files with 232 additions and 15 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ GitHub Actions (`.github/workflows/ci.yml`): matrix of PHP 7.28.5 on ubuntu +
## Docs
Markdown in `docs/`, built with mkdocs + Material theme. Preview: `mkdocs serve`. Published via `mike deploy 5.x`.
Markdown in `docs/`, built with mkdocs + Material theme. Install the toolchain with `pip install -r docs/requirements.txt`, then preview with `mkdocs serve`. Published via `mike deploy 5.x`.
## Release
+4
View File
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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)
+5
View File
@@ -100,6 +100,11 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/smarty
The [docs](docs/index.md) are written in markdown, configured in [mkdocs.yml](mkdocs.yml) and published
to [GitHub pages](https://smarty-php.github.io/smarty) using [mkdocs](https://www.mkdocs.org/) and [mike](https://github.com/jimporter/mike).
You need Python to build the docs. Install the required packages first:
```bash
pip install -r docs/requirements.txt
```
To preview the docs while you are writing, run:
```bash
mkdocs serve
@@ -0,0 +1 @@
- 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)
@@ -0,0 +1 @@
- 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.
+14
View File
@@ -0,0 +1,14 @@
# 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
+1 -1
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 php72 ./run-tests.sh $@ && \
$COMPOSE_CMD run --rm php73 ./run-tests.sh $@ && \
+12 -1
View File
@@ -54,8 +54,19 @@ 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
View File
@@ -69,12 +69,8 @@ class InheritanceRuntime {
* @param array $blockNames outer level block name
*/
public function init(Template $tpl, $initChild, $blockNames = []) {
// if called while executing parent template it must be a sub-template with new inheritance root.
// A new root is started either by a child template ($initChild) or by a sub-template included
// outside of any block rendering (empty source stack); the latter must not inherit the leftover
// block overrides of a previously completed inheritance tree (see issue #1189).
if (($initChild || empty($this->sourceStack)) && $this->state === 3
&& (strpos($tpl->template_resource, 'extendsall') === false)) {
// if called while executing parent template it must be a sub-template with new inheritance root
if ($initChild && $this->state === 3 && (strpos($tpl->template_resource, 'extendsall') === false)) {
$tpl->setInheritance(clone $tpl->getSmarty()->getRuntime('Inheritance'));
$tpl->getInheritance()->init($tpl, $initChild, $blockNames);
return;
+6 -2
View File
@@ -52,7 +52,7 @@ class Security {
/**
* This is an array of trusted static classes.
* If empty access to all static classes is allowed.
* If set to 'none' none is allowed.
* To disable access to all static classes set $static_classes = null.
*
* @var array
*/
@@ -206,7 +206,11 @@ class Security {
* @return boolean true if class is trusted
*/
public function isTrustedStaticClass($class_name, $compiler) {
if (isset($this->static_classes)
// 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)
&& (empty($this->static_classes) || in_array($class_name, $this->static_classes))
) {
return true;
+1 -1
View File
@@ -54,7 +54,7 @@ class Smarty extends \Smarty\TemplateBase {
/**
* smarty version
*/
const SMARTY_VERSION = '5.8.2';
const SMARTY_VERSION = '5.8.3';
/**
* define caching modes
+5 -1
View File
@@ -260,7 +260,11 @@ class Template extends TemplateBase {
$tpl = $this->smarty->doCreateTemplate($template_name, $cache_id, $compile_id, $this, $caching, $cache_lifetime);
$tpl->inheritance = $this->getInheritance(); // re-use the same Inheritance object inside the inheritance tree
// Re-use the same Inheritance object only inside an active inheritance tree, i.e. when this
// (including) template already has one. A template outside any inheritance tree has no
// Inheritance object (null); sub-templates it {include}s must then start with their own, so an
// {include}d template that uses {block}/{extends} creates a fresh root via getInheritance().
$tpl->inheritance = $this->inheritance;
if ($scope) {
$tpl->defaultScope = $scope;
+40 -2
View File
@@ -243,6 +243,39 @@ 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(
@@ -273,7 +306,10 @@ class SecurityTest extends PHPUnit_Smarty
$this->expectException(\Smarty\Exception::class);
$this->expectExceptionMessage('not trusted file path');
$this->smarty->fetch('string:{include file="' . $link . '"}');
// 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) . '"}');
}
/**
@@ -292,7 +328,9 @@ class SecurityTest extends PHPUnit_Smarty
$this->smarty->security_policy->secure_dir = array($secureDir . DIRECTORY_SEPARATOR);
$this->assertEquals('inside-content', $this->smarty->fetch('string:{include file="' . $link . '"}'));
// 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) . '"}'));
}
/**
@@ -0,0 +1,139 @@
<?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;
}
}