mirror of
https://github.com/smarty-php/smarty.git
synced 2026-08-04 04:24:18 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bca68beec | |||
| 3c9f77a2e0 | |||
| 042dff64a9 | |||
| 1830aa7a12 | |||
| b83ffdd2d6 | |||
| ac27e1e5b6 | |||
| 17fae11a38 | |||
| 06594a1149 | |||
| 75a738ad44 | |||
| 11e69eca68 | |||
| 31e06fc087 | |||
| 99c048ce7a | |||
| 1c9b2ce1d3 |
@@ -73,8 +73,15 @@ GitHub Actions (`.github/workflows/ci.yml`): matrix of PHP 7.2–8.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
|
||||
|
||||
`./make-release.sh <version>` — only v5.x.x. Updates changelog and version constant, creates a merge commit and tag on `master`.
|
||||
|
||||
### Changelog
|
||||
|
||||
Every change that should appear in `CHANGELOG.md` must add a new markdown file under `changelog/`. At release time `utilities/update-changelog.php` concatenates all `changelog/*.md` files into the `## [Unreleased]` section, so:
|
||||
|
||||
- One file per change, containing a single line that starts with a dash (`- ...`). The filename is arbitrary; name it after the issue number (e.g. `1036.md`) when there is one, otherwise use a short descriptive slug.
|
||||
- Include a markdown link to the relevant issue when one exists, e.g. `[#1036](https://github.com/smarty-php/smarty/issues/1036)`. Omit the link when there is no public issue (e.g. embargoed security reports).
|
||||
|
||||
@@ -6,6 +6,17 @@ 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)
|
||||
- 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)
|
||||
- Fixed "Attempt to assign property step on null" error when using a {for} loop inside a block of an extended template [#1036](https://github.com/smarty-php/smarty/issues/1036)
|
||||
|
||||
|
||||
## [5.8.1] - 2026-06-23
|
||||
- Re-activated unit tests for user literals, which were previously disabled due to a bug in refactoring to v5.
|
||||
- fixed a bug where child template's block content leaked into subsequent rendering of the parent template [#1189](https://github.com/smarty-php/smarty/issues/1189)
|
||||
|
||||
@@ -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.
|
||||
@@ -27,6 +27,13 @@ which item(s) are selected by default as well.
|
||||
- All parameters that are not in the list above are printed as
|
||||
name/value-pairs inside each of the created <input\>-tags.
|
||||
|
||||
> **Security note**
|
||||
>
|
||||
> The `separator` attribute is written into the generated HTML without escaping,
|
||||
> so it can contain markup such as `separator='<br />'`. If its value originates
|
||||
> from untrusted input, escape it yourself first to avoid cross-site scripting
|
||||
> (XSS). Option values and labels are escaped automatically.
|
||||
|
||||
## Examples
|
||||
```php
|
||||
<?php
|
||||
|
||||
@@ -28,6 +28,13 @@ selected by default as well.
|
||||
- All parameters that are not in the list above are output as
|
||||
name/value-pairs inside each of the created `<input>`-tags.
|
||||
|
||||
> **Security note**
|
||||
>
|
||||
> The `separator` attribute is written into the generated HTML without escaping,
|
||||
> so it can contain markup such as `separator='<br />'`. If its value originates
|
||||
> from untrusted input, escape it yourself first to avoid cross-site scripting
|
||||
> (XSS). Option values and labels are escaped automatically.
|
||||
|
||||
## Examples
|
||||
|
||||
```php
|
||||
|
||||
@@ -47,6 +47,14 @@ name/value-pairs inside the `<select>` tags of day, month and year.
|
||||
> There is an useful php function on the [date tips page](../../appendixes/tips.md)
|
||||
> for converting `{html_select_date}` form values to a timestamp.
|
||||
|
||||
> **Security note**
|
||||
>
|
||||
> The `*_extra` attributes, `field_separator`/`option_separator`, and any
|
||||
> unrecognised parameter (which is emitted as a raw attribute on the `<select>`
|
||||
> tag) are written into the generated HTML without escaping. If any of these
|
||||
> values originate from untrusted input, escape them yourself first to avoid
|
||||
> cross-site scripting (XSS).
|
||||
|
||||
## Exaples
|
||||
|
||||
Template code
|
||||
|
||||
@@ -47,6 +47,14 @@ parseable by PHP's [`strtotime()`](https://www.php.net/strtotime).
|
||||
| meridian\_empty | null | If supplied then the first element of the meridian's select-box has this value as it's label and "" as it's value. This is useful to make the select-box read "Please select an meridian" for example. |
|
||||
|
||||
|
||||
> **Security note**
|
||||
>
|
||||
> The `*_extra` attributes, `field_separator`/`option_separator`, and any
|
||||
> unrecognised parameter (which is emitted as a raw attribute on the `<select>`
|
||||
> tag) are written into the generated HTML without escaping. If any of these
|
||||
> values originate from untrusted input, escape them yourself first to avoid
|
||||
> cross-site scripting (XSS).
|
||||
|
||||
## Examples
|
||||
|
||||
```smarty
|
||||
|
||||
@@ -31,6 +31,15 @@ dumps an array of data into an HTML `<table>`.
|
||||
- `trailpad` is the value put into the trailing cells on the last
|
||||
table row if there are any present.
|
||||
|
||||
> **Security note**
|
||||
>
|
||||
> The `loop`/`cols` data and the `caption`, `trailpad`, `table_attr`, `tr_attr`,
|
||||
> `td_attr` and `th_attr` attributes are written into the generated HTML without
|
||||
> escaping (this is by design — e.g. `table_attr='border="1"'`). If any of these
|
||||
> values originate from untrusted input, escape them yourself first (e.g. with the
|
||||
> [`escape`](../language-modifiers/language-modifier-escape.md) modifier) to avoid
|
||||
> cross-site scripting (XSS).
|
||||
|
||||
## Examples
|
||||
|
||||
```php
|
||||
|
||||
@@ -24,6 +24,14 @@ spiders to lift email addresses off of a site.
|
||||
> you can use hex encoding too.
|
||||
|
||||
|
||||
> **Security note**
|
||||
>
|
||||
> The `extra` attribute is written into the generated `<a>` tag without escaping,
|
||||
> so that you can add attributes such as `extra='class="mailto"'`. If you pass a
|
||||
> value that originates from untrusted input, escape it yourself first (e.g. with
|
||||
> the [`escape`](../language-modifiers/language-modifier-escape.md) modifier) to
|
||||
> avoid cross-site scripting (XSS).
|
||||
|
||||
## Examples
|
||||
|
||||
```smarty
|
||||
|
||||
@@ -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
|
||||
@@ -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 $@ && \
|
||||
|
||||
+15
-12
@@ -51,8 +51,9 @@ class ForTag extends Base {
|
||||
$var = $_statement['var'];
|
||||
$index = '';
|
||||
}
|
||||
$output .= "\$_smarty_tpl->assign($var, null);\n";
|
||||
$output .= "\$_smarty_tpl->tpl_vars[$var]->value{$index} = {$_statement['value']};\n";
|
||||
$itemVar = "\$_smarty_tpl->getVariable({$var})";
|
||||
$output .= "\$_smarty_tpl->assign($var, []);\n";
|
||||
$output .= "{$itemVar}->value{$index} = {$_statement['value']};\n";
|
||||
}
|
||||
if (is_array($_attr['var'])) {
|
||||
$var = $_attr['var']['var'];
|
||||
@@ -61,7 +62,8 @@ class ForTag extends Base {
|
||||
$var = $_attr['var'];
|
||||
$index = '';
|
||||
}
|
||||
$output .= "if ($_attr[ifexp]) {\nfor (\$_foo=true;$_attr[ifexp]; \$_smarty_tpl->tpl_vars[$var]->value{$index}$_attr[step]) {\n";
|
||||
$itemVar = "\$_smarty_tpl->getVariable({$var})";
|
||||
$output .= "if ($_attr[ifexp]) {\nfor (\$_foo=true;$_attr[ifexp]; {$itemVar}->value{$index}$_attr[step]) {\n";
|
||||
} else {
|
||||
$_statement = $_attr['start'];
|
||||
if (is_array($_statement['var'])) {
|
||||
@@ -71,21 +73,22 @@ class ForTag extends Base {
|
||||
$var = $_statement['var'];
|
||||
$index = '';
|
||||
}
|
||||
$output .= "\$_smarty_tpl->assign($var, null);";
|
||||
$itemVar = "\$_smarty_tpl->getVariable({$var})";
|
||||
$output .= "\$_smarty_tpl->assign($var, []);";
|
||||
if (isset($_attr['step'])) {
|
||||
$output .= "\$_smarty_tpl->tpl_vars[$var]->step = $_attr[step];";
|
||||
$output .= "{$itemVar}->step = $_attr[step];";
|
||||
} else {
|
||||
$output .= "\$_smarty_tpl->tpl_vars[$var]->step = 1;";
|
||||
$output .= "{$itemVar}->step = 1;";
|
||||
}
|
||||
if (isset($_attr['max'])) {
|
||||
$output .= "\$_smarty_tpl->tpl_vars[$var]->total = (int) min(ceil((\$_smarty_tpl->tpl_vars[$var]->step > 0 ? $_attr[to]+1 - ($_statement[value]) : $_statement[value]-($_attr[to])+1)/abs(\$_smarty_tpl->tpl_vars[$var]->step)),$_attr[max]);\n";
|
||||
$output .= "{$itemVar}->total = (int) min(ceil(({$itemVar}->step > 0 ? $_attr[to]+1 - ($_statement[value]) : $_statement[value]-($_attr[to])+1)/abs({$itemVar}->step)),$_attr[max]);\n";
|
||||
} else {
|
||||
$output .= "\$_smarty_tpl->tpl_vars[$var]->total = (int) ceil((\$_smarty_tpl->tpl_vars[$var]->step > 0 ? $_attr[to]+1 - ($_statement[value]) : $_statement[value]-($_attr[to])+1)/abs(\$_smarty_tpl->tpl_vars[$var]->step));\n";
|
||||
$output .= "{$itemVar}->total = (int) ceil(({$itemVar}->step > 0 ? $_attr[to]+1 - ($_statement[value]) : $_statement[value]-($_attr[to])+1)/abs({$itemVar}->step));\n";
|
||||
}
|
||||
$output .= "if (\$_smarty_tpl->tpl_vars[$var]->total > 0) {\n";
|
||||
$output .= "for (\$_smarty_tpl->tpl_vars[$var]->value{$index} = $_statement[value], \$_smarty_tpl->tpl_vars[$var]->iteration = 1;\$_smarty_tpl->tpl_vars[$var]->iteration <= \$_smarty_tpl->tpl_vars[$var]->total;\$_smarty_tpl->tpl_vars[$var]->value{$index} += \$_smarty_tpl->tpl_vars[$var]->step, \$_smarty_tpl->tpl_vars[$var]->iteration++) {\n";
|
||||
$output .= "\$_smarty_tpl->tpl_vars[$var]->first = \$_smarty_tpl->tpl_vars[$var]->iteration === 1;";
|
||||
$output .= "\$_smarty_tpl->tpl_vars[$var]->last = \$_smarty_tpl->tpl_vars[$var]->iteration === \$_smarty_tpl->tpl_vars[$var]->total;";
|
||||
$output .= "if ({$itemVar}->total > 0) {\n";
|
||||
$output .= "for ({$itemVar}->value{$index} = $_statement[value], {$itemVar}->iteration = 1;{$itemVar}->iteration <= {$itemVar}->total;{$itemVar}->value{$index} += {$itemVar}->step, {$itemVar}->iteration++) {\n";
|
||||
$output .= "{$itemVar}->first = {$itemVar}->iteration === 1;";
|
||||
$output .= "{$itemVar}->last = {$itemVar}->iteration === {$itemVar}->total;";
|
||||
}
|
||||
$output .= '?>';
|
||||
|
||||
|
||||
@@ -189,7 +189,22 @@ class Fetch extends Base {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$content = @file_get_contents($params['file']);
|
||||
if ($protocol && isset($template->getSmarty()->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([
|
||||
'http' => [
|
||||
'follow_location' => 0,
|
||||
'max_redirects' => 1,
|
||||
],
|
||||
]);
|
||||
$content = @file_get_contents($params['file'], false, $context);
|
||||
} else {
|
||||
$content = @file_get_contents($params['file']);
|
||||
}
|
||||
if ($content === false) {
|
||||
throw new Exception("{fetch} cannot read resource '" . $params['file'] . "'");
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class HtmlImage extends Base {
|
||||
break;
|
||||
case 'link':
|
||||
case 'href':
|
||||
$prefix = '<a href="' . $_val . '">';
|
||||
$prefix = '<a href="' . smarty_function_escape_special_chars($_val) . '">';
|
||||
$suffix = '</a>';
|
||||
break;
|
||||
default:
|
||||
@@ -143,7 +143,12 @@ class HtmlImage extends Base {
|
||||
$width = round($width * $_resize);
|
||||
$height = round($height * $_resize);
|
||||
}
|
||||
return $prefix . '<img src="' . $path_prefix . $file . '" alt="' . $alt . '" width="' . $width . '" height="' .
|
||||
$height . '"' . $extra . ' />' . $suffix;
|
||||
// $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;
|
||||
}
|
||||
}
|
||||
@@ -120,9 +120,6 @@ class HtmlSelectDate extends Base {
|
||||
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':
|
||||
@@ -140,6 +137,13 @@ class HtmlSelectDate extends Base {
|
||||
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':
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+30
-4
@@ -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;
|
||||
@@ -474,12 +478,34 @@ class Security {
|
||||
* @throws \Smarty\Exception
|
||||
*/
|
||||
private function _checkDir($filepath, $dirs) {
|
||||
$directory = dirname($this->smarty->_realpath($filepath, true)) . DIRECTORY_SEPARATOR;
|
||||
// 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 = [];
|
||||
foreach ($dirs as $dir => $unused) {
|
||||
$trusted[$dir] = true;
|
||||
if (($dirRealpath = @realpath($dir)) !== false) {
|
||||
$trusted[rtrim($dirRealpath, '\\/') . DIRECTORY_SEPARATOR] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$_directory = [];
|
||||
if (!preg_match('#[\\\\/][.][.][\\\\/]#', $directory)) {
|
||||
while (true) {
|
||||
// test if the directory is trusted
|
||||
if (isset($dirs[$directory])) {
|
||||
if (isset($trusted[$directory])) {
|
||||
return $_directory;
|
||||
}
|
||||
// abort if we've reached root
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ class Smarty extends \Smarty\TemplateBase {
|
||||
/**
|
||||
* smarty version
|
||||
*/
|
||||
const SMARTY_VERSION = '5.8.1';
|
||||
const SMARTY_VERSION = '5.8.3';
|
||||
|
||||
/**
|
||||
* define caching modes
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
@@ -256,6 +289,88 @@ class SecurityTest extends PHPUnit_Smarty
|
||||
);
|
||||
$this->assertEquals("templates_3", $this->smarty->fetch('string:{include file="templates_3/dirname.tpl"}'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
[$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(\Smarty\Exception::class);
|
||||
$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()
|
||||
{
|
||||
[$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 [secureDir, outsideFile].
|
||||
*/
|
||||
private function createSymlinkFixture(string $outsideContent): array
|
||||
{
|
||||
$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));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/** @var string|null temp dir created by createSymlinkFixture(), removed in tearDown */
|
||||
private $symlinkFixtureDir = null;
|
||||
|
||||
/**
|
||||
* test template file exits
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -191,4 +191,45 @@ class CompileForTest extends PHPUnit_Smarty
|
||||
array("{for \$x=-1;\$x>=0;\$x--}{\$x}{forelse}{\$buh}{/for}", "buh", 'T14', $i++),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test {for} inside an inheritance (extends) template.
|
||||
*
|
||||
* The {for} tag pokes loop bookkeeping (step, total, value, ...) directly
|
||||
* onto its loop Variable. In an extended template the child block renders
|
||||
* with SCOPE_PARENT, so the loop variable is assigned to the parent and was
|
||||
* not resolvable locally, causing 'assign property on null'.
|
||||
*
|
||||
* @see https://github.com/smarty-php/smarty/issues/1036
|
||||
*
|
||||
* @dataProvider dataForInheritance
|
||||
*/
|
||||
public function testForInheritance($code, $result, $testName, $caching)
|
||||
{
|
||||
$this->smarty->caching = $caching;
|
||||
$tpl = $this->smarty->createTemplate($code);
|
||||
$this->assertEquals($result, $this->smarty->fetch($tpl), "test - {$testName}");
|
||||
}
|
||||
|
||||
public function dataForInheritance()
|
||||
{
|
||||
$parent = 'extends:string:{block name="content"}{/block}';
|
||||
$cases = array(
|
||||
array('to', '{for $i=0 to 3}{$i}{/for}', '0123'),
|
||||
array('step', '{for $i=0 to 3 step 2}{$i}{/for}', '02'),
|
||||
array('max', '{for $i=0 to 30 max=3}{$i}{/for}', '012'),
|
||||
array('nested', '{for $i=0 to 1}{for $y=0 to 3}{$y}{/for}{/for}', '01230123'),
|
||||
array('legacy', '{for $i=0; $i<4; $i++}{$i}{/for}', '0123'),
|
||||
);
|
||||
$data = array();
|
||||
foreach (array(false, true) as $caching) {
|
||||
foreach ($cases as $case) {
|
||||
$code = $parent . '|string:{block name="content"}' . $case[1] . '{/block}';
|
||||
$name = $case[0] . ($caching ? ' (caching)' : '');
|
||||
$data[] = array($code, $case[2], $name, $caching);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -64,4 +64,93 @@ 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).
|
||||
*/
|
||||
public function testFetchRemoteDisablesRedirectsUnderSecurity()
|
||||
{
|
||||
FetchContextCaptureStreamWrapper::$capturedOptions = null;
|
||||
stream_wrapper_register('ssrftest', FetchContextCaptureStreamWrapper::class);
|
||||
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).
|
||||
*/
|
||||
public function testFetchRemoteKeepsDefaultBehaviorWithoutSecurity()
|
||||
{
|
||||
FetchContextCaptureStreamWrapper::$capturedOptions = null;
|
||||
stream_wrapper_register('ssrftest', FetchContextCaptureStreamWrapper::class);
|
||||
try {
|
||||
$result = $this->smarty->fetch('string:{fetch file="ssrftest://allowed/data"}');
|
||||
|
||||
$this->assertSame('BODY', $result);
|
||||
$this->assertSame([], 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) : [];
|
||||
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 [];
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
/**
|
||||
* Smarty PHPunit tests of the {html_image} function plugin
|
||||
*/
|
||||
|
||||
/**
|
||||
* class for {html_image} tests
|
||||
*/
|
||||
class PluginFunctionHtmlImageTest extends PHPUnit_Smarty
|
||||
{
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->setUpSmarty(__DIR__);
|
||||
$this->smarty->setErrorReporting(E_ALL & ~E_DEPRECATED);
|
||||
}
|
||||
|
||||
public function testInit()
|
||||
{
|
||||
$this->assertTrue($this->smarty instanceof \Smarty\Smarty);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 + [
|
||||
'file' => 'pic.jpg',
|
||||
'width' => 44,
|
||||
'height' => 68,
|
||||
'href' => '',
|
||||
'path_prefix' => '',
|
||||
]);
|
||||
return $tpl->fetch();
|
||||
}
|
||||
|
||||
public function testHrefIsEscaped()
|
||||
{
|
||||
$result = $this->render(['href' => '"><script>alert(1)</script>']);
|
||||
$this->assertStringNotContainsString('<script>', $result);
|
||||
$this->assertStringContainsString('<script>', $result);
|
||||
}
|
||||
|
||||
public function testWidthIsEscaped()
|
||||
{
|
||||
$result = $this->render(['width' => '44" onload="alert(1)']);
|
||||
$this->assertStringNotContainsString('onload="', $result);
|
||||
$this->assertStringContainsString('"', $result);
|
||||
}
|
||||
|
||||
public function testHeightIsEscaped()
|
||||
{
|
||||
$result = $this->render(['height' => '68" onmouseover="alert(1)']);
|
||||
$this->assertStringNotContainsString('onmouseover="', $result);
|
||||
}
|
||||
|
||||
public function testFileAndPathPrefixAreEscaped()
|
||||
{
|
||||
$result = $this->render(['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(['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&page=2"', $result);
|
||||
$this->assertStringNotContainsString('&amp;', $result);
|
||||
}
|
||||
}
|
||||
+21
@@ -630,4 +630,25 @@ class PluginFunctionHtmlSelectDateTest extends PHPUnit_Smarty
|
||||
$tpl->assign('date_array', $date_array);
|
||||
$this->assertEquals($result, $tpl->fetch());
|
||||
}
|
||||
|
||||
/**
|
||||
* year_size/month_size/day_size are numeric HTML size attributes and must be
|
||||
* cast to int (like html_select_time), so a value cannot break out of size="…".
|
||||
*/
|
||||
public function testSizeAttributesCannotBreakOut()
|
||||
{
|
||||
$tpl = $this->smarty->createTemplate('eval:{html_select_date time=$time year_size=$size}');
|
||||
$tpl->assign('time', mktime(0, 0, 0, 1, 1, 2010));
|
||||
$tpl->assign('size', '2"><script>alert(1)</script>');
|
||||
$result = $tpl->fetch();
|
||||
$this->assertStringNotContainsString('<script>', $result);
|
||||
$this->assertStringContainsString('size="2"', $result);
|
||||
}
|
||||
|
||||
public function testBenignSizeAttributeIsKept()
|
||||
{
|
||||
$tpl = $this->smarty->createTemplate('eval:{html_select_date time=$time year_size=3}');
|
||||
$tpl->assign('time', mktime(0, 0, 0, 1, 1, 2010));
|
||||
$this->assertStringContainsString('size="3"', $tpl->fetch());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user