From 6a5463e9058e29efe5ea38277db7e82ea5813d19 Mon Sep 17 00:00:00 2001 From: Amaury Bouchard Date: Mon, 13 Jul 2026 12:38:46 +0200 Subject: [PATCH] Fix escape modifier being a no-op under auto-escaping, breaking nofilter (#1188) When auto-escaping is enabled, the escape modifier (default 'html' mode) compiled to a no-op to avoid double-escaping. When the outer auto-escaping was suppressed (nofilter flag or |raw), nothing escaped the value at all, e.g. {$var|escape|nl2br nofilter} output raw HTML (CWE-79). The modifier now always emits htmlspecialchars() and marks its output as safe via setRawOutput(true) - the mechanism already used by the htmlall/ url/quotes/javascript modes - so auto-escaping skips it. This also keeps nl2br's
tags unescaped in {$var|escape|nl2br}, and honors the charset/double_encode parameters again. The 'force' mode keeps its forced double-escaping semantics. Also harden the raw output marker so it cannot leak out of tags that do not print anything ({if $x|escape:'url'}, {assign var=y value=$x|raw}, {$x|escape assign=y}): it is now discarded at the end of every tag compilation and in the assign branch of PrintExpressionCompiler. Restore the nofilter documentation and document the 'force' escape mode. Fixes #1188 --- CHANGELOG.md | 3 + docs/api/configuring.md | 21 ++++- .../language-modifier-escape.md | 12 ++- .../language-modifier-raw.md | 7 ++ .../Modifier/EscapeModifierCompiler.php | 9 +- src/Compile/PrintExpressionCompiler.php | 4 +- src/Compiler/Template.php | 4 + .../A_Core/AutoEscape/AutoEscapeTest.php | 93 +++++++++++++++++++ 8 files changed, 143 insertions(+), 10 deletions(-) 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)); + } + }