From 800eb2f444a2ea960d4dfd09d0853062c47bd974 Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Tue, 18 Mar 2025 22:51:22 +0900
Subject: [PATCH 01/11] Change behavior of \@mobile directive in template v2
#2510
---
common/framework/Template.php | 10 ++++++++++
.../framework/parsers/template/TemplateParser_v2.php | 4 ++--
tests/_data/template/v2example.compiled.html | 2 +-
tests/unit/framework/parsers/TemplateParserV2Test.php | 4 ++--
4 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/common/framework/Template.php b/common/framework/Template.php
index 8330aae8a..a94c8fbc6 100644
--- a/common/framework/Template.php
+++ b/common/framework/Template.php
@@ -945,6 +945,16 @@ class Template
return count($args) ? in_array((string)$validator_id, $args, true) : true;
}
+ /**
+ * Check if the current visitor is using a mobile device for v2.
+ *
+ * @return bool
+ */
+ protected function _v2_isMobile(): bool
+ {
+ return UA::isMobile() && (config('mobile.tablets') || !UA::isTablet());
+ }
+
/**
* Lang shortcut for v2.
*
diff --git a/common/framework/parsers/template/TemplateParser_v2.php b/common/framework/parsers/template/TemplateParser_v2.php
index b1d6c0c46..7e6d1dcbf 100644
--- a/common/framework/parsers/template/TemplateParser_v2.php
+++ b/common/framework/parsers/template/TemplateParser_v2.php
@@ -104,8 +104,8 @@ class TemplateParser_v2
'cannot' => ['if ($this->_v2_checkCapability(2, %s)):', 'endif;'],
'canany' => ['if ($this->_v2_checkCapability(3, %s)):', 'endif;'],
'guest' => ['if (!$this->user->isMember()):', 'endif;'],
- 'desktop' => ["if (!\\Context::get('m')):", 'endif;'],
- 'mobile' => ["if (\\Context::get('m')):", 'endif;'],
+ 'desktop' => ['if (!$this->_v2_isMobile()):', 'endif;'],
+ 'mobile' => ['if ($this->_v2_isMobile()):', 'endif;'],
'env' => ['if (!empty($_ENV[%s])):', 'endif;'],
'else' => ['else:'],
'elseif' => ['elseif (%s):'],
diff --git a/tests/_data/template/v2example.compiled.html b/tests/_data/template/v2example.compiled.html
index d4742e251..ef60e4677 100644
--- a/tests/_data/template/v2example.compiled.html
+++ b/tests/_data/template/v2example.compiled.html
@@ -42,7 +42,7 @@
_v2_include("include", $__filename, [(string)$__varname => $__var]); endforeach; })('incl/eachtest', $__Context->bar, 'var'); ?>
_v2_include("include", $__filename, [(string)$__varname => $__var]); endforeach; })('incl/eachtest', [], 'anything', 'incl/empty'); ?>
-
+_v2_isMobile()): ?>
The full class name is , config->context === 'JS' ? escape_js(Rhymix\Framework\Push::class) : htmlspecialchars(Rhymix\Framework\Push::class, \ENT_QUOTES, 'UTF-8', false); ?> really.
diff --git a/tests/unit/framework/parsers/TemplateParserV2Test.php b/tests/unit/framework/parsers/TemplateParserV2Test.php
index e6c7b0d9c..57d849151 100644
--- a/tests/unit/framework/parsers/TemplateParserV2Test.php
+++ b/tests/unit/framework/parsers/TemplateParserV2Test.php
@@ -858,10 +858,10 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
'@endmobile',
]);
$target = implode("\n", [
- "",
+ '_v2_isMobile()): ?>',
'4K or GTFO!
',
'',
- "",
+ '_v2_isMobile()): ?>',
'USB C is the way to go~
',
'',
]);
From 62eb6b2aaecb473fba143e46c1d952b5dcb0212c Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Tue, 18 Mar 2025 23:17:42 +0900
Subject: [PATCH 02/11] Add more context switches for inline scripts and styles
---
.../parsers/template/TemplateParser_v2.php | 33 ++++++++++++--
tests/_data/template/v2example.compiled.html | 4 +-
.../parsers/TemplateParserV2Test.php | 45 ++++++++++++++++---
3 files changed, 69 insertions(+), 13 deletions(-)
diff --git a/common/framework/parsers/template/TemplateParser_v2.php b/common/framework/parsers/template/TemplateParser_v2.php
index 7e6d1dcbf..5700f9fdf 100644
--- a/common/framework/parsers/template/TemplateParser_v2.php
+++ b/common/framework/parsers/template/TemplateParser_v2.php
@@ -179,20 +179,45 @@ class TemplateParser_v2
*/
protected function _addContextSwitches(string $content): string
{
- return preg_replace_callback('#(config->context = \'CSS\'; ?>' . $match[2] . 'config->context = \'HTML\'; ?>"';
+ }, $content);
+
+ // Inline scripts.
+ $content = preg_replace_callback('#(?<=\s)(href="javascript:|on[a-z]+=")([^"]*?)"#i', function($match) {
+ return $match[1] . 'config->context = \'JS\'; ?>' . $match[2] . 'config->context = \'HTML\'; ?>"';
+ }, $content);
+
+ // config->context = "HTML"; ?>' . $match[1];
+ return 'config->context = \'HTML\'; ?>' . $match[1];
+ }
+ else
+ {
+ return $match[1] . 'config->context = \'CSS\'; ?>';
+ }
+ }, $content);
+
+ // config->context = \'HTML\'; ?>' . $match[1];
}
elseif (!str_contains($match[2] ?? '', 'src="'))
{
- return $match[1] . 'config->context = "JS"; ?>';
+ return $match[1] . 'config->context = \'JS\'; ?>';
}
else
{
return $match[0];
}
}, $content);
+
+ return $content;
}
/**
@@ -203,7 +228,7 @@ class TemplateParser_v2
*/
protected static function _removeContextSwitches(string $content): string
{
- return preg_replace('#<\?php \$this->config->context = "[A-Z]+"; \?>#', '', $content);
+ return preg_replace('#<\?php \$this->config->context = \'[A-Z]+\'; \?>#', '', $content);
}
/**
diff --git a/tests/_data/template/v2example.compiled.html b/tests/_data/template/v2example.compiled.html
index ef60e4677..0929af928 100644
--- a/tests/_data/template/v2example.compiled.html
+++ b/tests/_data/template/v2example.compiled.html
@@ -60,7 +60,7 @@
]); ?>>
-
+config->context = 'HTML'; ?>
diff --git a/tests/unit/framework/parsers/TemplateParserV2Test.php b/tests/unit/framework/parsers/TemplateParserV2Test.php
index 57d849151..2d6a7a763 100644
--- a/tests/unit/framework/parsers/TemplateParserV2Test.php
+++ b/tests/unit/framework/parsers/TemplateParserV2Test.php
@@ -248,6 +248,34 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
$this->assertEquals($target, $this->_parse($source));
}
+ public function testContextSwitches()
+ {
+ // ';
+ $target = '';
+ $this->assertEquals($target, $this->_parse($source, true, false));
+
+ // Inline script in link href
+ $source = 'Hello';
+ $target = 'Hello';
+ $this->assertEquals($target, $this->_parse($source, true, false));
+
+ // Inline script in event handler
+ $source = 'Hello
';
+ $target = 'Hello
';
+ $this->assertEquals($target, $this->_parse($source, true, false));
+
+ // ';
+ $target = '';
+ $this->assertEquals($target, $this->_parse($source, true, false));
+
+ // Inline style
+ $source = '
';
+ $target = '
';
+ $this->assertEquals($target, $this->_parse($source, true, false));
+ }
+
public function testEchoStatements()
{
// Basic usage of XE-style single braces
@@ -366,11 +394,6 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
$target = "foo ?? ''); ?>";
$this->assertEquals($target, $this->_parse($source));
- // Context-aware escape
- $source = '';
- $target = '';
- $this->assertEquals($target, $this->_parse($source));
-
// JSON using context-aware escape
$source = '{{ $foo|json }}';
$target = implode('', [
@@ -573,7 +596,7 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
// Script tag with external path
$source = '';
- $target = '';
+ $target = '';
$this->assertEquals($target, $this->_parse($source));
// Absolute URL
@@ -1295,9 +1318,10 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
*
* @param string $source
* @param bool $force_v2 Disable version detection
+ * @param bool $remove_context_switches Remove context switches that make code difficult to read
* @return string
*/
- protected function _parse(string $source, bool $force_v2 = true): string
+ protected function _parse(string $source, bool $force_v2 = true, bool $remove_context_switches = true): string
{
$tmpl = new \Rhymix\Framework\Template('./tests/_data/template', 'empty.html');
if ($force_v2)
@@ -1309,6 +1333,13 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
{
$result = substr($result, strlen($this->prefix));
}
+
+ // Remove context switches.
+ if ($remove_context_switches)
+ {
+ $result = preg_replace('#<\?php \$this->config->context = \'[A-Z]+\'; \?>#', '', $result);
+ }
+
return $result;
}
From baadb36e378e651cd36eef78a52b0a872a42ed13 Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Tue, 18 Mar 2025 23:53:06 +0900
Subject: [PATCH 03/11] Improve escape_css() to accept most common CSS
expressions
---
common/functions.php | 2 +-
tests/unit/functions/FunctionsTest.php | 4 +++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/common/functions.php b/common/functions.php
index 84753ca64..8680c6f39 100644
--- a/common/functions.php
+++ b/common/functions.php
@@ -205,7 +205,7 @@ function escape($str, bool $double_escape = true, bool $except_lang_code = false
*/
function escape_css(string $str): string
{
- return preg_replace('/[^a-zA-Z0-9_.#\/-]/', '', (string)$str);
+ return preg_replace('/[^a-zA-Z0-9_.,#%\/\'()\x20-]/', '', (string)$str);
}
/**
diff --git a/tests/unit/functions/FunctionsTest.php b/tests/unit/functions/FunctionsTest.php
index d9657c2f5..000fe451f 100644
--- a/tests/unit/functions/FunctionsTest.php
+++ b/tests/unit/functions/FunctionsTest.php
@@ -50,8 +50,10 @@ class FunctionsTest extends \Codeception\Test\Unit
$this->assertEquals('$user_lang->userLang1234567890', escape('$user_lang->userLang1234567890', true, false));
$this->assertEquals('$user_lang->userLang1234567890', escape('$user_lang->userLang1234567890', true, true));
- $this->assertEquals('expressionalertXSS', escape_css('expression:alert("XSS")'));
+ $this->assertEquals('expressionalert(XSS)', escape_css('expression:alert("XSS")'));
$this->assertEquals('#123456', escape_css('#123456'));
+ $this->assertEquals('16px/160% Segoe UI, sans-serif font-style', escape_css('16px/160% Segoe UI, sans-serif; font-style'));
+ $this->assertEquals('box-shadow(0 1px 2px rgba(0, 0, 0, 0.15)', escape_css('box-shadow(0 1px 2px rgba(0, 0, 0, "0.15")'));
$this->assertEquals('hello\\\\world', escape_js('hello\\world'));
$this->assertEquals('\u003Cbr \/\u003E', escape_js('
'));
From 9689a1ed683aa733ed40a65f0d04b3fbbfae26e9 Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Wed, 19 Mar 2025 00:12:39 +0900
Subject: [PATCH 04/11] Implement better contextual escape for template v2
---
common/framework/Template.php | 17 +++++++++++++++++
.../parsers/template/TemplateParser_v2.php | 8 ++++----
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/common/framework/Template.php b/common/framework/Template.php
index a94c8fbc6..b0e2c9a06 100644
--- a/common/framework/Template.php
+++ b/common/framework/Template.php
@@ -955,6 +955,23 @@ class Template
return UA::isMobile() && (config('mobile.tablets') || !UA::isTablet());
}
+ /**
+ * Contextual escape function for v2.
+ *
+ * @param string $str
+ * @param string $type
+ * @return string
+ */
+ protected function _v2_escape(string $str, string $type = ''): string
+ {
+ switch ($this->config->context)
+ {
+ case 'CSS': return escape_css($str);
+ case 'JS': return escape_js($str);
+ default: return escape($str);
+ }
+ }
+
/**
* Lang shortcut for v2.
*
diff --git a/common/framework/parsers/template/TemplateParser_v2.php b/common/framework/parsers/template/TemplateParser_v2.php
index 5700f9fdf..926c3e0f4 100644
--- a/common/framework/parsers/template/TemplateParser_v2.php
+++ b/common/framework/parsers/template/TemplateParser_v2.php
@@ -782,7 +782,7 @@ class TemplateParser_v2
'json_encode(%s, self::$_json_options2) : ' .
'htmlspecialchars(json_encode(%s, self::$_json_options), \ENT_QUOTES, \'UTF-8\', false); ?>', $args, $args);
case 'lang':
- return sprintf('config->context === \'JS\' ? escape_js($this->_v2_lang(%s)) : $this->_v2_lang(%s); ?>', $args, $args);
+ return sprintf('config->context === \'HTML\' ? $this->_v2_lang(%s) : $this->_v2_escape($this->_v2_lang(%s)); ?>', $args, $args);
case 'dump':
return sprintf('', $args);
case 'dd':
@@ -790,7 +790,7 @@ class TemplateParser_v2
case 'stack':
return sprintf('', $args);
case 'url':
- return sprintf('config->context === \'JS\' ? escape_js(getNotEncodedUrl(%s)) : getUrl(%s); ?>', $args, $args);
+ return sprintf('config->context === \'HTML\' ? getUrl(%s) : $this->_v2_escape(getNotEncodedUrl(%s)); ?>', $args, $args);
default:
return $match[0];
}
@@ -968,11 +968,11 @@ class TemplateParser_v2
switch($option)
{
case 'autocontext':
- return "\$this->config->context === 'JS' ? escape_js({$str2}) : htmlspecialchars({$str}, \ENT_QUOTES, 'UTF-8', false)";
+ return "\$this->config->context === 'HTML' ? htmlspecialchars({$str}, \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape({$str2})";
case 'autocontext_json':
return "\$this->config->context === 'JS' ? {$str2} : htmlspecialchars({$str}, \ENT_QUOTES, 'UTF-8', false)";
case 'autocontext_lang':
- return "\$this->config->context === 'JS' ? escape_js({$str2}) : ({$str})";
+ return "\$this->config->context === 'HTML' ? ({$str}) : \$this->_v2_escape({$str2})";
case 'autoescape':
return "htmlspecialchars({$str}, \ENT_QUOTES, 'UTF-8', false)";
case 'autolang':
From 0bc3635b6ce11cc9868b484269e0884238903954 Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Wed, 19 Mar 2025 00:12:57 +0900
Subject: [PATCH 05/11] Update unit tests for improved contextual escape
---
tests/_data/template/v2example.compiled.html | 12 ++---
.../parsers/TemplateParserV2Test.php | 50 +++++++++----------
2 files changed, 31 insertions(+), 31 deletions(-)
diff --git a/tests/_data/template/v2example.compiled.html b/tests/_data/template/v2example.compiled.html
index 0929af928..2ad7c4bb9 100644
--- a/tests/_data/template/v2example.compiled.html
+++ b/tests/_data/template/v2example.compiled.html
@@ -15,16 +15,16 @@
{{ $foo }}
-
baz))): ?> class="foobar">
foo || $__Context->bar): ?>
Hello bar): ?>foo ?? ''; ?>
-
config->context === 'JS' ? escape_js(implode('|', array_map(function($i) { return strtoupper($i); }, $__Context->bar))) : htmlspecialchars(implode('|', array_map(function($i) { return strtoupper($i); }, $__Context->bar)), \ENT_QUOTES, 'UTF-8', false); ?>
+
config->context === 'HTML' ? htmlspecialchars(implode('|', array_map(function($i) { return strtoupper($i); }, $__Context->bar)), \ENT_QUOTES, 'UTF-8', false) : $this->_v2_escape(implode('|', array_map(function($i) { return strtoupper($i); }, $__Context->bar))); ?>
@@ -33,7 +33,7 @@
nosuchvar)): ?>

-
k >= 2): ?>class="config->context === 'JS' ? escape_js($__Context->val ?? '') : htmlspecialchars($__Context->val ?? '', \ENT_QUOTES, 'UTF-8', false); ?>">
+
k >= 2): ?>class="config->context === 'HTML' ? htmlspecialchars($__Context->val ?? '', \ENT_QUOTES, 'UTF-8', false) : $this->_v2_escape($__Context->val ?? ''); ?>">
_v2_incrLoopVar($__loop_RANDOM_LOOP_ID); endforeach; $this->_v2_removeLoopVar($__loop_RANDOM_LOOP_ID); unset($__loop_RANDOM_LOOP_ID); else: ?>Nothing here...
@@ -43,7 +43,7 @@
_v2_include("include", $__filename, [(string)$__varname => $__var]); endforeach; })('incl/eachtest', [], 'anything', 'incl/empty'); ?>
_v2_isMobile()): ?>
- The full class name is , config->context === 'JS' ? escape_js(Rhymix\Framework\Push::class) : htmlspecialchars(Rhymix\Framework\Push::class, \ENT_QUOTES, 'UTF-8', false); ?> really.
+ The full class name is , config->context === 'HTML' ? htmlspecialchars(Rhymix\Framework\Push::class, \ENT_QUOTES, 'UTF-8', false) : $this->_v2_escape(Rhymix\Framework\Push::class); ?> really.
@@ -61,6 +61,6 @@
diff --git a/tests/unit/framework/parsers/TemplateParserV2Test.php b/tests/unit/framework/parsers/TemplateParserV2Test.php
index 2d6a7a763..4892fbb87 100644
--- a/tests/unit/framework/parsers/TemplateParserV2Test.php
+++ b/tests/unit/framework/parsers/TemplateParserV2Test.php
@@ -280,7 +280,7 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
{
// Basic usage of XE-style single braces
$source = '{$var}';
- $target = "config->context === 'JS' ? escape_js(\$__Context->var ?? '') : htmlspecialchars(\$__Context->var ?? '', \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(\$__Context->var ?? '', \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(\$__Context->var ?? ''); ?>";
$this->assertEquals($target, $this->_parse($source));
// Single braces with space at beginning will not be parsed
@@ -290,22 +290,22 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
// Single braces with space at end are OK
$source = '{$var }';
- $target = "config->context === 'JS' ? escape_js(\$__Context->var ?? '') : htmlspecialchars(\$__Context->var ?? '', \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(\$__Context->var ?? '', \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(\$__Context->var ?? ''); ?>";
$this->assertEquals($target, $this->_parse($source));
// Correct handling of object property and array access
$source = '{Context::getRequestVars()->$foo[$bar]}';
- $target = "config->context === 'JS' ? escape_js(Context::getRequestVars()->{\$__Context->foo}[\$__Context->bar]) : htmlspecialchars(Context::getRequestVars()->{\$__Context->foo}[\$__Context->bar], \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(Context::getRequestVars()->{\$__Context->foo}[\$__Context->bar], \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(Context::getRequestVars()->{\$__Context->foo}[\$__Context->bar]); ?>";
$this->assertEquals($target, $this->_parse($source));
// Basic usage of Blade-style double braces
$source = '{{ $var }}';
- $target = "config->context === 'JS' ? escape_js(\$__Context->var ?? '') : htmlspecialchars(\$__Context->var ?? '', \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(\$__Context->var ?? '', \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(\$__Context->var ?? ''); ?>";
$this->assertEquals($target, $this->_parse($source));
// Double braces without spaces are OK
$source = '{{$var}}';
- $target = "config->context === 'JS' ? escape_js(\$__Context->var ?? '') : htmlspecialchars(\$__Context->var ?? '', \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(\$__Context->var ?? '', \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(\$__Context->var ?? ''); ?>";
$this->assertEquals($target, $this->_parse($source));
// Literal double braces
@@ -325,7 +325,7 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
// Multiline echo statement
$source = '{{ $foo ?' . "\n" . ' date($foo) :' . "\n" . ' toBool($bar) }}';
- $target = "config->context === 'JS' ? escape_js(\$__Context->foo ? date(\$__Context->foo) : toBool(\$__Context->bar)) : htmlspecialchars(\$__Context->foo ?\n date(\$__Context->foo) :\n toBool(\$__Context->bar), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(\$__Context->foo ?\n date(\$__Context->foo) :\n toBool(\$__Context->bar), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(\$__Context->foo ? date(\$__Context->foo) : toBool(\$__Context->bar)); ?>";
$this->assertEquals($target, $this->_parse($source));
}
@@ -367,11 +367,11 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
$this->assertEquals($target, $this->_parse($source));
$source = '{{ $lang->cmd_hello_world }}';
- $target = "config->context === 'JS' ? escape_js(\$__Context->lang->cmd_hello_world) : (\$__Context->lang->cmd_hello_world); ?>";
+ $target = "config->context === 'HTML' ? (\$__Context->lang->cmd_hello_world) : \$this->_v2_escape(\$__Context->lang->cmd_hello_world); ?>";
$this->assertEquals($target, $this->_parse($source));
$source = '{{ $user_lang->user_lang_1234567890 }}';
- $target = "config->context === 'JS' ? escape_js(\$__Context->user_lang->user_lang_1234567890 ?? '') : (\$__Context->user_lang->user_lang_1234567890 ?? ''); ?>";
+ $target = "config->context === 'HTML' ? (\$__Context->user_lang->user_lang_1234567890 ?? '') : \$this->_v2_escape(\$__Context->user_lang->user_lang_1234567890 ?? ''); ?>";
$this->assertEquals($target, $this->_parse($source));
// Escape
@@ -405,12 +405,12 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
// strip_tags
$source = '{{ $foo|strip }}';
- $target = "config->context === 'JS' ? escape_js(strip_tags(\$__Context->foo ?? '')) : htmlspecialchars(strip_tags(\$__Context->foo ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(strip_tags(\$__Context->foo ?? ''), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(strip_tags(\$__Context->foo ?? '')); ?>";
$this->assertEquals($target, $this->_parse($source));
// strip_tags (alternate name)
$source = '{{ $foo|upper|strip_tags }}';
- $target = "config->context === 'JS' ? escape_js(strip_tags(strtoupper(\$__Context->foo ?? ''))) : htmlspecialchars(strip_tags(strtoupper(\$__Context->foo ?? '')), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(strip_tags(strtoupper(\$__Context->foo ?? '')), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(strip_tags(strtoupper(\$__Context->foo ?? ''))); ?>";
$this->assertEquals($target, $this->_parse($source));
// Trim
@@ -420,12 +420,12 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
// URL encode
$source = '{{ $foo|trim|urlencode }}';
- $target = "config->context === 'JS' ? escape_js(rawurlencode(trim(\$__Context->foo ?? ''))) : htmlspecialchars(rawurlencode(trim(\$__Context->foo ?? '')), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(rawurlencode(trim(\$__Context->foo ?? '')), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(rawurlencode(trim(\$__Context->foo ?? ''))); ?>";
$this->assertEquals($target, $this->_parse($source));
// Lowercase
$source = '{{ $foo|trim|lower }}';
- $target = "config->context === 'JS' ? escape_js(strtolower(trim(\$__Context->foo ?? ''))) : htmlspecialchars(strtolower(trim(\$__Context->foo ?? '')), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(strtolower(trim(\$__Context->foo ?? '')), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(strtolower(trim(\$__Context->foo ?? ''))); ?>";
$this->assertEquals($target, $this->_parse($source));
// Uppercase
@@ -445,37 +445,37 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
// Array join (default joiner is comma)
$source = '{{ $foo|join }}';
- $target = "config->context === 'JS' ? escape_js(implode(', ', \$__Context->foo ?? '')) : htmlspecialchars(implode(', ', \$__Context->foo ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(implode(', ', \$__Context->foo ?? ''), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(implode(', ', \$__Context->foo ?? '')); ?>";
$this->assertEquals($target, $this->_parse($source));
// Array join (custom joiner)
$source = '{{ $foo|join:"!@!" }}';
- $target = "config->context === 'JS' ? escape_js(implode(\"!@!\", \$__Context->foo ?? '')) : htmlspecialchars(implode(\"!@!\", \$__Context->foo ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(implode(\"!@!\", \$__Context->foo ?? ''), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(implode(\"!@!\", \$__Context->foo ?? '')); ?>";
$this->assertEquals($target, $this->_parse($source));
// Date conversion (default format)
$source = '{{ $item->regdate | date }}';
- $target = "config->context === 'JS' ? escape_js(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), 'Y-m-d H:i:s')) : htmlspecialchars(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), 'Y-m-d H:i:s'), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), 'Y-m-d H:i:s'), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), 'Y-m-d H:i:s')); ?>";
$this->assertEquals($target, $this->_parse($source));
// Date conversion (custom format)
$source = "{{ \$item->regdate | date:'n/j H:i' }}";
- $target = "config->context === 'JS' ? escape_js(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), 'n/j H:i')) : htmlspecialchars(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), 'n/j H:i'), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), 'n/j H:i'), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), 'n/j H:i')); ?>";
$this->assertEquals($target, $this->_parse($source));
// Date conversion (custom format in variable)
$source = "{{ \$item->regdate | date:\$format }}";
- $target = "config->context === 'JS' ? escape_js(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), \$__Context->format)) : htmlspecialchars(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), \$__Context->format), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), \$__Context->format), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(getDisplayDateTime(ztime(\$__Context->item->regdate ?? ''), \$__Context->format)); ?>";
$this->assertEquals($target, $this->_parse($source));
// Number format
$source = '{{ $num | format }}';
- $target = "config->context === 'JS' ? escape_js(number_format(\$__Context->num ?? '')) : htmlspecialchars(number_format(\$__Context->num ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(number_format(\$__Context->num ?? ''), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(number_format(\$__Context->num ?? '')); ?>";
$this->assertEquals($target, $this->_parse($source));
// Number format (alternate name)
$source = '{{ $num | number_format }}';
- $target = "config->context === 'JS' ? escape_js(number_format(\$__Context->num ?? '')) : htmlspecialchars(number_format(\$__Context->num ?? ''), \ENT_QUOTES, 'UTF-8', false); ?>";
+ $target = "config->context === 'HTML' ? htmlspecialchars(number_format(\$__Context->num ?? ''), \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(number_format(\$__Context->num ?? '')); ?>";
$this->assertEquals($target, $this->_parse($source));
// Number format (custom format)
@@ -820,7 +820,7 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
]);
$target = implode("\n", [
"_v2_errorExists('email', 'login')): ?>",
- "config->context === 'JS' ? escape_js(\$__Context->message ?? '') : htmlspecialchars(\$__Context->message ?? '', \ENT_QUOTES, 'UTF-8', false); ?>",
+ "config->context === 'HTML' ? htmlspecialchars(\$__Context->message ?? '', \ENT_QUOTES, 'UTF-8', false) : \$this->_v2_escape(\$__Context->message ?? ''); ?>",
'',
]);
$this->assertEquals($target, $this->_parse($source));
@@ -999,17 +999,17 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
// Lang code with variable as name
$source = '@lang($var->name)';
- $target = 'config->context === \'JS\' ? escape_js($this->_v2_lang($__Context->var->name)) : $this->_v2_lang($__Context->var->name); ?>';
+ $target = 'config->context === \'HTML\' ? $this->_v2_lang($__Context->var->name) : $this->_v2_escape($this->_v2_lang($__Context->var->name)); ?>';
$this->assertEquals($target, $this->_parse($source));
// Lang code with literal name and variable
$source = "@lang('board.cmd_list_items', \$var)";
- $target = "config->context === 'JS' ? escape_js(\$this->_v2_lang('board.cmd_list_items', \$__Context->var)) : \$this->_v2_lang('board.cmd_list_items', \$__Context->var); ?>";
+ $target = "config->context === 'HTML' ? \$this->_v2_lang('board.cmd_list_items', \$__Context->var) : \$this->_v2_escape(\$this->_v2_lang('board.cmd_list_items', \$__Context->var)); ?>";
$this->assertEquals($target, $this->_parse($source));
// Lang code with class alias
$source = "@use('Rhymix\Framework\Lang', 'Lang')\n" . '@lang(Lang::getLang())
';
- $target = "\n" . 'config->context === \'JS\' ? escape_js($this->_v2_lang(Rhymix\Framework\Lang::getLang())) : $this->_v2_lang(Rhymix\Framework\Lang::getLang()); ?>
';
+ $target = "\n" . 'config->context === \'HTML\' ? $this->_v2_lang(Rhymix\Framework\Lang::getLang()) : $this->_v2_escape($this->_v2_lang(Rhymix\Framework\Lang::getLang())); ?>
';
$this->assertEquals($target, $this->_parse($source));
// Dump one variable
@@ -1024,12 +1024,12 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
// URL
$source = "@url(['mid' => 'foo', 'act' => 'dispBoardWrite'])";
- $target = "config->context === 'JS' ? escape_js(getNotEncodedUrl(['mid' => 'foo', 'act' => 'dispBoardWrite'])) : getUrl(['mid' => 'foo', 'act' => 'dispBoardWrite']); ?>";
+ $target = "config->context === 'HTML' ? getUrl(['mid' => 'foo', 'act' => 'dispBoardWrite']) : \$this->_v2_escape(getNotEncodedUrl(['mid' => 'foo', 'act' => 'dispBoardWrite'])); ?>";
$this->assertEquals($target, $this->_parse($source));
// URL old-style with variables
$source = "@url('', 'mid', \$mid, 'act', \$act])";
- $target = "config->context === 'JS' ? escape_js(getNotEncodedUrl('', 'mid', \$__Context->mid, 'act', \$__Context->act])) : getUrl('', 'mid', \$__Context->mid, 'act', \$__Context->act]); ?>";
+ $target = "config->context === 'HTML' ? getUrl('', 'mid', \$__Context->mid, 'act', \$__Context->act]) : \$this->_v2_escape(getNotEncodedUrl('', 'mid', \$__Context->mid, 'act', \$__Context->act])); ?>";
$this->assertEquals($target, $this->_parse($source));
}
From 49f57980dd78f14717e8edcee5c7c6f59280ac3c Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Wed, 19 Mar 2025 00:16:08 +0900
Subject: [PATCH 06/11] Add unit test for inline styles in template v2
---
tests/_data/template/v2example.compiled.html | 4 ++++
tests/_data/template/v2example.executed.html | 4 ++++
tests/_data/template/v2example.html | 4 ++++
3 files changed, 12 insertions(+)
diff --git a/tests/_data/template/v2example.compiled.html b/tests/_data/template/v2example.compiled.html
index 2ad7c4bb9..1c9dce8a3 100644
--- a/tests/_data/template/v2example.compiled.html
+++ b/tests/_data/template/v2example.compiled.html
@@ -64,3 +64,7 @@
const foo = 'config->context === 'HTML' ? htmlspecialchars($__Context->foo ?? '', \ENT_QUOTES, 'UTF-8', false) : $this->_v2_escape($__Context->foo ?? ''); ?>';
const bar = config->context === 'JS' ? json_encode($__Context->bar, self::$_json_options2) : htmlspecialchars(json_encode($__Context->bar, self::$_json_options), \ENT_QUOTES, 'UTF-8', false); ?>;
config->context = 'HTML'; ?>
+
+
diff --git a/tests/_data/template/v2example.executed.html b/tests/_data/template/v2example.executed.html
index 1baff6af2..3224403f0 100644
--- a/tests/_data/template/v2example.executed.html
+++ b/tests/_data/template/v2example.executed.html
@@ -60,3 +60,7 @@
const foo = 'FOOFOO\u003C\u0022FOO\u0022\u003EBAR';
const bar = ["Rhy","miX","is","da","BEST!"];
+
+
diff --git a/tests/_data/template/v2example.html b/tests/_data/template/v2example.html
index 2b57d75d1..8335308bf 100644
--- a/tests/_data/template/v2example.html
+++ b/tests/_data/template/v2example.html
@@ -64,3 +64,7 @@
const foo = '{{ $foo }}';
const bar = @json($bar);
+
+
From 01f34781df5474e4feebff6a1afccc2303316c9f Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Wed, 19 Mar 2025 00:42:01 +0900
Subject: [PATCH 07/11] Don't parse template v1 syntax (single braces) in
CSS/JS context
---
common/framework/parsers/template/TemplateParser_v2.php | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/common/framework/parsers/template/TemplateParser_v2.php b/common/framework/parsers/template/TemplateParser_v2.php
index 926c3e0f4..be9d4088c 100644
--- a/common/framework/parsers/template/TemplateParser_v2.php
+++ b/common/framework/parsers/template/TemplateParser_v2.php
@@ -822,6 +822,13 @@ class TemplateParser_v2
return $this->_arrangeOutputFilters($match);
}, $content);
+ // Exclude {single} curly braces in non-HTML contexts.
+ $content = preg_replace_callback('#(<\?php \$this->config->context = \'(?:CSS|JS)\'; \?>)(.*?)(<\?php \$this->config->context = \'HTML\'; \?>)#s', function($match) {
+ $warning = '';
+ $match[2] = preg_replace('#(?
Date: Wed, 19 Mar 2025 11:23:42 +0900
Subject: [PATCH 08/11] Use regular escape() for CSS contexts
---
common/framework/Template.php | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/common/framework/Template.php b/common/framework/Template.php
index b0e2c9a06..49ffb831b 100644
--- a/common/framework/Template.php
+++ b/common/framework/Template.php
@@ -959,14 +959,12 @@ class Template
* Contextual escape function for v2.
*
* @param string $str
- * @param string $type
* @return string
*/
- protected function _v2_escape(string $str, string $type = ''): string
+ protected function _v2_escape(string $str): string
{
switch ($this->config->context)
{
- case 'CSS': return escape_css($str);
case 'JS': return escape_js($str);
default: return escape($str);
}
From 04a7734b2e98be727f0f78d643ea8882e837850a Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Wed, 19 Mar 2025 11:23:56 +0900
Subject: [PATCH 09/11] Add unit test for contextual escape
---
.../_data/template/v2contextual.executed.html | 27 +++++++++++++++++
tests/_data/template/v2contextual.html | 30 +++++++++++++++++++
.../parsers/TemplateParserV2Test.php | 18 +++++++++++
3 files changed, 75 insertions(+)
create mode 100644 tests/_data/template/v2contextual.executed.html
create mode 100644 tests/_data/template/v2contextual.html
diff --git a/tests/_data/template/v2contextual.executed.html b/tests/_data/template/v2contextual.executed.html
new file mode 100644
index 000000000..69847da11
--- /dev/null
+++ b/tests/_data/template/v2contextual.executed.html
@@ -0,0 +1,27 @@
+
+
+Hello <"world"> ('string') variable.jpg
+
+
+
+Hello <"world"> ('string') variable.jpg
+
+
+
+
+
+
+
+ - Hello <"world"> ('string') variable.jpg
+ - Hello <"world"> ('string') variable.jpg
+
diff --git a/tests/_data/template/v2contextual.html b/tests/_data/template/v2contextual.html
new file mode 100644
index 000000000..7ca89745a
--- /dev/null
+++ b/tests/_data/template/v2contextual.html
@@ -0,0 +1,30 @@
+@version(2)
+
+
+ {{ $var }}
+
+
+
+
+ {{ $var }}
+
+
+
+
+
+
+
+
+ - {{ $var }}
+ - {{ $var|noescape }}
+
diff --git a/tests/unit/framework/parsers/TemplateParserV2Test.php b/tests/unit/framework/parsers/TemplateParserV2Test.php
index 4892fbb87..75a9e4bb5 100644
--- a/tests/unit/framework/parsers/TemplateParserV2Test.php
+++ b/tests/unit/framework/parsers/TemplateParserV2Test.php
@@ -1220,6 +1220,24 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
$this->assertStringContainsString('/tests/_data/template/css/style.scss', array_first($list)['file']);
}
+ public function testCompileContextualEscape()
+ {
+ // Contextual escape
+ $tmpl = new \Rhymix\Framework\Template('./tests/_data/template', 'v2contextual.html');
+ $tmpl->disableCache();
+ $tmpl->setVars([
+ 'var' => 'Hello <"world"> (\'string\') variable.jpg'
+ ]);
+
+ $executed_output = $tmpl->compile();
+ //Rhymix\Framework\Storage::write(\RX_BASEDIR . 'tests/_data/template/v2contextual.executed.html', $executed_output);
+ $expected = file_get_contents(\RX_BASEDIR . 'tests/_data/template/v2contextual.executed.html');
+ $this->assertEquals(
+ $this->_normalizeWhitespace($expected),
+ $this->_normalizeWhitespace($executed_output)
+ );
+ }
+
public function testCompileLang()
{
// Lang
From 3e052d2d0087369f95855006897bb4c4435eab9a Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Wed, 19 Mar 2025 12:44:17 +0900
Subject: [PATCH 10/11] Improve detection of template v1-style syntax in CSS/JS
contexts
---
common/framework/parsers/template/TemplateParser_v2.php | 6 ++++--
tests/_data/template/v2contextual.executed.html | 9 ++++-----
tests/_data/template/v2contextual.html | 9 ++++-----
3 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/common/framework/parsers/template/TemplateParser_v2.php b/common/framework/parsers/template/TemplateParser_v2.php
index be9d4088c..84b010e5d 100644
--- a/common/framework/parsers/template/TemplateParser_v2.php
+++ b/common/framework/parsers/template/TemplateParser_v2.php
@@ -824,8 +824,10 @@ class TemplateParser_v2
// Exclude {single} curly braces in non-HTML contexts.
$content = preg_replace_callback('#(<\?php \$this->config->context = \'(?:CSS|JS)\'; \?>)(.*?)(<\?php \$this->config->context = \'HTML\'; \?>)#s', function($match) {
- $warning = '';
- $match[2] = preg_replace('#(?' : '';
+ return '{' . $warning . $m[1] . '}';
+ }, $match[2]);
return $match[1] . $match[2] . $match[3];
}, $content);
diff --git a/tests/_data/template/v2contextual.executed.html b/tests/_data/template/v2contextual.executed.html
index 69847da11..f36d54714 100644
--- a/tests/_data/template/v2contextual.executed.html
+++ b/tests/_data/template/v2contextual.executed.html
@@ -8,17 +8,16 @@ Hello <"world"> ('string') variable.jpg
diff --git a/tests/_data/template/v2contextual.html b/tests/_data/template/v2contextual.html
index 7ca89745a..e8c690844 100644
--- a/tests/_data/template/v2contextual.html
+++ b/tests/_data/template/v2contextual.html
@@ -11,17 +11,16 @@
From e192bc0ff68903d47e025d0293abf2a8e091ab70 Mon Sep 17 00:00:00 2001
From: Kijin Sung
Date: Thu, 20 Mar 2025 14:01:56 +0900
Subject: [PATCH 11/11] Add widget directive for Template v2
---
.../parsers/template/TemplateParser_v2.php | 5 ++++-
modules/widget/widget.controller.php | 19 +++++++++++++------
.../parsers/TemplateParserV2Test.php | 5 +++++
3 files changed, 22 insertions(+), 7 deletions(-)
diff --git a/common/framework/parsers/template/TemplateParser_v2.php b/common/framework/parsers/template/TemplateParser_v2.php
index 84b010e5d..ff4dd6bde 100644
--- a/common/framework/parsers/template/TemplateParser_v2.php
+++ b/common/framework/parsers/template/TemplateParser_v2.php
@@ -760,6 +760,7 @@ class TemplateParser_v2
* @dd($var, $var, ...)
* @stack('name')
* @url(['mid' => $mid, 'act' => $act])
+ * @widget('name', $args)
*
* @param string $content
* @return string
@@ -773,7 +774,7 @@ class TemplateParser_v2
// Insert JSON, lang codes, and dumps.
$parentheses = self::_getRegexpForParentheses(2);
- $content = preg_replace_callback('#(?', $args);
case 'url':
return sprintf('config->context === \'HTML\' ? getUrl(%s) : $this->_v2_escape(getNotEncodedUrl(%s)); ?>', $args, $args);
+ case 'widget':
+ return sprintf('execute(%s); ?>', $args);
default:
return $match[0];
}
diff --git a/modules/widget/widget.controller.php b/modules/widget/widget.controller.php
index 9c8275062..ec0244638 100644
--- a/modules/widget/widget.controller.php
+++ b/modules/widget/widget.controller.php
@@ -450,14 +450,21 @@ class WidgetController extends Widget
// Save for debug run-time widget
$start = microtime(true);
- // urldecode the value of args haejum
- $object_vars = get_object_vars($args);
- if(count($object_vars))
+ // Type juggling
+ if (is_array($args))
{
- foreach($object_vars as $key => $val)
+ $args = (object)$args;
+ }
+
+ // Apply urldecode for backward compatibility
+ if ($escaped)
+ {
+ foreach (get_object_vars($args) ?: [] as $key => $val)
{
- if(in_array($key, array('widgetbox_content','body','class','style','widget_sequence','widget','widget_padding_left','widget_padding_top','widget_padding_bottom','widget_padding_right','widgetstyle','document_srl'))) continue;
- if($escaped) $args->{$key} = utf8RawUrlDecode($val);
+ if (!in_array($key, ['body', 'class', 'style', 'document_srl', 'widget', 'widget_sequence', 'widgetstyle', 'widgetbox_content', 'widget_padding_left', 'widget_padding_top', 'widget_padding_bottom', 'widget_padding_right']))
+ {
+ $args->{$key} = utf8RawUrlDecode($val);
+ }
}
}
diff --git a/tests/unit/framework/parsers/TemplateParserV2Test.php b/tests/unit/framework/parsers/TemplateParserV2Test.php
index 75a9e4bb5..ce6e68ab7 100644
--- a/tests/unit/framework/parsers/TemplateParserV2Test.php
+++ b/tests/unit/framework/parsers/TemplateParserV2Test.php
@@ -1031,6 +1031,11 @@ class TemplateParserV2Test extends \Codeception\Test\Unit
$source = "@url('', 'mid', \$mid, 'act', \$act])";
$target = "config->context === 'HTML' ? getUrl('', 'mid', \$__Context->mid, 'act', \$__Context->act]) : \$this->_v2_escape(getNotEncodedUrl('', 'mid', \$__Context->mid, 'act', \$__Context->act])); ?>";
$this->assertEquals($target, $this->_parse($source));
+
+ // Widget
+ $source = "@widget('login_info', ['skin' => 'default'])";
+ $target = "execute('login_info', ['skin' => 'default']); ?>";
+ $this->assertEquals($target, $this->_parse($source));
}
public function testComments()