diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a048d068..cb69227f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+- Security: when auto-escaping is enabled, the `escape` modifier (default `html` mode) was compiled to a no-op, leaving values completely unescaped when combined with `nofilter` (e.g. `{$var|escape|nl2br nofilter}`) (CWE-79). The modifier now always escapes and marks its output as safe, so auto-escaping does not double-escape it; `{$var|escape|nl2br}` now also keeps the `
` tags produced by `nl2br` unescaped, and the charset/`double_encode` parameters of an explicit `escape` are honored again [#1188](https://github.com/smarty-php/smarty/issues/1188)
+- Security: the internal "safe output" marker set by `|raw` and the escaping modifiers no longer leaks out of tags that do not print anything (e.g. `{if $x|escape:'url'}`, `{assign var=y value=$x|raw}`, `{$x|escape assign=y}`), which used to disable auto-escaping of the next printed variable (CWE-79)
+- Documented the `nofilter` tag flag and the `force` escape mode, and their interaction with auto-escaping [#1188](https://github.com/smarty-php/smarty/issues/1188)
## [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.
diff --git a/docs/api/configuring.md b/docs/api/configuring.md
index 540f6906d..7a89e493f 100644
--- a/docs/api/configuring.md
+++ b/docs/api/configuring.md
@@ -143,13 +143,21 @@ Enable auto-escaping for HTML as follows:
$smarty->setEscapeHtml(true);
```
-When auto-escaping is enabled, the `|escape` modifier's default mode (`html`) has no effect,
-to avoid double-escaping. It is possible to force it with the `force` mode.
+When auto-escaping is enabled, applying the
+[escape modifier](../designers/language-modifiers/language-modifier-escape.md) explicitly
+does not result in double-escaping: the modifier escapes the value, and Smarty then
+considers it safe and does not escape it again. It is possible to force a second
+escaping round with the `force` mode.
Other modes (`htmlall`, `url`, `urlpathinfo`, `quotes`, `javascript`) may be used
with the result you might expect, without double-escaping.
Even when auto-escaping is enabled, you might want to display the content of a variable without
-escaping it. To do so, use the `|raw` modifier.
+escaping it. To do so, use the `|raw` modifier, or the `nofilter` tag flag, which disables
+auto-escaping (and any variable filter) for the whole tag.
+
+Combining an explicit `|escape` with a modifier that produces HTML gives you escaped
+content while preserving the generated markup: `{$myVar|escape|nl2br}` outputs the
+escaped value with real `
` tags.
Examples (with auto-escaping enabled):
```smarty
@@ -159,7 +167,7 @@ Examples (with auto-escaping enabled):
{$myVar|escape:'html'}
{* no double-escaping on these statements *}
-{$var|escape:'htmlall'}
+{$myVar|escape:'htmlall'}
{$myVar|escape:'url'}
{$myVar|escape:'urlpathinfo'}
{$myVar|escape:'quotes'}
@@ -167,6 +175,11 @@ Examples (with auto-escaping enabled):
{* no escaping at all *}
{$myVar|raw}
+{$myVar nofilter}
+
+{* escaped content, with real
tags *}
+{$myVar|escape|nl2br}
+{$myVar|escape|nl2br nofilter}
{* force double-escaping *}
{$myVar|escape:'force'}
diff --git a/docs/designers/language-modifiers/language-modifier-escape.md b/docs/designers/language-modifiers/language-modifier-escape.md
index 18c98f1cb..4a1b385da 100644
--- a/docs/designers/language-modifiers/language-modifier-escape.md
+++ b/docs/designers/language-modifiers/language-modifier-escape.md
@@ -13,10 +13,20 @@ its `html`.
| Parameter Position | Type | Required | Possible Values | Default | Description |
|--------------------|---------|----------|----------------------------------------------------------------------------------------------------------------|---------|--------------------------------------------------------------------------------------|
-| 1 | string | No | `html`, `htmlall`, `url`, `urlpathinfo`, `quotes`, `hex`, `hexentity`, `javascript`, `mail` | `html` | This is the escape format to use. |
+| 1 | string | No | `html`, `htmlall`, `url`, `urlpathinfo`, `quotes`, `hex`, `hexentity`, `javascript`, `mail`, `force` | `html` | This is the escape format to use. |
| 2 | string | No | `ISO-8859-1`, `UTF-8`, and any character set supported by [`htmlentities()`](https://www.php.net/htmlentities) | `UTF-8` | The character set encoding passed to htmlentities() et. al. |
| 3 | boolean | No | FALSE | TRUE | Double encode entities from & to & (applies to `html` and `htmlall` only) |
+## Interaction with auto-escaping
+
+When [auto-escaping](../../api/configuring.md#enabling-auto-escaping) is enabled, a value
+explicitly escaped with this modifier is considered safe and is not escaped a second time.
+This also holds when the `nofilter` tag flag is used, so `{$myVar|escape|nl2br nofilter}`
+outputs escaped content with real `
` tags.
+
+The `force` format behaves like `html`, except that the value is not marked as safe:
+with auto-escaping enabled, the output ends up escaped twice.
+
## Examples
diff --git a/docs/designers/language-modifiers/language-modifier-raw.md b/docs/designers/language-modifiers/language-modifier-raw.md
index e9cce97d3..c7f992983 100644
--- a/docs/designers/language-modifiers/language-modifier-raw.md
+++ b/docs/designers/language-modifiers/language-modifier-raw.md
@@ -6,3 +6,10 @@ Prevents variable escaping when [auto-escaping](../../api/configuring.md#enablin
```smarty
{$myVar|raw}
```
+
+Alternatively, the `nofilter` tag flag disables auto-escaping, as well as any
+variable filter, for the whole tag:
+
+```smarty
+{$myVar nofilter}
+```
diff --git a/src/Compile/Modifier/EscapeModifierCompiler.php b/src/Compile/Modifier/EscapeModifierCompiler.php
index 4352359f0..44118724a 100644
--- a/src/Compile/Modifier/EscapeModifierCompiler.php
+++ b/src/Compile/Modifier/EscapeModifierCompiler.php
@@ -25,10 +25,11 @@ public function compile($params, \Smarty\Compiler\Template $compiler) {
switch ($esc_type) {
case 'html':
case 'force':
- // in case of auto-escaping, and without the 'force' option, no double-escaping
- if ($compiler->getSmarty()->escape_html && $esc_type != 'force')
- return $params[0];
- // otherwise, escape the variable
+ // unless the 'force' option is used, mark the output as already escaped,
+ // so auto-escaping does not double-escape it
+ if ($esc_type != 'force') {
+ $compiler->setRawOutput(true);
+ }
return 'htmlspecialchars((string)' . $params[ 0 ] . ', ENT_QUOTES, ' . var_export($char_set, true) . ', ' .
var_export($double_encode, true) . ')';
// no break
diff --git a/src/Compile/PrintExpressionCompiler.php b/src/Compile/PrintExpressionCompiler.php
index 3642551ee..ef3cb510d 100644
--- a/src/Compile/PrintExpressionCompiler.php
+++ b/src/Compile/PrintExpressionCompiler.php
@@ -58,7 +58,9 @@ public function compile($args, \Smarty\Compiler\Template $compiler, $parameter =
$output = $compiler->compileModifier($parameter['modifierlist'], $output);
}
if (isset($_attr['assign'])) {
- // assign output to variable
+ // assign output to variable; nothing is printed, so discard the raw output
+ // marker possibly set by a modifier, or it would leak into the next output
+ $compiler->setRawOutput(false);
return "assign({$_attr['assign']},{$output});?>";
} else {
// display value
diff --git a/src/Compiler/Template.php b/src/Compiler/Template.php
index efc52162b..3e0764759 100644
--- a/src/Compiler/Template.php
+++ b/src/Compiler/Template.php
@@ -481,6 +481,10 @@ public function compileTag($tag, $args, $parameter = []) {
$this->prefix_code = [];
$result = $this->compileTag2($tag, $args, $parameter);
$this->prefix_code = array_merge($this->prefix_code, array_pop($this->prefixCodeStack));
+ // tags handled here do not print expressions themselves, so discard the raw
+ // output marker possibly set by a modifier compiled as part of this tag
+ // (e.g. {if $x|escape:'url'}), or it would leak into the next output
+ $this->setRawOutput(false);
return $result;
}
diff --git a/tests/UnitTests/A_Core/AutoEscape/AutoEscapeTest.php b/tests/UnitTests/A_Core/AutoEscape/AutoEscapeTest.php
index 4a4ef0662..17f6e3951 100644
--- a/tests/UnitTests/A_Core/AutoEscape/AutoEscapeTest.php
+++ b/tests/UnitTests/A_Core/AutoEscape/AutoEscapeTest.php
@@ -125,4 +125,97 @@ public function testAutoEscapeSpecialEscape4() {
$this->assertEquals("<\\'", $this->smarty->fetch($tpl));
}
+ /**
+ * test nofilter disables autoescape
+ */
+ public function testAutoEscapeNofilter() {
+ $tpl = $this->smarty->createTemplate('eval:{$foo nofilter}');
+ $tpl->assign('foo', '');
+ $this->assertEquals("", $this->smarty->fetch($tpl));
+ }
+
+ /**
+ * test autoescape + explicit escape still escapes when nofilter is used
+ * @group issue1188
+ */
+ public function testAutoEscapeEscapeWithNofilter() {
+ $tpl = $this->smarty->createTemplate('eval:{$foo|escape nofilter}');
+ $tpl->assign('foo', '');
+ $this->assertEquals("<a@b.c>", $this->smarty->fetch($tpl));
+ }
+
+ /**
+ * test autoescape + escape followed by an HTML-producing modifier, with nofilter
+ * @group issue1188
+ */
+ public function testAutoEscapeEscapeNl2brWithNofilter() {
+ $tpl = $this->smarty->createTemplate('eval:{$foo|escape|nl2br nofilter}');
+ $tpl->assign('foo', "\nsecond line");
+ $this->assertEquals("<a@b.c>
\nsecond line", $this->smarty->fetch($tpl));
+ }
+
+ /**
+ * test autoescape + escape followed by an HTML-producing modifier, without nofilter
+ * @group issue1188
+ */
+ public function testAutoEscapeEscapeNl2br() {
+ $tpl = $this->smarty->createTemplate('eval:{$foo|escape|nl2br}');
+ $tpl->assign('foo', "\nsecond line");
+ $this->assertEquals("<a@b.c>
\nsecond line", $this->smarty->fetch($tpl));
+ }
+
+ /**
+ * test autoescape + escape modifier honors the double_encode parameter
+ * @group issue1188
+ */
+ public function testAutoEscapeEscapeHonorsDoubleEncodeParameter() {
+ $tpl = $this->smarty->createTemplate('eval:{$foo|escape:\'html\':\'UTF-8\':false}');
+ $tpl->assign('foo', '& ');
+ $this->assertEquals("& <a@b.c>", $this->smarty->fetch($tpl));
+ }
+
+ /**
+ * test that escape used in an assign attribute does not disable
+ * auto-escaping of the next printed variable
+ * @group issue1188
+ */
+ public function testRawOutputDoesNotLeakFromAssignAttribute() {
+ $tpl = $this->smarty->createTemplate('eval:{$foo|escape assign=bar}{$foo}');
+ $tpl->assign('foo', '');
+ $this->assertEquals("<a@b.c>", $this->smarty->fetch($tpl));
+ }
+
+ /**
+ * test that escape used in an {assign} tag does not disable
+ * auto-escaping of the next printed variable
+ * @group issue1188
+ */
+ public function testRawOutputDoesNotLeakFromAssignTag() {
+ $tpl = $this->smarty->createTemplate('eval:{assign var=bar value=$foo|escape}{$foo}');
+ $tpl->assign('foo', '');
+ $this->assertEquals("<a@b.c>", $this->smarty->fetch($tpl));
+ }
+
+ /**
+ * test that an escaping modifier used in an {if} condition does not disable
+ * auto-escaping of the next printed variable
+ * @group issue1188
+ */
+ public function testRawOutputDoesNotLeakFromIfCondition() {
+ $tpl = $this->smarty->createTemplate('eval:{if $foo|escape:\'url\'}{/if}{$foo}');
+ $tpl->assign('foo', '');
+ $this->assertEquals("<a@b.c>", $this->smarty->fetch($tpl));
+ }
+
+ /**
+ * test that the raw modifier used in an {assign} tag does not disable
+ * auto-escaping of the next printed variable
+ * @group issue1188
+ */
+ public function testRawOutputDoesNotLeakFromRawInAssignTag() {
+ $tpl = $this->smarty->createTemplate('eval:{assign var=bar value=$foo|raw}{$foo}');
+ $tpl->assign('foo', '');
+ $this->assertEquals("<a@b.c>", $this->smarty->fetch($tpl));
+ }
+
}