mirror of
https://github.com/smarty-php/smarty.git
synced 2026-07-05 16:00:55 +02:00
0460eb08cf
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.
140 lines
4.2 KiB
PHP
140 lines
4.2 KiB
PHP
<?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;
|
|
}
|
|
}
|